This is an automated email from the ASF dual-hosted git repository.

LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new c0edff509 fix: plugin card grid semantics, and read Monaco from the 
model in e2e (#3465)
c0edff509 is described below

commit c0edff509b69be82626a1a709c205c4e7ba9540c
Author: Joe Castle <[email protected]>
AuthorDate: Fri Oct 16 03:51:59 2026 -0400

    fix: plugin card grid semantics, and read Monaco from the model in e2e 
(#3465)
---
 e2e/tests/plugin_metadata.crud-all-fields.spec.ts  | 31 +-------
 e2e/tests/regression/a11y.plugin-card-list.spec.ts | 92 ++++++++++++++++++++++
 e2e/tests/routes.empty-plugin-config.spec.ts       |  7 +-
 e2e/utils/ui/index.ts                              | 34 ++++++++
 package.json                                       |  1 +
 pnpm-lock.yaml                                     | 19 +++++
 .../form-slice/FormItemPlugins/PluginCardList.tsx  | 47 +++++------
 .../FormItemPlugins/PluginEditorDrawer.tsx         |  2 +-
 .../FormItemPlugins/SelectPluginsDrawer.tsx        |  1 +
 src/locales/de/common.json                         |  2 +
 src/locales/en/common.json                         |  2 +
 src/locales/es/common.json                         |  2 +
 src/locales/tr/common.json                         |  2 +
 src/locales/zh/common.json                         |  2 +
 14 files changed, 186 insertions(+), 58 deletions(-)

diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts 
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 4a1fe6070..5c4be7ff0 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -20,6 +20,7 @@ import { test } from '@e2e/utils/test';
 import {
   uiFillMonacoEditor,
   uiGetMonacoEditor,
+  uiGetMonacoEditorValue,
   uiHasToastMsg,
 } from '@e2e/utils/ui';
 import type { Locator } from '@playwright/test';
@@ -33,34 +34,6 @@ const deletePluginMetadata = async (req: typeof e2eReq, 
name: string) => {
     // Ignore errors if metadata doesn't exist
   });
 };
-const getMonacoEditorValue = async (editPluginDialog: Locator) => {
-  const textarea = editPluginDialog.locator('textarea');
-
-  // Wait for Monaco editor to be fully loaded with content (increased timeout 
for CI)
-  await textarea.waitFor({ state: 'attached', timeout: 10000 });
-
-  let editorValue = '';
-
-  // Try to get value from textarea first
-  if (await textarea.count() > 0) {
-    editorValue = await textarea.inputValue();
-  }
-
-  // Fallback to reading view-lines if textarea value is incomplete
-  if (!editorValue || editorValue.trim() === '{') {
-    // Wait for view-lines to be populated
-    await editPluginDialog.locator('.view-line').first().waitFor({ timeout: 
10000 });
-    const lines = await 
editPluginDialog.locator('.view-line').allTextContents();
-    editorValue = lines.join('\n').replace(/\s+/g, ' ');
-  }
-
-  if (!editorValue || editorValue.trim() === '{') {
-    const allText = await editPluginDialog.textContent();
-    void allText; // fallback failed, editor value unavailable
-  }
-  return editorValue;
-};
-
 // Helper function to close edit dialog
 const closeEditDialog = async (editPluginDialog: Locator) => {
   const buttons = await editPluginDialog.locator('button').allTextContents();
@@ -203,7 +176,7 @@ test('should CRUD plugin metadata with all fields', async 
({ page }) => {
     await expect(editPluginDialog).toBeVisible();
 
     // Get Monaco editor value using helper
-    const editorValue = await getMonacoEditorValue(editPluginDialog);
+    const editorValue = await uiGetMonacoEditorValue(page, editPluginDialog);
     expect(editorValue).toMatch(/"time"\s*:\s*"\$time_iso8601"/);
     expect(editorValue).toMatch(/"user_agent"\s*:\s*"\$http_user_agent"/);
     expect(editorValue).toMatch(/"host"\s*:\s*"\$host"/);
diff --git a/e2e/tests/regression/a11y.plugin-card-list.spec.ts 
b/e2e/tests/regression/a11y.plugin-card-list.spec.ts
new file mode 100644
index 000000000..555625fee
--- /dev/null
+++ b/e2e/tests/regression/a11y.plugin-card-list.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for the "plugin cards are wrapped in Combobox/role=option
+// semantics with nested interactive buttons and no keyboard wiring" a11y item
+// of apache/apisix-dashboard#3417, explicitly deferred by #3442.
+//
+// The card grid was rendered inside a Combobox whose store was never wired to
+// a target and whose onOptionSubmit was never set, so every card carried
+// role="option" while containing a real button and a real link. That is an
+// aria-allowed-descendants violation (axe: nested-interactive), and it lied to
+// screen readers about a listbox the user could not operate as one.
+
+import { AxeBuilder } from '@axe-core/playwright';
+import { routesPom } from '@e2e/pom/routes';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+const openSelectPluginsDrawer = async (page: Parameters<typeof 
routesPom.toAdd>[0]) => {
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+  await page.getByRole('button', { name: 'Select Plugins' }).click();
+  const drawer = page.getByRole('dialog', { name: 'Select Plugins' });
+  await expect(drawer).toBeVisible();
+  await expect(drawer.getByTestId(/^plugin-/).first()).toBeVisible();
+  return drawer;
+};
+
+test('the plugin card grid is a list, not a fake listbox', async ({ page }) => 
{
+  const drawer = await openSelectPluginsDrawer(page);
+
+  // no combobox semantics remain anywhere in the drawer
+  await expect(drawer.locator('[role="listbox"]')).toHaveCount(0);
+  await expect(drawer.locator('[role="option"]')).toHaveCount(0);
+
+  // the cards are a real list, so a screen reader announces the item count
+  const list = drawer.getByRole('list');
+  await expect(list).toHaveCount(1);
+  // one list item per rendered plugin card
+  const cardCount = await drawer.getByTestId(/^plugin-/).count();
+  expect(cardCount).toBeGreaterThan(0);
+  await expect(list.getByRole('listitem')).toHaveCount(cardCount);
+});
+
+test('plugin cards expose no nested-interactive violations', async ({
+  page,
+}) => {
+  await openSelectPluginsDrawer(page);
+
+  const results = await new AxeBuilder({ page })
+    .include('[role="dialog"]')
+    .withRules(['nested-interactive', 'aria-required-children', 
'aria-required-parent'])
+    .analyze();
+
+  expect(results.violations).toEqual([]);
+});
+
+test('the card action buttons are reachable and operable by keyboard', async ({
+  page,
+}) => {
+  const drawer = await openSelectPluginsDrawer(page);
+
+  const firstAdd = drawer.getByRole('button', { name: 'Add' }).first();
+  await expect(firstAdd).toBeVisible();
+  // focusable in document order rather than needing listbox key handling,
+  // which the Combobox never provided
+  await firstAdd.focus();
+  await expect(firstAdd).toBeFocused();
+});
+
+test('the search clear button and the drawer close button have names', async ({
+  page,
+}) => {
+  const drawer = await openSelectPluginsDrawer(page);
+
+  await expect(drawer.getByRole('button', { name: 'Clear search' 
})).toBeVisible();
+  await expect(drawer.getByRole('button', { name: 'Close' })).toBeVisible();
+});
diff --git a/e2e/tests/routes.empty-plugin-config.spec.ts 
b/e2e/tests/routes.empty-plugin-config.spec.ts
index bff6a2a05..4d5953768 100644
--- a/e2e/tests/routes.empty-plugin-config.spec.ts
+++ b/e2e/tests/routes.empty-plugin-config.spec.ts
@@ -21,6 +21,7 @@ import { test } from '@e2e/utils/test';
 import {
   uiFillMonacoEditor,
   uiGetMonacoEditor,
+  uiGetMonacoEditorValue,
   uiHasToastMsg,
 } from '@e2e/utils/ui';
 import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
@@ -247,11 +248,7 @@ test('should preserve plugin with empty configuration 
(key-auth) during creation
     await pluginCard.getByRole('button', { name: 'View' }).click();
     const viewPluginDialog = page.getByRole('dialog', { name: 'View Plugin' });
     await uiGetMonacoEditor(page, viewPluginDialog, false);
-    // Read Monaco editor content from the visible lines (not the textarea)
-    const viewContent = await viewPluginDialog
-      .locator('.view-lines')
-      .first()
-      .innerText();
+    const viewContent = await uiGetMonacoEditorValue(page, viewPluginDialog);
     expect(viewContent.replace(/\s/g, '')).toBe('{}');
   });
 });
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 88042097e..904d4c1a8 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -137,3 +137,37 @@ export const uiFillMonacoEditor = async (
   await editor.blur();
   await page.waitForTimeout(800);
 };
+
+/**
+ * Read an editor's contents back.
+ *
+ * Reads the Monaco model, the same source `uiFillMonacoEditor` and
+ * `uiClearMonacoEditor` write to. Do not scrape the DOM for this: the hidden
+ * `textarea` holds only a small buffer around the cursor rather than the whole
+ * document, and `.view-line` elements are virtualised, so both return partial
+ * content until the editor has finished painting.
+ *
+ * Polls, because the drawer that owns the editor mounts it asynchronously and
+ * `window.__monacoEditor__` can still point at a previously mounted instance
+ * for a moment after a new drawer opens.
+ */
+export const uiGetMonacoEditorValue = async (page: Page, parent: Locator) => {
+  await expect(parent.getByTestId('editor-loading')).toBeHidden();
+  await expect(parent.locator('.monaco-editor').first()).toBeVisible({
+    timeout: 10_000,
+  });
+
+  await expect
+    .poll(
+      () =>
+        page.evaluate(
+          () => window.__monacoEditor__?.getModel()?.getValue().length ?? 0
+        ),
+      { timeout: 10_000 }
+    )
+    .toBeGreaterThan(0);
+
+  return page.evaluate(
+    () => window.__monacoEditor__?.getModel()?.getValue() ?? ''
+  );
+};
diff --git a/package.json b/package.json
index 1def1bfe6..e89d603d2 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
     "zod-empty": "^1.4.4"
   },
   "devDependencies": {
+    "@axe-core/playwright": "^4.13.0",
     "@eslint/js": "^9.32.0",
     "@estruyf/github-actions-reporter": "^1.11.0",
     "@iconify-json/tabler": "^1.2.37",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 950a1e0c5..ebe8f9fac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,6 +122,9 @@ importers:
         specifier: ^1.4.4
         version: 1.5.2([email protected])([email protected])
     devDependencies:
+      '@axe-core/playwright':
+        specifier: ^4.13.0
+        version: 4.13.0([email protected])
       '@eslint/js':
         specifier: ^9.32.0
         version: 9.39.4
@@ -381,6 +384,11 @@ packages:
   '@antfu/[email protected]':
     resolution: {integrity: 
sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
 
+  '@axe-core/[email protected]':
+    resolution: {integrity: 
sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
+    peerDependencies:
+      playwright-core: '>= 1.0.0'
+
   '@babel/[email protected]':
     resolution: {integrity: 
sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
     engines: {node: '>=6.9.0'}
@@ -1858,6 +1866,10 @@ packages:
     resolution: {integrity: 
sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
     engines: {node: '>= 0.4'}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+    engines: {node: '>=4'}
+
   [email protected]:
     resolution: {integrity: 
sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
 
@@ -4491,6 +4503,11 @@ snapshots:
       package-manager-detector: 1.6.0
       tinyexec: 1.0.2
 
+  '@axe-core/[email protected]([email protected])':
+    dependencies:
+      axe-core: 4.13.0
+      playwright-core: 1.58.2
+
   '@babel/[email protected]':
     dependencies:
       '@babel/helper-validator-identifier': 7.28.5
@@ -5945,6 +5962,8 @@ snapshots:
     dependencies:
       possible-typed-array-names: 1.1.0
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       follow-redirects: 1.16.0
diff --git a/src/components/form-slice/FormItemPlugins/PluginCardList.tsx 
b/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
index ab95c1dac..bd82bfecb 100644
--- a/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
@@ -16,12 +16,11 @@
  */
 import {
   CloseButton,
-  Combobox,
   ScrollArea,
   SimpleGrid,
+  Text,
   TextInput,
   type TextInputProps,
-  useVirtualizedCombobox,
 } from '@mantine/core';
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -48,6 +47,7 @@ export const PluginCardListSearch = (props: 
PluginCardListSearchProps) => {
       rightSectionPointerEvents="all"
       rightSection={
         <CloseButton
+          aria-label={t('a11y.clearSearch')}
           onClick={(event) => {
             event.preventDefault();
             event.stopPropagation();
@@ -59,16 +59,16 @@ export const PluginCardListSearch = (props: 
PluginCardListSearchProps) => {
   );
 };
 
-type OptionProps = Pick<
+type PluginListItemProps = Pick<
   PluginCardProps,
   'onAdd' | 'onEdit' | 'onDelete' | 'onView' | 'mode'
 > & {
   name: string;
 };
-const Option = (props: OptionProps) => {
+const PluginListItem = (props: PluginListItemProps) => {
   const { mode, name, onAdd, onEdit, onDelete, onView } = props;
   return (
-    <Combobox.Option key={name} value={name} p={0}>
+    <li>
       <PluginCard
         mode={mode}
         name={name}
@@ -77,22 +77,22 @@ const Option = (props: OptionProps) => {
         onDelete={() => onDelete?.(name)}
         onView={() => onView?.(name)}
       />
-    </Combobox.Option>
+    </li>
   );
 };
 
-const Options = (props: { list: OptionProps[] }) => {
+const PluginListItems = (props: { list: PluginListItemProps[] }) => {
   const { list } = props;
   return (
     <>
       {list.map((option) => (
-        <Option key={option.name} {...option} />
+        <PluginListItem key={option.name} {...option} />
       ))}
     </>
   );
 };
 
-export type PluginCardListProps = Omit<OptionProps, 'name'> &
+export type PluginCardListProps = Omit<PluginListItemProps, 'name'> &
   Pick<TextInputProps, 'placeholder'> & {
     cols?: number;
     h?: number | string;
@@ -105,7 +105,6 @@ export const PluginCardList = (props: PluginCardListProps) 
=> {
   const { search = '', cols = 3, h, mah, plugins } = props;
   const { mode, onAdd, onEdit, onDelete, onView } = props;
   const { t } = useTranslation();
-  const combobox = useVirtualizedCombobox();
 
   const list = useMemo(() => {
     const query = search.toLowerCase().trim();
@@ -123,18 +122,20 @@ export const PluginCardList = (props: 
PluginCardListProps) => {
   }, [search, plugins, mode, onAdd, onEdit, onDelete, onView]);
 
   return (
-    <Combobox store={combobox}>
-      <Combobox.Options mt="1em">
-        <ScrollArea.Autosize h={h} mah={mah} type="scroll">
-          {!list.length ? (
-            <Combobox.Empty>{t('noData')}</Combobox.Empty>
-          ) : (
-            <SimpleGrid cols={cols}>
-              <Options list={list} />
-            </SimpleGrid>
-          )}
-        </ScrollArea.Autosize>
-      </Combobox.Options>
-    </Combobox>
+    <ScrollArea.Autosize mt="1em" h={h} mah={mah} type="scroll">
+      {!list.length ? (
+        <Text c="dimmed" ta="center" py="sm">
+          {t('noData')}
+        </Text>
+      ) : (
+        <SimpleGrid
+          component="ul"
+          cols={cols}
+          style={{ listStyle: 'none', margin: 0, padding: 0 }}
+        >
+          <PluginListItems list={list} />
+        </SimpleGrid>
+      )}
+    </ScrollArea.Autosize>
   );
 };
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx 
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index b883d9bc0..c41cf2bf6 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -108,7 +108,7 @@ export const PluginEditorDrawer = (props: 
PluginEditorDrawerProps) => {
       closeOnEscape={false}
       opened={opened}
       onClose={handleClose}
-      closeButtonProps={{ 'aria-label': 'Close' }}
+      closeButtonProps={{ 'aria-label': t('a11y.close') }}
       styles={{ body: { paddingTop: '18px' } }}
       {...(mode === 'add' && { title: t('form.plugins.addPlugin') })}
       {...(mode === 'edit' && { title: t('form.plugins.editPlugin') })}
diff --git a/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx 
b/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
index 1b459e840..604ab08d1 100644
--- a/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
@@ -51,6 +51,7 @@ export const SelectPluginsDrawer = (props: 
SelectPluginsDrawerProps) => {
         // protect, so Escape should close it like any other dialog (#3417)
         opened={opened}
         onClose={() => setOpened(false)}
+        closeButtonProps={{ 'aria-label': t('a11y.close') }}
         title={t('form.plugins.selectPlugins.title')}
       >
         <Drawer.Header p={0} mih="60px">
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 60b822fe1..a9b04ac0a 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -418,6 +418,8 @@
     }
   },
   "a11y": {
+    "clearSearch": "Suche zurücksetzen",
+    "close": "Schließen",
     "openSettings": "Open settings",
     "selectLanguage": "Select language",
     "toggleNavigation": "Toggle navigation"
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index b896a6ef9..4dac293bc 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -418,6 +418,8 @@
     }
   },
   "a11y": {
+    "clearSearch": "Clear search",
+    "close": "Close",
     "openSettings": "Open settings",
     "selectLanguage": "Select language",
     "toggleNavigation": "Toggle navigation"
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 635c9a48e..effabf5ad 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -418,6 +418,8 @@
     }
   },
   "a11y": {
+    "clearSearch": "Borrar búsqueda",
+    "close": "Cerrar",
     "openSettings": "Open settings",
     "selectLanguage": "Select language",
     "toggleNavigation": "Toggle navigation"
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index ed8e03371..f5cba5438 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -418,6 +418,8 @@
     }
   },
   "a11y": {
+    "clearSearch": "Aramayı temizle",
+    "close": "Kapat",
     "openSettings": "Open settings",
     "selectLanguage": "Select language",
     "toggleNavigation": "Toggle navigation"
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 84e323c40..de2767ace 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -418,6 +418,8 @@
     }
   },
   "a11y": {
+    "clearSearch": "清除搜索",
+    "close": "关闭",
     "openSettings": "打开设置",
     "selectLanguage": "选择语言",
     "toggleNavigation": "切换导航"

Reply via email to