From e8d1065d358e042ce5df2286764b0e0b42726b54 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:06:55 +0700 Subject: [PATCH 1/7] fix(a11y): expose the selected state of leaderboard and compare selectors (#1067) (#1068) * fix(a11y): expose the selected state of leaderboard and compare selectors (#1067) The category switchers used tab roles without panels, arrow keys or a roving tab stop, and the leaderboard's criterion, persona and price pills showed the selection only by colour. Each selector is now a named button group with aria-pressed, and the customize button gets aria-expanded and aria-controls for its weight panel. Co-Authored-By: Claude Opus 5.5 * fix(a11y): name custom leaderboard weights on the customize button (#1067) Custom weights select no persona pill and are shown only by colouring the customize button; its name now says so too, so a screen reader gets the same cue. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: JOY Co-authored-by: Claude Opus 5.5 --- apps/web/e2e/leaderboard.spec.ts | 15 +++-- apps/web/e2e/selector-state.spec.ts | 59 +++++++++++++++++++ apps/web/locales/cs.json | 1 + apps/web/locales/en.json | 1 + apps/web/locales/vi.json | 1 + .../components/compare/CompareWorkspace.tsx | 6 +- .../leaderboard/LeaderboardExplorer.tsx | 55 +++++++++++++---- 7 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 apps/web/e2e/selector-state.spec.ts diff --git a/apps/web/e2e/leaderboard.spec.ts b/apps/web/e2e/leaderboard.spec.ts index f4d3c337..8097ba0d 100644 --- a/apps/web/e2e/leaderboard.spec.ts +++ b/apps/web/e2e/leaderboard.spec.ts @@ -20,8 +20,11 @@ test.use({ }, }); +// The category switcher is a group of single-select buttons (DOS/Rate#1067). +const categories = (page: Page) => + page.getByRole('group', { name: 'Chọn ngành hàng' }); const selectedTab = (page: Page) => - page.getByRole('tablist').getByRole('tab', { selected: true }); + categories(page).getByRole('button', { pressed: true }); // The active criterion is the dark pill. const activeCriterion = (page: Page) => page.locator('button.btn-dark'); @@ -30,8 +33,9 @@ test('/leaderboard is served from the page cache', async ({ request }) => { const res = await request.get('/leaderboard?cat=earbuds'); expect(res.status()).toBe(200); expect(res.headers()['x-nextjs-cache']).toMatch(/^(HIT|STALE)$/); - // Only the URL reader is client-rendered: the tabs are in the server HTML. - expect(await res.text()).toContain('role="tablist"'); + // Only the URL reader is client-rendered: the category switcher is in the + // server HTML. + expect(await res.text()).toContain('aria-label="Chọn ngành hàng"'); }); test('a leaderboard deep link opens its tab, and a click updates the URL', async ({ @@ -41,9 +45,8 @@ test('a leaderboard deep link opens its tab, and a click updates the URL', async await expect(selectedTab(page)).toContainText('Tai nghe ANC'); await expect(activeCriterion(page)).toContainText('Điểm người mua thực chất'); - await page - .getByRole('tablist') - .getByRole('tab', { name: /Laptop & MacBook/ }) + await categories(page) + .getByRole('button', { name: /Laptop & MacBook/ }) .click(); await expect(selectedTab(page)).toContainText('Laptop & MacBook'); await expect(page).toHaveURL(/\/leaderboard\?cat=laptops&criterion=user$/); diff --git a/apps/web/e2e/selector-state.spec.ts b/apps/web/e2e/selector-state.spec.ts new file mode 100644 index 00000000..2e08e9c4 --- /dev/null +++ b/apps/web/e2e/selector-state.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +// Leaderboard and compare selectors are named button groups that expose the +// pressed choice, not colour alone, and no tab roles without tab panels +// (DOS/Rate#1067). +test('leaderboard selectors expose their state', async ({ page }) => { + expect((await page.goto('/leaderboard?criterion=user'))?.status()).toBe(200); + await expect(page.getByRole('tab')).toHaveCount(0); + + const groups = [ + page.getByRole('group', { name: 'Chọn ngành hàng' }), + page.getByRole('group', { name: /Xếp hạng theo tiêu chí/ }), + page.getByRole('group', { name: /Gợi ý theo nhu cầu/ }), + page.getByRole('group', { name: 'Lọc theo tầm giá' }), + ]; + for (const group of groups) { + await expect(group).toBeVisible(); + await expect(group.getByRole('button', { pressed: true })).toHaveCount(1); + } + await expect(groups[1]!.getByRole('button', { pressed: true })).toContainText( + 'Điểm người mua thực chất' + ); + + // Choosing a price segment moves the pressed state. + const price = groups[3]!; + const unpressed = price.getByRole('button', { pressed: false }).first(); + const label = (await unpressed.innerText()).trim(); + await unpressed.click(); + await expect(price.getByRole('button', { pressed: true })).toHaveText(label); + + // The customize button reports the weight panel it opens. + const customize = page.locator( + 'button[aria-controls="leaderboard-weight-sliders"]' + ); + await expect(customize).toHaveAttribute('aria-expanded', 'false'); + await customize.click(); + await expect(customize).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#leaderboard-weight-sliders')).toBeVisible(); + + // Moving a slider makes the weights custom: no preset is pressed, and the + // customize button, coloured for it, says so in its name. + await expect(customize).not.toHaveAccessibleName(/Trọng số cá nhân hóa/); + const slider = page + .locator('#leaderboard-weight-sliders input[type="range"]') + .first(); + await slider.focus(); + await page.keyboard.press('ArrowRight'); + await expect(customize).toHaveAccessibleName(/Trọng số cá nhân hóa/); + await expect(groups[2]!.getByRole('button', { pressed: true })).toHaveCount( + 0 + ); +}); + +test('the compare category switcher exposes its state', async ({ page }) => { + expect((await page.goto('/compare'))?.status()).toBe(200); + await expect(page.getByRole('tab')).toHaveCount(0); + const group = page.getByRole('group', { name: 'Chọn danh mục đối chiếu' }); + await expect(group.getByRole('button', { pressed: true })).toHaveCount(1); +}); diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index c4dc9585..35a3bdcf 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -385,6 +385,7 @@ "brandFilterLabel": "Značka", "brandFilterAria": "Filtrovat podle značky", "priceAll": "Všechny cenové hladiny", + "priceSegmentAria": "Filtrovat podle ceny", "priceUnder15": "Do 15M VND", "price15to25": "15M - 25M VND", "priceOver25": "Nad 25M VND", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index b9439720..01498e10 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -385,6 +385,7 @@ "brandFilterLabel": "Brand", "brandFilterAria": "Filter by brand", "priceAll": "All price tiers", + "priceSegmentAria": "Filter by price range", "priceUnder15": "Under 15M VND", "price15to25": "15M - 25M VND", "priceOver25": "Over 25M VND", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 934bde13..a9c9ce07 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -385,6 +385,7 @@ "brandFilterLabel": "Hãng", "brandFilterAria": "Lọc theo thương hiệu", "priceAll": "Mọi tầm giá", + "priceSegmentAria": "Lọc theo tầm giá", "priceUnder15": "Dưới 15 triệu", "price15to25": "15 - 25 triệu", "priceOver25": "Trên 25 triệu", diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index e1261b00..a4376800 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -1307,7 +1307,8 @@ export default function CompareWorkspace({
{categoryTabs.map((tab) => { @@ -1319,8 +1320,7 @@ export default function CompareWorkspace({
- {/* 5-Category Switcher Pills */} + {/* 5-Category Switcher Pills: single-select buttons, not tabs (no + panels); the pressed one is exposed, not only its colour + (DOS/Rate#1067). */}
{categoryTabs.map((tab) => { @@ -841,8 +844,7 @@ export default function LeaderboardExplorer({
-
+
{( [ 'none', @@ -933,6 +959,7 @@ export default function LeaderboardExplorer({
{showWeightSliders && ( -
+
{( [ @@ -1004,7 +1034,11 @@ export default function LeaderboardExplorer({
-
+
{( [ { id: 'all', label: copy.priceAll }, @@ -1016,6 +1050,7 @@ export default function LeaderboardExplorer({
- {/* Progress Bar */} + {/* Progress Bar: draws the badge percentages above, so it is hidden + from assistive tech rather than labelled (DOS/Rate#1071). */}