A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.
Three things the swap turned up:
- Ace fires *two* change events for a replace (a remove, then an insert), so
select-all-and-retype briefly leaves an empty document. Acting on the first
emitted `{}` and wiped the user's saved metadata the instant they selected
it. `handleChange` now coalesces the pair in a microtask, and the component
tracks what it last emitted so the parent echoing that value back is not
mistaken for an external reset.
- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.
- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
"<label>, Cursor at row N" and only recomputes it when the cursor moves, so
it is forced once on load. Until then the control's only accessible name is a
position readout.
Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n \n}`) is asserted by Storybook play functions in Chromium.
`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
import { act } from "@testing-library/react";
|
|
import type { Ace } from "ace-builds";
|
|
|
|
/**
|
|
* Reach the live Ace editor a component mounted, and drive it.
|
|
*
|
|
* Typing is deliberately **not** simulated. Ace reads keystrokes through a 1px
|
|
* offscreen textarea plus selection and composition state that happy-dom does
|
|
* not implement, so `userEvent.type` reaches the element and produces no edit
|
|
* at all — a test written that way asserts nothing while looking like it
|
|
* asserts a lot. Real keyboard behaviour (auto-closing a brace on Enter, and
|
|
* the draft surviving invalid text) is covered by the `JsonObjectInput`
|
|
* Storybook play functions, which run in Chromium.
|
|
*
|
|
* Ace hangs the instance off its container element as `env.editor`; that is the
|
|
* supported way back to it from the DOM.
|
|
*/
|
|
export function getAceEditor(container: ParentNode = document): Ace.Editor {
|
|
const node = container.querySelector(".ace_editor");
|
|
const editor = (
|
|
node as (HTMLElement & { env?: { editor?: Ace.Editor } }) | null
|
|
)?.env?.editor;
|
|
if (!editor) throw new Error("Ace editor not mounted");
|
|
return editor;
|
|
}
|
|
|
|
/** The document the editor currently holds. */
|
|
export function getAceText(container?: ParentNode): string {
|
|
return getAceEditor(container).getValue();
|
|
}
|
|
|
|
/**
|
|
* Replace the document the way a select-all-and-retype would, and let React
|
|
* flush.
|
|
*
|
|
* Async because `JsonObjectInput` coalesces the paired remove/insert events
|
|
* Ace fires for a replace; an `await act` is what drains that microtask.
|
|
*/
|
|
export async function setAceText(
|
|
text: string,
|
|
container?: ParentNode,
|
|
): Promise<void> {
|
|
await act(async () => {
|
|
// `1` parks the cursor at the end, matching where typing would leave it.
|
|
getAceEditor(container).setValue(text, 1);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Matcher for an Ace-labelled control.
|
|
*
|
|
* Ace composes its text input's `aria-label` as `"<label>, Cursor at row N"`,
|
|
* so an exact-string `getByLabelText` never matches — the position readout is
|
|
* Ace's, only the prefix is ours.
|
|
*/
|
|
export function aceLabel(label: string): RegExp {
|
|
return new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
}
|