fix(web): key enlarge carets by Map, and count maxLength in code points
Two more from review, both about field data the schema controls. The caret record was a bare object keyed by field name, and field names come from a server's schema. A field legitimately named `constructor` or `toString` read back an inherited function rather than undefined, so the documented end-of-value fallback never fired and that value reached setSelectionRange. Use a Map, which has no such keys. JSON Schema counts maxLength in Unicode code points; String.length counts UTF-16 code units. A field holding one emoji measured 2, so a maxLength:2 field with room for another character was treated as full and refused its newline. Count code points. The maxLength handed to the DOM input still carries the HTML attribute's own UTF-16 counting; that predates this and is left alone. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1d3b973742
commit
d49798c91c
2 changed files with 79 additions and 9 deletions
|
|
@ -2286,6 +2286,58 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -125,6 +125,20 @@ const TWO_ACTION_WIDTH = rem(
|
|||
// (#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);
|
||||
}
|
||||
|
|
@ -608,13 +622,18 @@ export function SchemaForm({
|
|||
// 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<
|
||||
Readonly<Record<string, number>>
|
||||
>({});
|
||||
ReadonlyMap<string, number>
|
||||
>(() => new Map());
|
||||
|
||||
useValueChange(resetKey, () => {
|
||||
setEnlargedFields(new Set());
|
||||
setEnlargeCarets({});
|
||||
setEnlargeCarets(new Map());
|
||||
});
|
||||
|
||||
// Stable so a field's reporting effect subscribes once, not per render. The
|
||||
|
|
@ -737,7 +756,7 @@ export function SchemaForm({
|
|||
<EnlargedStringField
|
||||
key={fieldName}
|
||||
{...sharedProps}
|
||||
caretAt={enlargeCarets[fieldName]}
|
||||
caretAt={enlargeCarets.get(fieldName)}
|
||||
rightSectionWidth={ONE_ACTION_WIDTH}
|
||||
rightSection={clearButton}
|
||||
/>
|
||||
|
|
@ -777,16 +796,15 @@ export function SchemaForm({
|
|||
const next = `${current.slice(0, start)}\n${current.slice(end)}`;
|
||||
const fits =
|
||||
fieldSchema.maxLength === undefined ||
|
||||
next.length <= fieldSchema.maxLength;
|
||||
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) => ({
|
||||
...previous,
|
||||
[fieldName]: fits ? start + 1 : start,
|
||||
}));
|
||||
setEnlargeCarets((previous) =>
|
||||
new Map(previous).set(fieldName, fits ? start + 1 : start),
|
||||
);
|
||||
enlarge();
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue