fix(web): let tool string arguments be typed as multiline (#2042)

A string argument renders as a single-line `<input>`, which swallows Enter —
so a value containing newlines could not be typed at all.

Each string field now carries an enlarge button (ImEnlarge2) to the left of its
clear button. Clicking it swaps the input for an autosizing text area that keeps
the field's value, label, and length constraints. It is deliberately one-way:
shrinking back would have to either discard newlines the user typed or keep a
value the field can no longer show, and both are worse than staying enlarged.

Enlarging is per-field and resets with `resetKey`, so it cannot carry across to
another tool's same-named field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 09:05:31 -04:00
commit 5340283fb4
3 changed files with 301 additions and 14 deletions

View file

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { EnlargeButton } from "./EnlargeButton";
const meta: Meta<typeof EnlargeButton> = {
title: "Elements/EnlargeButton",
component: EnlargeButton,
args: { onClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof EnlargeButton>;
// Default: labelled "Enlarge", reachable by keyboard, and clickable.
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole("button", { name: "Enlarge" });
await expect(button).not.toHaveAttribute("tabindex", "-1");
await userEvent.click(button);
},
};
// A per-field accessible name, for a form where several fields each carry one.
export const CustomLabel: Story = {
args: { ariaLabel: "Enlarge Query" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("button", { name: "Enlarge Query" }),
).toBeVisible();
},
};

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { EnlargeButton } from "./EnlargeButton";
describe("EnlargeButton", () => {
it('renders with the "Enlarge" accessible name', () => {
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
expect(screen.getByRole("button", { name: "Enlarge" })).toBeInTheDocument();
});
it("stays in the keyboard tab order", () => {
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
expect(screen.getByRole("button", { name: "Enlarge" })).not.toHaveAttribute(
"tabindex",
"-1",
);
});
it("invokes onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithMantine(<EnlargeButton onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "Enlarge" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("allows the accessible name to be overridden", () => {
renderWithMantine(
<EnlargeButton onClick={vi.fn()} ariaLabel="Enlarge Query" />,
);
expect(
screen.getByRole("button", { name: "Enlarge Query" }),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,45 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { ImEnlarge2 } from "react-icons/im";
export interface EnlargeButtonProps {
/** Swap the single-line input this sits in for a multiline text area. */
onClick: () => void;
/**
* Overrides the accessible name (aria-label), which defaults to the tooltip
* text ("Enlarge"). Pass a per-field name (e.g. including the field's label)
* when several inputs in one form each carry a button, so assistive tech can
* tell them apart; the visible tooltip stays the plain verb.
*/
ariaLabel?: string;
}
const EnlargeActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "sm",
});
/**
* The enlarge affordance shown to the left of a text input's ClearButton
* (#2042). Clicking it replaces the single-line input with a multiline text
* area, so a value containing newlines can be typed — a plain `<input>` swallows
* Enter, which left string arguments effectively single-line.
*
* Deliberately not a toggle: shrinking back would have to decide what to do with
* the newlines already typed, and either answer (silently discard them, or keep
* a value the field can no longer display) is worse than staying enlarged.
*
* Unlike ClearButton it stays in the keyboard tab order. Clearing a field has a
* keyboard equivalent (select-all, delete), so #1487 could take that button out
* of the tab order without cost; entering multiline mode has none, so removing
* this one would put the feature out of reach of keyboard users entirely.
*/
export function EnlargeButton({ onClick, ariaLabel }: EnlargeButtonProps) {
return (
<Tooltip label="Enlarge">
<EnlargeActionIcon aria-label={ariaLabel ?? "Enlarge"} onClick={onClick}>
<ImEnlarge2 size={12} />
</EnlargeActionIcon>
</Tooltip>
);
}