fix: plugin card grid semantics, and read Monaco from the model in e2e - #3465
opensource-joe wants to merge 2 commits into
Conversation
The plugin card grid was rendered inside a Mantine `Combobox`, so every card carried `role="option"` under a `role="listbox"`. None of it was real: - the `useVirtualizedCombobox` store was created and passed to `<Combobox store>`, but no `Combobox.Target` was ever rendered - `onOptionSubmit` is set nowhere in the repo, so activating an "option" did nothing; the actual actions are buttons inside the card - no `aria-activedescendant`, no key handling, and the search box lives outside the Combobox entirely So the listbox was announced to screen readers as something the user could arrow through and select, while in practice it was a grid of cards whose buttons are the only way to act. Worse, `role="option"` does not permit interactive descendants, and each card holds an Add / View / Edit / Delete button plus the docs link added in apache#3462. axe reports 106 `nested-interactive` violations in the Select Plugins drawer, one per card. Replace the whole thing with what it actually is: a `ul` of `li` cards. Screen readers now announce a list and its item count, the buttons and links are reachable in document order without any key handling to write, and the invalid ARIA is gone. `Combobox.Empty` becomes a plain dimmed `Text` for the no-data case. While in the same surface, two icon-only controls were still nameless: - the search field's clear button (`CloseButton`, no label at all) - the Select Plugins drawer's close button Both now use new translated `a11y.*` keys, added to all five locales to satisfy the key-parity guard. `PluginEditorDrawer` was already labelling its close button but with a hardcoded English "Close", so it now uses the same key. The internal `Option`/`Options`/`OptionProps` names are renamed to `PluginListItem`/`PluginListItems`/`PluginListItemProps`, since nothing is an ARIA option any more. Part of apache#3417 (a11y section), completing the combobox/role semantics item that apache#3442 explicitly deferred. Verified against a live APISIX instance: axe `nested-interactive` goes from 106 violations to 0, and drawer screenshots before and after are byte-identical (sha256 0f93827b...), so this is a semantics-only change with no visual delta.
|
@LiteSun Would you be able to approve the workflow runs on this PR? As a first-time contributor my workflows need a maintainer to release them, so all five runs are sitting at I would rather CI ran before anyone spends review time here, particularly on the first commit: it changes DOM semantics, so the e2e suite is the real check on it. Locally a scoped subset (123 tests, the plugin specs plus the component's three consumers) was clean, and on the combined branch Updated: this PR now also carries the Monaco test fix that was briefly open separately as #3467, which is closed and folded in here as its own commit. The ask applies only to this PR. The two commits touch disjoint files and are meant to be reviewed one at a time. |
) `plugin_metadata.crud-all-fields` was flaky on its final assertion, failing with `Received string: "{"` when reading the editor back after a save. It is a test-only race, and CI hides it because `playwright.config.ts` sets `retries: process.env.CI ? 2 : 0`. The spec had its own local `getMonacoEditorValue` that read the DOM through two paths, and neither is a reliable source for editor content: - the hidden `textarea` holds only a small buffer around the cursor for IME purposes, never the whole document - `.view-line` elements are virtualised, so only painted lines exist Sampling all three sources at the instant the drawer becomes visible, for a 219-character config: t+0ms textarea= 0 "" viewLines= 1 "{" model=219 t+50ms textarea= 0 "" viewLines=11 "{ \"log_format\"…" model=219 t+1000ms textarea= 0 "" viewLines=11 "{ \"log_format\"…" model=219 The textarea is empty at every offset, so the helper always fell through to the `.view-line` path, and that path returns exactly `{` until the editor paints. That is the observed failure value. The window is roughly 50ms wide, which is why it reproduces under load and passes on an idle machine. The model, meanwhile, is complete at t+0. There is no race to wait out, only the wrong source being read. Add `uiGetMonacoEditorValue` alongside the existing helpers and read `window.__monacoEditor__.getModel().getValue()`, which is the same source `uiFillMonacoEditor` and `uiClearMonacoEditor` already write to, and which `plugin-metadata.drawer-keeps-edits-on-failed-save` already reads. It polls for non-empty content because the drawer mounts its editor asynchronously and the global can briefly still point at a previously mounted instance. `routes.empty-plugin-config` had the same defect, reading `.view-lines` innerText. Its comment ("not the textarea") shows the textarea problem was already known; the fix reached for the other DOM source rather than the model. Converted too, so the pattern does not survive anywhere. Verified against a live APISIX instance: the affected specs pass 24/24 across 4 repeats, and lint and typecheck are clean.
Please answer these questions before submitting a pull request, or your PR will get closed.
Why submit this pull request?
What changes will this PR take into?
Two related changes to the plugin card surface, kept as separate commits so each can be reviewed on its own. Both came out of the same piece of work and the second was originally #3467, now folded in here.
fix:completes the combobox/rolesemantics item from the a11y section of Tracking: verified findings from a full frontend review (master @ 9979b31) #3417, which fix: accessible names for header icon buttons, Monaco contrast, drawer Escape #3442 explicitly left for follow-up.test:fixes the flaky Monaco read that the first change's e2e run exposed (e2e: plugin_metadata.crud-all-fields is flaky on a half-rendered Monaco editor, masked by CI retries #3466).Commit 1 — drop the fictional combobox semantics (part of #3417)
The plugin card grid was pretending to be a listbox
PluginCardListrendered the cards inside a MantineCombobox, so every card came out asrole="option"under arole="listbox". None of that was wired up:useVirtualizedCombobox()created a store and passed it to<Combobox store>, but noCombobox.Targetis rendered anywhereonOptionSubmitdoes not appear anywhere insrc/, so activating an "option" did nothingaria-activedescendant, no key handlingPluginCardListSearch) is rendered by the parent, outside the Combobox entirely, so it never controlled itSo a screen reader was told this was a listbox to arrow through and select from, while in reality it is a grid of cards whose buttons are the only way to do anything.
The concrete violation is that
role="option"does not permit interactive descendants, and each card contains an Add / View / Edit / Delete button plus the docs link from #3462. Measured in the Select Plugins drawer against a live APISIX instance:The fix is to stop lying about what it is
It is a list of cards, so it is now a
ulofli. Screen readers announce a list and its item count, the buttons and links are reachable in document order with no key handling to write, and the invalid ARIA is gone.Combobox.Emptybecomes a plain dimmedTextfor the no-data case.Because nothing is an ARIA option any more, the internal
Option/Options/OptionPropsnames are renamed toPluginListItem/PluginListItems/PluginListItemProps. No exported name changes.Two nameless controls on the same surface
Picked up while verifying the above, both
button-nameviolations in the same drawer:CloseButton) had no label at allBoth now use new
a11y.*keys.PluginEditorDrawerwas already labelling its close button, but with a hardcoded English'Close', so it moves to the same key. New keys are added to all five locales with real translations, which thelocale-parityguard requires.Commit 2 — read Monaco content from the model, not the DOM (fixes #3466)
Running the above surfaced a failure in
plugin_metadata.crud-all-fields.spec.ts, which reads the editor back after a save and getsReceived string: "{". It is pre-existing onmaster, not caused by commit 1, andretries: 2hides it in CI.The spec had a private
getMonacoEditorValuethat tried the hiddentextareaand fell back to joining.view-lineelements. Neither ever holds the document: the textarea keeps only a small cursor-local buffer for IME, and.view-lineis virtualised. Sampling all three sources the instant the drawer becomes visible, for a 219-character config:The textarea is empty at every offset, so the fallback always ran, and it returns exactly
{until the editor paints, because that is the first line of the JSON. The model is complete att+0. So this is the wrong source being read rather than a race to wait out.The fix adds
uiGetMonacoEditorValuebeside the existing helpers and readswindow.__monacoEditor__.getModel().getValue()— the same sourceuiFillMonacoEditoranduiClearMonacoEditoralready write to, and thatplugin-metadata.drawer-keeps-edits-on-failed-savealready reads.routes.empty-plugin-confighad the same.view-linesscrape and is converted too.It is timing-sensitive rather than a fixed rate: on a busy machine it failed 4 of 6 runs on
master, and on an idle one it passed 10 of 10. So a handful of green runs does not clear it, and I cannot show a clean red-to-green locally. CDPEmulation.setCPUThrottlingRatedoes not force it either, because throttling slows the test driver along with the renderer.Verification
Against a live APISIX instance (
e2e/servercompose) with axe-core, scoped to the Select Plugins drawer:nested-interactivearia-required-childrenbutton-name(drawer chrome + search clear)The new spec is not vacuous. Reverting only
src/componentsand re-running it fails 3 of its 4 tests, with the axe assertion producing a 3,490-line diff.No visual change. Drawer screenshots taken before and after are byte-identical,
sha256 0f93827bc3f9ff3b4eb1d81096e5be95290d24cf83f75e93e9121781c1ac28b4both times.Also run:
pnpm buildclean,pnpm lintclean at--max-warnings=0,pnpm test136 passed across 16 files (includinglocale-parity).e2e, scoped rather than skipped.
PluginCardListhas three consumers (FormItemPlugins,SelectPluginsDrawer,PluginMetadata), so I ran the 8 plugin specs plus the whole ofe2e/tests/regression/, 123 tests: 122 passed, 1 failed. I did not run all 243 locally; at--workers=1against a real gateway that was tracking to about three hours, and CI runs the full suite here anyway.Combined branch, after folding #3467 in here: the two commits touch disjoint files, and on the merged branch
pnpm install,pnpm lint(--max-warnings=0),pnpm test(136 passing across 16 files) andpnpm buildall exit 0.One new devDependency
@axe-core/playwright, for the assertion in the new spec. #3417 lists "Zero a11y assertions in e2e" as a test-suite gap, and #3442 added a11y coverage that could only assert on accessible names because there was no axe available. Happy to drop it and assert on the DOM shape alone if you would rather not take the dependency.Deliberately not fixed here
Two things axe flags in the same drawer that are app-wide rather than plugin-card issues, so they want their own change and their own decision:
color-contrast, 33 nodes page-wide. Mantine'svariant="light"buttons render#228be6on#e9f3fd, which is 3.16:1 against a 4.5:1 requirement. It hits Add, Docs, the language button and NavLink labels, so it is a theme-token decision, not a local one.landmark-no-duplicate-banner. Mantine'sDrawer.Headerrenders a<header>, giving a second banner landmark whenever a drawer is open. Affects every drawer in the app.Happy to open either as its own issue or PR if useful.
Related issues
Part of #3417 (please do not auto-close the tracking issue)
fixes #3466
Supersedes #3467, which I have closed in favour of this PR.
Checklist: