fix(web): address Copilot review on the shared JSON editor
- Cap the read-only editor's `maxLines` at 200. Uncapped, Ace renders every line of a payload and its viewport virtualization never engages — which matters because the Protocol and Network lists are not virtualized and keep each entry's payload mounted inside its `Collapse`. 200 is chosen so no real payload nests a scrollbar inside its host's while the pathological one stays bounded; the StructuredOutputPanel `Large` story now pins both halves (full payload in the document, only part of it in the DOM). - Take a disabled editor out of the tab order and give it `aria-disabled`. Ace has no disabled state, so its hidden textarea kept `tabindex="0"` and a keyboard user could tab into a field every neighbouring control had dropped — landing somewhere that looks editable and swallows what they type. A merely read-only editor stays focusable, since a payload has to be keyboard-scrollable (WCAG SC 2.1.1). - Reword a confusing comment in the EditReplayModal test. The `Disabled` story additionally scopes the axe `color-contrast` rule off: WCAG 1.4.3 exempts inactive components, and axe cannot see that the dimmed gutter belongs to one because the disabled state lives on Ace's hidden textarea. Scoped to that one story so the rule still covers everything else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1a2a36f0b8
commit
c9fb461bf0
2 changed files with 144 additions and 21 deletions
|
|
@ -100,8 +100,9 @@ describe("EditReplayModal", () => {
|
|||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The draft belongs to the sitting the user opened it in. Reopening must show
|
||||
// the entry's params again, not whatever they typed and abandoned last time.
|
||||
// The draft belongs to the one opening of the modal it was typed in.
|
||||
// Reopening must show the entry's params again, not whatever was typed and
|
||||
// abandoned last time.
|
||||
it("reseeds from the entry when reopened", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ const flat = {
|
|||
city: "San Francisco",
|
||||
};
|
||||
|
||||
// Long enough that the payload exceeds the section's max height and scrolls
|
||||
// within it rather than pushing the rest of the result panel down.
|
||||
// Long enough on both counts that matter: the payload exceeds the section's max
|
||||
// height (so it scrolls within it rather than pushing the rest of the result
|
||||
// panel down) *and* it exceeds the read-only editor's line cap, so Ace renders
|
||||
// only the rows its own viewport covers.
|
||||
const large = {
|
||||
rows: Array.from({ length: 60 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
|
|
@ -45,8 +47,8 @@ export const Nested: Story = {
|
|||
canvas.getByRole("heading", { name: "Structured Output" }),
|
||||
).toBeInTheDocument();
|
||||
// The section starts expanded, so the payload is visible without a click.
|
||||
// Asserted on the container's text: once the Prism grammar loads, the JSON
|
||||
// is split across per-token elements, so no single node holds the value.
|
||||
// Asserted on the container's text: the JSON renders in Ace, which puts
|
||||
// each token in its own element, so no single node holds the value.
|
||||
await waitFor(() =>
|
||||
expect(canvasElement.textContent).toContain('"Item A"'),
|
||||
);
|
||||
|
|
@ -62,14 +64,30 @@ export const Flat: Story = {
|
|||
// A payload taller than the section's cap scrolls inside it rather than
|
||||
// pushing the rest of the result panel down. Asserted on the real geometry,
|
||||
// so dropping `mah` (or moving it to the wrong element) fails here.
|
||||
//
|
||||
// It also pins the read-only editor's line cap (#2151). The Protocol and
|
||||
// Network lists are not virtualized and keep every entry's payload mounted
|
||||
// inside its `Collapse`, so an uncapped editor would put a whole large response
|
||||
// in the DOM per entry. The two assertions below are the difference between
|
||||
// "capped" and "truncated": the full payload is in the *document*, and only
|
||||
// part of it is in the *DOM*.
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
structuredContent: large,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await waitFor(() =>
|
||||
expect(canvasElement.textContent).toContain('"Row 60"'),
|
||||
);
|
||||
const editorNode = await waitFor(() => {
|
||||
const node = canvasElement.querySelector(".ace_editor");
|
||||
if (!node) throw new Error("JSON editor not mounted");
|
||||
return node as HTMLElement & { env: { editor: { getValue(): string } } };
|
||||
});
|
||||
|
||||
// Nothing is lost: the last row is in the document the editor holds, so it
|
||||
// is reachable by scrolling, folding, and the copy button.
|
||||
await expect(editorNode.env.editor.getValue()).toContain('"Row 60"');
|
||||
// …and it is not rendered, which is the virtualization the cap buys.
|
||||
await expect(canvasElement.textContent).not.toContain('"Row 60"');
|
||||
|
||||
const viewport = canvasElement.querySelector(
|
||||
".mantine-ScrollArea-viewport",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue