From cb8ab2de525853cdd91a19053b7a94b51713e788 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Fri, 17 Jul 2026 09:50:52 -0600 Subject: [PATCH] Align tool action group semantics (#263) --- .../bf-036-bf-038-keyboard-visual-qa.md | 2 +- docs/accessibility/bf-037-screen-reader-qa.md | 2 +- src/app/[lang]/all-tools/page.tsx | 1 + src/core/i18n/translations/de.json | 1 + src/core/i18n/translations/en.json | 1 + src/core/i18n/translations/fr.json | 1 + src/core/i18n/translations/ja.json | 1 + src/core/i18n/translations/ko.json | 1 + src/core/i18n/translations/zh-CN.json | 1 + src/core/i18n/translations/zh-TW.json | 1 + .../tool-discovery/all-tools-discovery.tsx | 2 ++ .../tool-shell/external-request-status.tsx | 14 ++++++++++---- src/features/tool-shell/tool-action-bar.tsx | 2 +- src/lib/sitemap-lastmod.json | 14 +++++++------- tests/component/all-tools-discovery.test.tsx | 4 ++++ .../http-request-builder-page.test.tsx | 2 +- .../phase3-pipeline-builder-page.test.tsx | 18 +++++++++--------- .../component/representative-a11y-axe.test.tsx | 1 + tests/component/tool-action-bar.test.tsx | 7 +++++-- .../tool-shell-status-output.test.tsx | 7 ++++++- tests/guards/shared-a11y-surfaces.test.ts | 9 ++++++--- 21 files changed, 62 insertions(+), 30 deletions(-) diff --git a/docs/accessibility/bf-036-bf-038-keyboard-visual-qa.md b/docs/accessibility/bf-036-bf-038-keyboard-visual-qa.md index 97cc32f0..cf0c32b6 100644 --- a/docs/accessibility/bf-036-bf-038-keyboard-visual-qa.md +++ b/docs/accessibility/bf-036-bf-038-keyboard-visual-qa.md @@ -31,7 +31,7 @@ Scope: representative navigation, discovery, tool action, upload, and output sur ## Automated Coverage - `tests/guards/focus-visible-guard.test.ts` prevents removing visible focus rings from app/component surfaces. -- `tests/guards/shared-a11y-surfaces.test.ts` guards All Tools dialog focus trap/restore behavior, shared action toolbar names, and Pipeline Builder run status. +- `tests/guards/shared-a11y-surfaces.test.ts` guards All Tools dialog focus trap/restore behavior, shared action-group names, and Pipeline Builder run status. - `tests/guards/a11y-mobile-baseline.test.ts` guards skip-link behavior, live toast feedback, mobile touch target baseline, and core mobile routes. - `tests/guards/all-tools-performance-a11y-guard.test.ts` guards disabled explanations plus long-text/mobile containment on All Tools. - `tests/guards/theme-contrast-token-guard.test.ts` checks light/dark foreground and muted text contrast tokens against WCAG AA. diff --git a/docs/accessibility/bf-037-screen-reader-qa.md b/docs/accessibility/bf-037-screen-reader-qa.md index c428fd43..5f4bf46b 100644 --- a/docs/accessibility/bf-037-screen-reader-qa.md +++ b/docs/accessibility/bf-037-screen-reader-qa.md @@ -17,7 +17,7 @@ Scope: representative dynamic tool interfaces for BF-037. - All Tools: search exposes the result-count status as its accessible description; filtered result counts announce through a polite live region; the mobile filter drawer has a dialog name, description, trapped focus, Escape close, and focus restoration. - Pipeline Builder: final output is label-associated; run-log status announces through a polite live region; the run-log table has an accessible name; run failures expose an alert. - External-request media flow: confirmation panel identifies the destination/purpose/data sent; preview/download actions remain disabled with accessible descriptions until explicit confirmation. -- Shared tool actions: action groups expose a toolbar label; disabled actions use visible button names plus programmatic descriptions, without exposing tool payloads. +- Shared tool actions: action groups expose a named group container; disabled actions use visible button names plus programmatic descriptions, without exposing tool payloads. - Copy, download, export, save, and share feedback: toast title/description text is mirrored into the shared polite status region so success and failure states are announced consistently. ## Automated Coverage diff --git a/src/app/[lang]/all-tools/page.tsx b/src/app/[lang]/all-tools/page.tsx index 86ba8b5a..5956fd26 100644 --- a/src/app/[lang]/all-tools/page.tsx +++ b/src/app/[lang]/all-tools/page.tsx @@ -142,6 +142,7 @@ export default async function AllToolsPage({ params }: { params: Promise<{ lang: popularTags: requireTranslationValue(t.common.popular_tags, "common.popular_tags"), removeFilter: requireTranslationValue(t.common.remove_filter, "common.remove_filter"), showFilters: requireTranslationValue(t.common.show_filters, "common.show_filters"), + showFiltersWithCount: requireTranslationValue(t.common.show_filters_with_count, "common.show_filters_with_count"), showFewerTools: requireTranslationValue(t.common.show_fewer_tools, "common.show_fewer_tools"), showMoreTools: requireTranslationValue(t.common.show_more_tools, "common.show_more_tools"), closeFilters: requireTranslationValue(t.common.close_filters, "common.close_filters"), diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index a3de38f1..c68ca4ce 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/Domain", "remove_filter": "Filter entfernen", "show_filters": "Filter anzeigen", + "show_filters_with_count": "Filter anzeigen. Aktive Filter: {count}", "show_fewer_tools": "Weniger anzeigen", "show_more_tools": "Mehr anzeigen", "use_case_encode": "Kodieren/dekodieren", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index ce706587..2219b44a 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/domain", "remove_filter": "Remove filter", "show_filters": "Show filters", + "show_filters_with_count": "Show filters. Active filters: {count}", "show_fewer_tools": "Show fewer", "show_more_tools": "Show more", "use_case_encode": "Encode/decode", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 5673e964..86a2b0f4 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/domaine", "remove_filter": "Retirer le filtre", "show_filters": "Afficher les filtres", + "show_filters_with_count": "Afficher les filtres. Filtres actifs : {count}", "show_fewer_tools": "Afficher moins", "show_more_tools": "Afficher plus", "use_case_encode": "Encoder/décoder", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index e233c312..257df54a 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/ドメイン", "remove_filter": "フィルターを削除", "show_filters": "フィルターを表示", + "show_filters_with_count": "フィルターを表示(適用中: {count} 件)", "show_fewer_tools": "少なく表示", "show_more_tools": "もっと表示", "use_case_encode": "エンコード/デコード", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 5b5f23a0..eb2d279c 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/도메인", "remove_filter": "필터 제거", "show_filters": "필터 표시", + "show_filters_with_count": "필터 표시, 적용됨 {count}개", "show_fewer_tools": "줄여 보기", "show_more_tools": "더 보기", "use_case_encode": "인코드/디코드", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index d6e03c0f..a1c45b02 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/域名", "remove_filter": "移除筛选", "show_filters": "显示筛选", + "show_filters_with_count": "显示筛选,已选 {count} 项", "show_fewer_tools": "收起", "show_more_tools": "显示更多", "use_case_encode": "编码/解码", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 220ab1bd..01609af7 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -458,6 +458,7 @@ "input_url_domain": "URL/網域", "remove_filter": "移除篩選", "show_filters": "顯示篩選", + "show_filters_with_count": "顯示篩選,已選 {count} 項", "show_fewer_tools": "收合", "show_more_tools": "顯示更多", "use_case_encode": "編碼/解碼", diff --git a/src/features/tool-discovery/all-tools-discovery.tsx b/src/features/tool-discovery/all-tools-discovery.tsx index dbd1d549..7906d982 100644 --- a/src/features/tool-discovery/all-tools-discovery.tsx +++ b/src/features/tool-discovery/all-tools-discovery.tsx @@ -102,6 +102,7 @@ type AllToolsDiscoveryLabels = { removeFilter: string closeFilters: string showFilters: string + showFiltersWithCount: string showFewerTools: string showMoreTools: string clearRecentTools: string @@ -925,6 +926,7 @@ export function AllToolsDiscovery({ type="button" className="mt-4 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/45 px-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 lg:hidden" onClick={() => setShowMobileFilters(true)} + aria-label={labels.showFiltersWithCount.replace("{count}", String(activeFilters.length))} aria-expanded={showMobileFilters} aria-controls="all-tools-filter-drawer" aria-describedby="all-tools-result-status" diff --git a/src/features/tool-shell/external-request-status.tsx b/src/features/tool-shell/external-request-status.tsx index 884f32cc..90974ca0 100644 --- a/src/features/tool-shell/external-request-status.tsx +++ b/src/features/tool-shell/external-request-status.tsx @@ -92,11 +92,17 @@ export function ExternalRequestStatus({ {announcement} + data-external-request-announcement={role === "status" ? true : undefined} + >{role === "status" ? announcement : ""} + {role === "alert" ? announcement : ""} ) } diff --git a/src/features/tool-shell/tool-action-bar.tsx b/src/features/tool-shell/tool-action-bar.tsx index 3adac4d3..397cf98c 100644 --- a/src/features/tool-shell/tool-action-bar.tsx +++ b/src/features/tool-shell/tool-action-bar.tsx @@ -273,7 +273,7 @@ export function ToolActionBar({ return (
{primaryActions.map((action) => { diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index 0deb4718..2179b7c3 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -184,13 +184,13 @@ }, "static": { "all-tools": { - "en": "2026-07-16T00:00:00.000Z", - "zh-CN": "2026-07-16T00:00:00.000Z", - "zh-TW": "2026-07-16T00:00:00.000Z", - "ja": "2026-07-16T00:00:00.000Z", - "ko": "2026-07-16T00:00:00.000Z", - "de": "2026-07-16T00:00:00.000Z", - "fr": "2026-07-16T00:00:00.000Z" + "en": "2026-07-17T00:00:00.000Z", + "zh-CN": "2026-07-17T00:00:00.000Z", + "zh-TW": "2026-07-17T00:00:00.000Z", + "ja": "2026-07-17T00:00:00.000Z", + "ko": "2026-07-17T00:00:00.000Z", + "de": "2026-07-17T00:00:00.000Z", + "fr": "2026-07-17T00:00:00.000Z" }, "about": { "en": "2026-07-16T00:00:00.000Z", diff --git a/tests/component/all-tools-discovery.test.tsx b/tests/component/all-tools-discovery.test.tsx index fbfe5794..ef5397e1 100644 --- a/tests/component/all-tools-discovery.test.tsx +++ b/tests/component/all-tools-discovery.test.tsx @@ -40,6 +40,7 @@ const labels = { popularTags: "Popular tags", removeFilter: "Remove filter", showFilters: "Show filters", + showFiltersWithCount: "Show filters. Active filters: {count}", showFewerTools: "Show fewer", showMoreTools: "Show more", closeFilters: "Done", @@ -387,6 +388,7 @@ describe("AllToolsDiscovery", () => { renderDiscovery() const trigger = screen.getByRole("button", { name: /Show filters/ }) + expect(trigger).toHaveAccessibleName("Show filters. Active filters: 0") trigger.focus() fireEvent.click(trigger) @@ -396,9 +398,11 @@ describe("AllToolsDiscovery", () => { expect(within(drawer).getByText(/10 tools/)).toBeInTheDocument() fireEvent.click(within(drawer).getAllByRole("button", { name: "File" })[0]) expect(within(drawer).getByText(/1 tools/)).toBeInTheDocument() + expect(trigger).toHaveAccessibleName("Show filters. Active filters: 1") fireEvent.click(within(drawer).getByRole("button", { name: "Clear filters" })) expect(within(drawer).getByText(/10 tools/)).toBeInTheDocument() + expect(trigger).toHaveAccessibleName("Show filters. Active filters: 0") const doneButtons = within(drawer).getAllByRole("button", { name: "Done" }) fireEvent.click(doneButtons[doneButtons.length - 1]) diff --git a/tests/component/http-request-builder-page.test.tsx b/tests/component/http-request-builder-page.test.tsx index a71f0d54..bc654234 100644 --- a/tests/component/http-request-builder-page.test.tsx +++ b/tests/component/http-request-builder-page.test.tsx @@ -126,7 +126,7 @@ describe("HttpRequestBuilderPage", () => { expect(screen.getByRole("group", { name: "Add to favorites / Remove from favorites" })).toHaveAttribute("data-tool-global-actions") expect(screen.getByRole("button", { name: "Add to favorites" })).toBeInTheDocument() - expect(screen.getByRole("toolbar", { name: "Tool actions" })).toBeInTheDocument() + expect(screen.getByRole("group", { name: "Tool actions" })).toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "Add Header" })) fireEvent.change(screen.getAllByRole("textbox", { name: "Header name" })[1], { diff --git a/tests/component/phase3-pipeline-builder-page.test.tsx b/tests/component/phase3-pipeline-builder-page.test.tsx index 51ceac49..6a489728 100644 --- a/tests/component/phase3-pipeline-builder-page.test.tsx +++ b/tests/component/phase3-pipeline-builder-page.test.tsx @@ -104,7 +104,7 @@ describe("phase 3 pipeline builder page", () => { expect(screen.getByRole("heading", { name: "Pipeline Builder" })).toBeInTheDocument() expect(screen.getByRole("heading", { name: "Build a recipe in three moves" })).toBeInTheDocument() - expect(screen.getByRole("toolbar", { name: "Tool actions" })).toBeInTheDocument() + expect(screen.getByRole("group", { name: "Tool actions" })).toBeInTheDocument() expect(screen.getByLabelText("Initial input")).toBeInTheDocument() expect(screen.getByLabelText("Final output")).toBeInTheDocument() expect(screen.getAllByText("Steps").length).toBeGreaterThan(0) @@ -310,13 +310,13 @@ describe("phase 3 pipeline builder page", () => { expect(screen.getByRole("button", { name: "Export structure only" })).toBeInTheDocument() }, 10_000) - it("restores privacy dialog focus for toolbar and inspector actions", async () => { + it("restores privacy dialog focus for action-bar and inspector actions", async () => { renderWithEnglish() - const toolbar = screen.getByRole("toolbar", { name: "Tool actions" }) - const toolbarExport = within(toolbar).getByRole("button", { name: "Export JSON" }) - toolbarExport.focus() - fireEvent.click(toolbarExport) + const actionGroup = screen.getByRole("group", { name: "Tool actions" }) + const actionGroupExport = within(actionGroup).getByRole("button", { name: "Export JSON" }) + actionGroupExport.focus() + fireEvent.click(actionGroupExport) const firstDialog = await screen.findByRole("dialog", { name: "Privacy preview" }) expect(firstDialog).toHaveAccessibleDescription("Export a structure-only JSON recipe file.") @@ -339,14 +339,14 @@ describe("phase 3 pipeline builder page", () => { fireEvent.keyDown(document, { key: "Escape" }) await waitFor(() => expect(screen.queryByRole("dialog", { name: "Privacy preview" })).not.toBeInTheDocument()) - await waitFor(() => expect(toolbarExport).toHaveFocus()) + await waitFor(() => expect(actionGroupExport).toHaveFocus()) - fireEvent.click(toolbarExport) + fireEvent.click(actionGroupExport) const cancelDialog = await screen.findByRole("dialog", { name: "Privacy preview" }) fireEvent.click(within(cancelDialog).getByRole("button", { name: "Cancel" })) await waitFor(() => expect(screen.queryByRole("dialog", { name: "Privacy preview" })).not.toBeInTheDocument()) - await waitFor(() => expect(toolbarExport).toHaveFocus()) + await waitFor(() => expect(actionGroupExport).toHaveFocus()) const inspector = screen.getByRole("complementary", { name: "Recipe inspector" }) const inspectorExport = within(inspector).getByRole("button", { name: "Export JSON" }) diff --git a/tests/component/representative-a11y-axe.test.tsx b/tests/component/representative-a11y-axe.test.tsx index 9a040e93..9cd2f72b 100644 --- a/tests/component/representative-a11y-axe.test.tsx +++ b/tests/component/representative-a11y-axe.test.tsx @@ -112,6 +112,7 @@ const discoveryLabels = { popularTags: "Popular tags", removeFilter: "Remove filter", showFilters: "Show filters", + showFiltersWithCount: "Show filters. Active filters: {count}", showFewerTools: "Show fewer", showMoreTools: "Show more", closeFilters: "Done", diff --git a/tests/component/tool-action-bar.test.tsx b/tests/component/tool-action-bar.test.tsx index ee887dc9..18f677d3 100644 --- a/tests/component/tool-action-bar.test.tsx +++ b/tests/component/tool-action-bar.test.tsx @@ -45,9 +45,12 @@ describe("ToolActionBar", () => { render() - const toolbar = screen.getByRole("toolbar", { name: "Tool actions" }) - const labels = within(toolbar).getAllByRole("button").map((button) => button.textContent) + const actionGroup = screen.getByRole("group", { name: "Tool actions" }) + const actionButtons = within(actionGroup).getAllByRole("button") + const labels = actionButtons.map((button) => button.textContent) + expect(screen.queryByRole("toolbar", { name: "Tool actions" })).not.toBeInTheDocument() + expect(actionButtons.filter((button) => !button.hasAttribute("disabled")).every((button) => button.tabIndex === 0)).toBe(true) expect(labels).toEqual(["Sample", "Clear", "Format", "Copy", "Download"]) expect(screen.getByRole("button", { name: "Download", description: "Nothing to download." })).toHaveAttribute("title", "Download: Nothing to download.") }) diff --git a/tests/component/tool-shell-status-output.test.tsx b/tests/component/tool-shell-status-output.test.tsx index 93e5755f..9c79a802 100644 --- a/tests/component/tool-shell-status-output.test.tsx +++ b/tests/component/tool-shell-status-output.test.tsx @@ -167,10 +167,12 @@ describe("shared tool shell status and output components", () => { ) const permissionStatus = screen.getByRole("status") + const failureAlert = screen.getByRole("alert") expect(permissionStatus.closest('[data-external-request-status="permission"]')).not.toBeNull() expect(permissionStatus).toHaveAttribute("aria-live", "polite") expect(permissionStatus.childNodes).toHaveLength(1) expect(permissionStatus.firstChild?.nodeType).toBe(Node.TEXT_NODE) + expect(failureAlert).toBeEmptyDOMElement() rerender( @@ -184,7 +186,9 @@ describe("shared tool shell status and output components", () => { ) expect(screen.getByRole("alert").closest('[data-external-request-status="offline"]')).not.toBeNull() - expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive") + expect(screen.getByRole("alert")).not.toHaveAttribute("aria-live") + expect(screen.getByRole("alert")).toHaveAttribute("data-external-request-announcement") + expect(permissionStatus).toBeEmptyDOMElement() }) it("exposes external request status, next step, hosts, and alert semantics for failures", () => { @@ -201,5 +205,6 @@ describe("shared tool shell status and output components", () => { expect(screen.getByRole("alert")).toHaveTextContent("What to do next: Reconnect and retry.") expect(screen.getByRole("alert")).toHaveTextContent("i.ytimg.com") expect(screen.getByRole("alert")).toHaveTextContent("Network access starts only after you choose the external-request action.") + expect(screen.getByRole("status")).toBeEmptyDOMElement() }) }) diff --git a/tests/guards/shared-a11y-surfaces.test.ts b/tests/guards/shared-a11y-surfaces.test.ts index 601b7845..b58498cf 100644 --- a/tests/guards/shared-a11y-surfaces.test.ts +++ b/tests/guards/shared-a11y-surfaces.test.ts @@ -23,7 +23,8 @@ describe("shared accessibility surfaces", () => { it("keeps the shared tool action bar named and described", () => { const source = readSource("src/features/tool-shell/tool-action-bar.tsx") - expect(source).toContain("role=\"toolbar\"") + expect(source).toContain("role=\"group\"") + expect(source).not.toContain("role=\"toolbar\"") expect(source).toContain("aria-label={t.common.tool_actions}") expect(source).toContain("aria-describedby={disabledDescriptionId}") expect(source).toContain("aria-describedby={handoffDisabledDescriptionId}") @@ -66,8 +67,10 @@ describe("shared accessibility surfaces", () => { for (const source of [youtube, vimeo, instagram]) { expect(source).toContain("ExternalRequestStatus") } - expect(externalRequestStatus).toContain('role={role}') - expect(externalRequestStatus).toContain('aria-live={role === "alert" ? "assertive" : "polite"}') + expect(externalRequestStatus).toContain('role="status"') + expect(externalRequestStatus).toContain('aria-live="polite"') + expect(externalRequestStatus).toContain('role="alert"') + expect(externalRequestStatus).toContain('{role === "alert" ? announcement : ""}') expect(externalRequestStatus).toContain("data-external-request-status") }) })