feat(web): use the Ace editor for the request-metadata JSON
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>
This commit is contained in:
parent
b0ab6842aa
commit
ed799a0c0b
1 changed files with 432 additions and 94 deletions
58
clients/web/src/test/aceEditor.ts
Normal file
58
clients/web/src/test/aceEditor.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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, "\\$&"));
|
||||
}
|
||||
Loading…
Reference in a new issue