fix(web): read the enlarge split from the control, and keep the caret

Two faults in the Enter path, both found in review.

The split read `values[fieldName] as string`, a cast over a
Record<string, unknown> fed by whatever a server's schema declares. A
string field holding a non-string default renders as text and then threw
on `.slice`, so one keystroke crashed the panel. Read the control's own
value instead: always a string, and the exact string the selection
offsets index into.

The caret was recorded only when the newline fit, so pressing Enter mid
value in a field at its maxLength enlarged without one and threw the
caret to the end — a second surprise on top of the newline that did not
arrive. Record it on both paths, `start + 1` after an insertion and
`start` otherwise.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 09:54:49 -04:00
commit 1d3b973742
2 changed files with 53 additions and 10 deletions

View file

@ -2002,9 +2002,15 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => {
// 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 }: { maxLength?: number }) {
function SeededHarness({
maxLength,
initial = "abc",
}: {
maxLength?: number;
initial?: unknown;
}) {
const [values, setValues] = useState<Record<string, unknown>>({
note: "abc",
note: initial,
});
return (
<SchemaForm
@ -2252,6 +2258,34 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => {
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);
});
// 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.

View file

@ -762,7 +762,14 @@ export function SchemaForm({
// 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) => {
const current = (rawValue as string) ?? "";
// 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;
@ -771,13 +778,15 @@ export function SchemaForm({
const fits =
fieldSchema.maxLength === undefined ||
next.length <= fieldSchema.maxLength;
if (fits) {
handleFieldChange(fieldName, next);
setEnlargeCarets((previous) => ({
...previous,
[fieldName]: start + 1,
}));
}
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) => ({
...previous,
[fieldName]: fits ? start + 1 : start,
}));
enlarge();
};