Merge pull request #2139 from modelcontextprotocol/v2/feat/2138-enlarge-button-tab-order

fix(web): take the enlarge button out of the tab order
This commit is contained in:
Cliff Hall 2026-08-26 10:46:10 -04:00 • committed by GitHub
commit 45823eca77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 608 additions and 29 deletions

View file

@ -11,12 +11,15 @@ const meta: Meta<typeof EnlargeButton> = {
export default meta;
type Story = StoryObj<typeof EnlargeButton>;
// Default: labelled "Enlarge", reachable by keyboard, and clickable.
// Default: labelled "Enlarge", clickable, and out of the tab order (#2138) —
// a form's string fields each carry one, so leaving them in doubled the tab
// stops between adjacent fields. SchemaForm binds Enter on the field itself as
// the keyboard route in.
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 expect(button).toHaveAttribute("tabindex", "-1");
await userEvent.click(button);
},
};

View file

@ -9,14 +9,33 @@ describe("EnlargeButton", () => {
expect(screen.getByRole("button", { name: "Enlarge" })).toBeInTheDocument();
});
it("stays in the keyboard tab order", () => {
// Out of the tab order like ClearButton (#2138) — a form's string fields each
// carry one, so leaving them in doubled the stops between adjacent fields.
it("is out of the keyboard tab order", () => {
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
expect(screen.getByRole("button", { name: "Enlarge" })).not.toHaveAttribute(
expect(screen.getByRole("button", { name: "Enlarge" })).toHaveAttribute(
"tabindex",
"-1",
);
});
// The property that matters is skipped-by-Tab, not the attribute that
// implements it — asserted by driving a real Tab past a neighbouring input.
it("is skipped when tabbing through the surrounding form", async () => {
const user = userEvent.setup();
renderWithMantine(
<>
<input aria-label="Before" />
<EnlargeButton onClick={vi.fn()} />
<input aria-label="After" />
</>,
);
screen.getByRole("textbox", { name: "Before" }).focus();
await user.tab();
expect(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
});
it("invokes onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();

View file

@ -23,6 +23,7 @@ const EnlargeActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "sm",
tabIndex: -1,
});
/**
@ -35,10 +36,18 @@ const EnlargeActionIcon = ActionIcon.withProps({
* 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.
* `tabIndex={-1}` keeps it clickable but out of the keyboard tab order, the same
* as ClearButton (#1487) — a form's string fields each carry one, so leaving
* them in doubled the tab stops between one field and the next (#2138).
*
* That is only affordable because the keyboard keeps its own way in: SchemaForm
* binds Enter on the single-line field, which enlarges it and enters the
* newline in one go. Nothing else is listening for that key there, and it is
* the one a user presses trying to type the newline the field cannot hold — so
* the gesture that fails is the gesture that fixes it. Do not take this button
* out of the tab order anywhere that binding is absent; unlike clearing
* (select-all, delete) there is no built-in equivalent, and multiline mode
* would simply be unreachable by keyboard.
*/
export function EnlargeButton({
onClick,

View file

@ -1932,22 +1932,26 @@ describe("SchemaForm multiline strings (#2042)", () => {
).toBeInTheDocument();
});
// The button unmounts in the same commit that mounts the text area, so
// without an explicit hand-off a keyboard user is left focused on nothing.
// The button unmounts in the same commit that mounts the text area, taking
// the focused element with it, so without an explicit hand-off the user is
// left focused on nothing. (The keyboard route hands off from the field
// instead — see the #2138 suite.)
it("moves focus into the text area, caret last, when activated", async () => {
const user = userEvent.setup();
renderWithMantine(<StringHarness />);
await user.type(screen.getByRole("textbox", { name: /Note/ }), "typed");
await user.tab();
expect(screen.getByRole("button", { name: "Enlarge Note" })).toHaveFocus();
await user.keyboard("{Enter}");
await user.click(screen.getByRole("button", { name: "Enlarge Note" }));
const textarea = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
expect(textarea.tagName).toBe("TEXTAREA");
expect(textarea).toHaveFocus();
// Clicking asks for a bigger box and nothing else: the value is carried
// over untouched, and only Enter — the key that means "new line" — enters
// one (#2138).
expect(textarea.value).toBe("typed");
expect(textarea.selectionStart).toBe("typed".length);
// And the caret really is at the end: typing appends rather than prepends.
@ -1970,3 +1974,415 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(screen.getByRole("button", { name: "Enlarge Note" })).toBeDisabled();
});
});
// The enlarge button is clickable but out of the tab order, so tabbing runs
// field to field; Enter in a single-line string field takes its place as the
// keyboard route into multiline mode (#2138).
describe("SchemaForm enlarge keyboard access (#2138)", () => {
const twoStringSchema: InspectorFormSchema = {
type: "object",
properties: {
note: { type: "string", title: "Note" },
summary: { type: "string", title: "Summary" },
},
};
function TwoStringHarness({ disabled }: { disabled?: boolean }) {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<SchemaForm
schema={twoStringSchema}
values={values}
onChange={setValues}
disabled={disabled}
/>
);
}
// Seeds a value and holds it in real state. A stub `onChange` would leave the
// field's value frozen, which makes "the value did not change" assertions
// pass whatever the component does.
function SeededHarness({
maxLength,
initial = "abc",
}: {
maxLength?: number;
initial?: unknown;
}) {
const [values, setValues] = useState<Record<string, unknown>>({
note: initial,
});
return (
<SchemaForm
schema={{
type: "object",
properties: { note: { type: "string", title: "Note", maxLength } },
}}
values={values}
onChange={setValues}
/>
);
}
const noteField = () =>
screen.getByRole("textbox", { name: /Note/ }) as HTMLTextAreaElement;
// The defect the issue was filed for: an extra stop per field, on every
// string field of every tool form.
it("tabs from one string field to the next, skipping the enlarge buttons", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
noteField().focus();
await user.tab();
expect(screen.getByRole("textbox", { name: /Summary/ })).toHaveFocus();
});
// A populated field renders the clear button too, so this is the widest the
// right section ever gets — and still must not add a stop.
it("skips both right-section buttons when the field holds a value", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "typed");
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
await user.tab();
expect(screen.getByRole("textbox", { name: /Summary/ })).toHaveFocus();
});
it("enlarges the focused field when Enter is pressed", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
expect(noteField().tagName).toBe("INPUT");
noteField().focus();
await user.keyboard("{Enter}");
expect(noteField().tagName).toBe("TEXTAREA");
});
// Enlarging is per field: the key must not reach the neighbour.
it("enlarges only the focused field", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
noteField().focus();
await user.keyboard("{Enter}");
expect(screen.getByRole("textbox", { name: /Summary/ }).tagName).toBe(
"INPUT",
);
});
// The other gesture a user reaches for when an input will not take a newline.
it("enlarges on Shift+Enter too", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
noteField().focus();
await user.keyboard("{Shift>}{Enter}{/Shift}");
expect(noteField().tagName).toBe("TEXTAREA");
});
// Those chords read as "submit" and stay free for a consumer to bind to
// running the tool; claiming them would silently enlarge a field instead.
it.each([
["Ctrl", "{Control>}{Enter}{/Control}"],
["Meta", "{Meta>}{Enter}{/Meta}"],
["Alt", "{Alt>}{Enter}{/Alt}"],
])("leaves %s+Enter alone", async (_name, keys) => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
noteField().focus();
await user.keyboard(keys);
expect(noteField().tagName).toBe("INPUT");
});
// Enter is also how an IME commits the candidate being composed, so acting on
// it there would enlarge the field and insert a newline every time a
// Japanese/Chinese/Korean user finished a word. userEvent has no composition
// mode, so the event is dispatched directly with the flag React reads.
it("ignores the Enter that commits an IME composition", () => {
renderWithMantine(<TwoStringHarness />);
fireEvent.keyDown(noteField(), { key: "Enter", isComposing: true });
expect(noteField().tagName).toBe("INPUT");
expect(noteField().value).toBe("");
});
// WebKit is reported to fire `compositionend` before the committing keydown,
// so `isComposing` is already false there and the guard above cannot see it.
// 229 is the older sentinel for "this key went to the IME", which that event
// still carries (#2139 review).
it("ignores a committing IME keydown that only reports keyCode 229", () => {
renderWithMantine(<TwoStringHarness />);
fireEvent.keyDown(noteField(), {
key: "Enter",
isComposing: false,
keyCode: 229,
});
expect(noteField().tagName).toBe("INPUT");
expect(noteField().value).toBe("");
});
// The control for the sentinel: a real Enter reports 13 and must still work,
// so the guard cannot be swallowing ordinary keystrokes.
it("still enlarges on an Enter reporting keyCode 13", () => {
renderWithMantine(<TwoStringHarness />);
fireEvent.keyDown(noteField(), { key: "Enter", keyCode: 13 });
expect(noteField().tagName).toBe("TEXTAREA");
});
// Same event without the flag, to prove the guard above is what turned it
// away rather than fireEvent simply not reaching the handler.
it("still enlarges on an Enter that is not composing", () => {
renderWithMantine(<TwoStringHarness />);
fireEvent.keyDown(noteField(), { key: "Enter", isComposing: false });
expect(noteField().tagName).toBe("TEXTAREA");
});
it("leaves the field alone for any other key", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "text ");
await user.keyboard("{Escape}{ArrowDown} ");
expect(noteField().tagName).toBe("INPUT");
});
// The keystroke has to mean what it says. Enlarging without entering the
// newline consumes the key and leaves the next word running on from the last
// one — the user pressed "new line" and got a reshaped box.
it("enters the newline it was asked for, not just the text area", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "before");
await user.keyboard("{Enter}");
expect(noteField().value).toBe("before\n");
await user.keyboard("after");
expect(noteField().value).toBe("before\nafter");
});
// The caret follows the newline, so what is typed next lands under it rather
// than back on the first line.
it("leaves the caret after the newline", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "before");
await user.keyboard("{Enter}");
expect(noteField().selectionStart).toBe("before\n".length);
});
// The newline goes in where the caret is, exactly as it would in a text area.
// Appending at the end instead silently rewrites the value whenever the caret
// was not already there — `abc|def` would become `abcdef` with a trailing
// blank line (#2139 review).
it("inserts the newline at the caret, not at the end", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "abcdef");
noteField().setSelectionRange(3, 3);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("abc\ndef");
expect(noteField().selectionStart).toBe(4);
});
// And a selected range is replaced, not kept — again matching what typing
// Enter into a real text area does.
it("replaces a selected range with the newline", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "abcdef");
noteField().setSelectionRange(3, 6);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("abc\n");
expect(noteField().selectionStart).toBe(4);
});
// Splitting at the very start is the boundary the slice arithmetic is most
// likely to get wrong.
it("splits at the start of the value", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
await user.type(noteField(), "abc");
noteField().setSelectionRange(0, 0);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("\nabc");
expect(noteField().selectionStart).toBe(1);
});
// A newline is a character, so a field with no room for one is enlarged
// without it rather than pushed past a constraint its schema states.
it("enlarges without a newline when the field is at its maxLength", async () => {
const user = userEvent.setup();
renderWithMantine(<SeededHarness maxLength={3} />);
noteField().focus();
await user.keyboard("{Enter}");
expect(noteField().tagName).toBe("TEXTAREA");
expect(noteField().value).toBe("abc");
});
// Replacing a selection frees room, so the same full field does take the
// newline when the keystroke is removing something to make space for it.
it("enters the newline at maxLength when a selection makes room", () => {
renderWithMantine(<SeededHarness maxLength={3} />);
noteField().setSelectionRange(1, 3);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("a\n");
});
// `values` is a Record<string, unknown> fed by whatever a server declares, so
// a string field can arrive holding a number. It renders as text, and slicing
// it as a string would throw — turning one keystroke into a crashed panel
// (#2139 review). Reading the control's own value keeps it a string.
it("survives a non-string value on a string field", () => {
renderWithMantine(<SeededHarness initial={123} />);
expect(noteField().value).toBe("123");
noteField().setSelectionRange(3, 3);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().tagName).toBe("TEXTAREA");
expect(noteField().value).toBe("123\n");
});
// Even when the newline does not fit, the keyboard had a real caret position
// and it is kept — otherwise the user editing the middle of a full field is
// thrown to the end on top of not getting their newline.
it("keeps the caret where it was when the newline does not fit", () => {
renderWithMantine(<SeededHarness maxLength={3} />);
noteField().setSelectionRange(1, 1);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("abc");
expect(noteField().selectionStart).toBe(1);
});
// JSON Schema counts maxLength in Unicode code points; String.length counts
// UTF-16 code units, so an emoji reads as 2 and a field with room left is
// treated as full (#2139 review).
it("counts maxLength in code points, not UTF-16 units", () => {
renderWithMantine(<SeededHarness maxLength={2} initial="😀" />);
noteField().setSelectionRange(2, 2);
fireEvent.keyDown(noteField(), { key: "Enter" });
// One emoji plus one newline is two characters, so it fits.
expect(noteField().value).toBe("😀\n");
});
// A field name that collides with something on Object.prototype must still
// take the documented end-of-value fallback when enlarged by pointer, rather
// than reading an inherited value off a bare record (#2139 review).
it("handles a field named after an Object.prototype member", async () => {
const user = userEvent.setup();
// Both halves are annotated rather than inlined. Under a `constructor` key
// the contextual type does not reach the nested literal, so `type: "string"`
// widens to `string` and fails to typecheck — the type-level echo of the
// very prototype collision this test is about.
const ctorField: InspectorFormSchema = { type: "string", title: "Ctor" };
const prototypeSchema: InspectorFormSchema = {
type: "object",
properties: { constructor: ctorField },
};
function PrototypeHarness() {
const [values, setValues] = useState<Record<string, unknown>>({
constructor: "abc",
});
return (
<SchemaForm
schema={prototypeSchema}
values={values}
onChange={setValues}
/>
);
}
renderWithMantine(<PrototypeHarness />);
await user.click(screen.getByRole("button", { name: "Enlarge Ctor" }));
const field = screen.getByRole("textbox", {
name: /Ctor/,
}) as HTMLTextAreaElement;
expect(field.tagName).toBe("TEXTAREA");
expect(field.selectionStart).toBe("abc".length);
});
// The control: the same seeded field with no maxLength does take the newline,
// so the test above is showing the constraint at work rather than a field
// that never accepts one.
it("enters the newline when the field has no maxLength", () => {
renderWithMantine(<SeededHarness />);
noteField().setSelectionRange(3, 3);
fireEvent.keyDown(noteField(), { key: "Enter" });
expect(noteField().value).toBe("abc\n");
});
// An empty field still has room, so the newline is entered there too.
it("enters a newline in an empty field", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
noteField().focus();
await user.keyboard("{Enter}");
expect(noteField().value).toBe("\n");
});
// The binding is the keyboard's only route in now, so announce that the
// field carries one rather than leaving it undiscoverable.
it("advertises the shortcut on the single-line field, not the text area", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness />);
expect(noteField()).toHaveAttribute("aria-keyshortcuts", "Enter");
noteField().focus();
await user.keyboard("{Enter}");
expect(noteField()).not.toHaveAttribute("aria-keyshortcuts");
});
// Same reasoning as the disabled button: a text area mounting disabled cannot
// take focus, so it would drop focus to the document.
it("cannot be enlarged by keyboard while the form is disabled", async () => {
const user = userEvent.setup();
renderWithMantine(<TwoStringHarness disabled />);
noteField().focus();
await user.keyboard("{Enter}");
expect(noteField().tagName).toBe("INPUT");
});
});

View file

@ -19,6 +19,7 @@ import {
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
@ -65,16 +66,23 @@ const MultilineStringInput = Textarea.withProps({
* A string field that has just been enlarged (#2042).
*
* Exists only to take focus on mount. This component mounts as a direct
* consequence of the user activating the enlarge button — which unmounts in the
* same commit, taking the focused element with it. Without this, a keyboard user
* who activates it is left with focus on the document body, and the next Tab
* restarts from the top of the page rather than continuing through the form.
* consequence of the user enlarging the field — and whichever control they used
* unmounts in the same commit, taking the focused element with it. Without this,
* the user is left with focus on the document body, and the next Tab restarts
* from the top of the page rather than continuing through the form.
*
* The caret is placed at the end of whatever was already typed, since focusing a
* pre-filled text control does not agree across browsers on where it lands, and
* the one answer that is never right is "before the text the user just wrote".
* `caretAt` says where the caret goes. The keyboard route passes one, because
* Enter inserts a newline at the user's selection and the caret belongs just
* after it (#2138). Pointer activation passes none: a click carries no
* meaningful position, so the caret falls to the end of whatever was already
* typed — focusing a pre-filled text control does not agree across browsers on
* where it lands, and the one answer that is never right is "before the text
* the user just wrote".
*/
function EnlargedStringField(props: TextareaProps) {
function EnlargedStringField({
caretAt,
...props
}: TextareaProps & { caretAt?: number }) {
const inputRef = useRef<HTMLTextAreaElement>(null);
// Layout, not passive: a passive effect runs after paint, so the browser would
@ -85,8 +93,9 @@ function EnlargedStringField(props: TextareaProps) {
/* v8 ignore next -- an effect runs after mount, so the ref is always set */
if (!node) return;
node.focus();
node.setSelectionRange(node.value.length, node.value.length);
}, []);
const caret = caretAt ?? node.value.length;
node.setSelectionRange(caret, caret);
}, [caretAt]);
return <MultilineStringInput {...props} ref={inputRef} />;
}
@ -110,6 +119,26 @@ const TWO_ACTION_WIDTH = rem(
ENLARGE_WIDTH + ACTION_GAP + CLEAR_WIDTH + SECTION_INSET,
);
// `keyCode` reported for a keydown the IME consumed — the pre-`isComposing`
// sentinel every browser still sets during composition. Only needed for the
// browsers whose composition events land too late for `isComposing` to help
// (#2138 review); a real Enter reports 13, so this cannot swallow one.
const IME_KEY_CODE = 229;
/**
* Length in Unicode code points, which is how JSON Schema counts `maxLength`
* (its characters are RFC 8259 characters, i.e. code points). `String.length`
* counts UTF-16 code units instead, so it double-counts anything astral: a
* field holding a single emoji reads as 2 and a `maxLength: 2` field would be
* treated as full when it has room for another character.
*
* Note the `maxLength` handed to the DOM input still enforces the HTML
* attribute's own UTF-16 counting; that predates this and is not changed here.
*/
function countCodePoints(value: string): number {
return Array.from(value).length;
}
function serializeJson(value: unknown): string {
return JSON.stringify(value, null, 2);
}
@ -590,7 +619,22 @@ export function SchemaForm({
// *name*, so it must not carry across to another entity's same-named field —
// the same reasoning `resetKey` documents for the number field's draft. Reset
// during render rather than in an effect so no frame paints the wrong shape.
useValueChange(resetKey, () => setEnlargedFields(new Set()));
// Where the caret goes when a field's text area mounts, for the fields
// enlarged by Enter (#2138). Absent for a field enlarged by the button, which
// carries no position — see EnlargedStringField.
// A Map rather than a plain object: the keys are field names straight out of
// a server's schema, and on a bare record a field legitimately named
// `constructor` or `toString` reads back an inherited function instead of
// `undefined` — which would then be handed to setSelectionRange in place of
// the documented end-of-value fallback.
const [enlargeCarets, setEnlargeCarets] = useState<
ReadonlyMap<string, number>
>(() => new Map());
useValueChange(resetKey, () => {
setEnlargedFields(new Set());
setEnlargeCarets(new Map());
});
// Stable so a field's reporting effect subscribes once, not per render. The
// updater returns the previous set unchanged when nothing moved, which is
@ -712,28 +756,116 @@ export function SchemaForm({
<EnlargedStringField
key={fieldName}
{...sharedProps}
caretAt={enlargeCarets.get(fieldName)}
rightSectionWidth={ONE_ACTION_WIDTH}
rightSection={clearButton}
/>
);
}
const enlarge = () =>
setEnlargedFields((previous) => new Set([...previous, fieldName]));
// Enter both enlarges the field and enters the newline that was asked
// for. Enlarging alone would consume the keystroke and leave the caret
// where it was, so the next thing typed runs on from the last word — the
// user pressed the key that means "new line" and got a reshaped box.
//
// The newline goes in at the field's own selection, exactly as it would
// in a text area: split at the caret, and replace a selected range rather
// than keeping it. Appending at the end instead would silently rewrite
// the value whenever the caret was not already there — pressing Enter in
// the middle of `abc|def` would produce `abcdef` with a trailing blank
// line, which is not what any editor does and not what was asked for.
//
// A field with no room left for the newline is enlarged without one,
// since the alternative is breaching a maxLength the schema states.
const enlargeWithNewline = (input: HTMLInputElement) => {
// The control's own value, not the form's. Two reasons, and the second
// is the one that bites: it is the exact string the selection offsets
// below index into, and it is always a string. `values` is a
// `Record<string, unknown>` fed by whatever a server's schema declares,
// so a non-string default on a string field (`default: 123`) arrives
// here as a number — it renders as text, and would then throw on
// `.slice`, turning a keystroke into a crashed panel.
const current = input.value;
// A control that cannot report a selection (`selectionStart` is null
// for some input types) is treated as a caret at the end.
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? start;
const next = `${current.slice(0, start)}\n${current.slice(end)}`;
const fits =
fieldSchema.maxLength === undefined ||
countCodePoints(next) <= fieldSchema.maxLength;
if (fits) handleFieldChange(fieldName, next);
// The caret is recorded either way. The keyboard route always has a
// real position, so dropping it when the newline does not fit would
// send the caret to the end of a field the user was editing the middle
// of — a second surprise on top of the newline they did not get.
setEnlargeCarets((previous) =>
new Map(previous).set(fieldName, fits ? start + 1 : start),
);
enlarge();
};
// The keyboard's way into multiline mode, now that the enlarge button is
// out of the tab order (#2138). Enter is inert in this field — nothing
// renders SchemaForm inside a `<form>`, so there is no implicit
// submission to displace — and it is the very key a user presses trying
// to enter the newline a single-line input swallows, which is what #2042
// exists to fix. So the gesture that fails is the one that enlarges,
// rather than a shortcut nobody would guess.
//
// Every condition below is load-bearing; none is incidental:
//
// - Shift+Enter is the other newline gesture, so it enlarges too.
// - `isComposing` is the IME guard. Enter is also how a Japanese, Chinese
// or Korean input method commits the candidate being composed, and that
// keystroke means "accept this word", not "new line". Acting on it
// would enlarge the field and insert a stray newline every time such a
// user finished a word, making the field unusable for them.
// - `keyCode === 229` is the same guard again, for WebKit. It is reported
// to fire `compositionend` *before* the committing keydown, which
// leaves `isComposing` already false by the time this runs; 229 is the
// pre-`isComposing` sentinel for "this key went to the IME" and is
// still set there. Kept despite `keyCode` being deprecated because
// nothing non-deprecated distinguishes that event, and it cannot misfire
// on a real Enter, which reports 13.
// - The Ctrl/Cmd/Alt chords are deliberately left alone: those read as
// "submit" in a form, and a consumer binding one (run the tool) must
// not be overridden into enlarging a field instead.
const handleEnlargeKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (
event.key !== "Enter" ||
event.nativeEvent.isComposing ||
event.keyCode === IME_KEY_CODE ||
event.ctrlKey ||
event.metaKey ||
event.altKey
) {
return;
}
event.preventDefault();
enlargeWithNewline(event.currentTarget);
};
return (
<TextInput
key={fieldName}
{...sharedProps}
rightSectionPointerEvents="auto"
rightSectionWidth={clearButton ? TWO_ACTION_WIDTH : ONE_ACTION_WIDTH}
onKeyDown={handleEnlargeKeyDown}
// Announces that the field carries a shortcut at all. A keyboard user
// no longer meets the button by tabbing, so without this the binding
// is undiscoverable rather than merely unlabelled.
aria-keyshortcuts="Enter"
rightSection={
<FieldActions>
<EnlargeButton
ariaLabel={`Enlarge ${label}`}
disabled={disabled}
onClick={() =>
setEnlargedFields(
(previous) => new Set([...previous, fieldName]),
)
}
onClick={enlarge}
/>
{clearButton}
</FieldActions>