Skip to content

fix: plugin card grid semantics, and read Monaco from the model in e2e - #3465

Open
opensource-joe wants to merge 2 commits into
apache:masterfrom
opensource-joe:a11y/plugin-card-semantics
Open

opensource-joe wants to merge 2 commits into
apache:masterfrom
opensource-joe:a11y/plugin-card-semantics

Conversation

@opensource-joe

@opensource-joe opensource-joe commented Aug 14, 2026

Copy link
Copy Markdown

Please answer these questions before submitting a pull request, or your PR will get closed.

Why submit this pull request?

  • Bugfix

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.

  1. fix: completes the combobox/role semantics 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.
  2. 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

PluginCardList rendered the cards inside a Mantine Combobox, so every card came out as role="option" under a role="listbox". None of that was wired up:

  • useVirtualizedCombobox() created a store and passed it to <Combobox store>, but no Combobox.Target is rendered anywhere
  • onOptionSubmit does not appear anywhere in src/, so activating an "option" did nothing
  • no aria-activedescendant, no key handling
  • the search box (PluginCardListSearch) is rendered by the parent, outside the Combobox entirely, so it never controlled it

So 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:

nested-interactive   106 violations   (one per plugin card)

The fix is to stop lying about what it is

It is a list of cards, so it is now a ul of li. 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.Empty becomes a plain dimmed Text for the no-data case.

Because nothing is an ARIA option any more, the internal Option / Options / OptionProps names are renamed to PluginListItem / PluginListItems / PluginListItemProps. No exported name changes.

Two nameless controls on the same surface

Picked up while verifying the above, both button-name violations in the same drawer:

  • the search field's clear button (CloseButton) had no label at all
  • the Select Plugins drawer's close button had no label

Both now use new a11y.* keys. PluginEditorDrawer was 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 the locale-parity guard 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 gets Received string: "{". It is pre-existing on master, not caused by commit 1, and retries: 2 hides it in CI.

The spec had a private getMonacoEditorValue that tried the hidden textarea and fell back to joining .view-line elements. Neither ever holds the document: the textarea keeps only a small cursor-local buffer for IME, and .view-line is virtualised. Sampling all three sources 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 fallback always ran, and it returns exactly { until the editor paints, because that is the first line of the JSON. The model is complete at t+0. So this is the wrong source being read rather than a race to wait out.

The fix adds uiGetMonacoEditorValue beside the existing helpers and reads window.__monacoEditor__.getModel().getValue() — the same source uiFillMonacoEditor and uiClearMonacoEditor already write to, and that plugin-metadata.drawer-keeps-edits-on-failed-save already reads. routes.empty-plugin-config had the same .view-lines scrape 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. CDP Emulation.setCPUThrottlingRate does not force it either, because throttling slows the test driver along with the renderer.

Verification

Against a live APISIX instance (e2e/server compose) with axe-core, scoped to the Select Plugins drawer:

before after
nested-interactive 106 0
aria-required-children 0 0
button-name (drawer chrome + search clear) 2 0

The new spec is not vacuous. Reverting only src/components and 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 0f93827bc3f9ff3b4eb1d81096e5be95290d24cf83f75e93e9121781c1ac28b4 both times.

Also run: pnpm build clean, pnpm lint clean at --max-warnings=0, pnpm test 136 passed across 16 files (including locale-parity).

e2e, scoped rather than skipped. PluginCardList has three consumers (FormItemPlugins, SelectPluginsDrawer, PluginMetadata), so I ran the 8 plugin specs plus the whole of e2e/tests/regression/, 123 tests: 122 passed, 1 failed. I did not run all 243 locally; at --workers=1 against 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) and pnpm build all 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's variant="light" buttons render #228be6 on #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's Drawer.Header renders 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:

  • Did you explain what problem does this PR solve? Or what new features have been added?
  • Have you added corresponding test cases?
  • Have you modified the corresponding document? (no user-facing document covers these controls)
  • Is this PR backward compatible?

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.
@opensource-joe

opensource-joe commented Aug 15, 2026

Copy link
Copy Markdown
Author

@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 action_required and none have executed. Nothing shows in the checks list at all, so it reads as though CI simply has not started.

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 lint, test and build all pass.

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.
@opensource-joe opensource-joe changed the title fix: drop the fictional combobox semantics from the plugin card grid fix: plugin card grid semantics, and read Monaco from the model in e2e Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

e2e: plugin_metadata.crud-all-fields is flaky on a half-rendered Monaco editor, masked by CI retries

1 participant