Skip to content

Commit ec535ea

Browse files
committed
fix(compare): improve facet selector accessibility
1 parent d21b146 commit ec535ea

File tree

2 files changed

+87
-22
lines changed

2 files changed

+87
-22
lines changed

app/components/Compare/FacetSelector.vue

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,96 @@ function isCategoryNoneSelected(category: string): boolean {
2222
const selectableFacets = facets.filter(f => !f.comingSoon)
2323
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.id))
2424
}
25+
26+
function getCategoryActiveControl(category: string): 'all' | 'none' {
27+
if (isCategoryAllSelected(category)) return 'all'
28+
if (isCategoryNoneSelected(category)) return 'none'
29+
return 'all'
30+
}
31+
32+
function handleCategoryControlKeydown(category: string, event: KeyboardEvent): void {
33+
const { key } = event
34+
35+
if (key === 'Enter') {
36+
event.preventDefault()
37+
return
38+
}
39+
40+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) return
41+
42+
event.preventDefault()
43+
44+
const target = event.currentTarget as HTMLElement | null
45+
if (!target) return
46+
47+
const group = target.closest('[data-facet-category-radiogroup]') as HTMLElement | null
48+
if (!group) return
49+
50+
const radios = Array.from(
51+
group.querySelectorAll<HTMLElement>('[role="radio"]'),
52+
)
53+
if (!radios.length) return
54+
55+
const currentIndex = radios.indexOf(target)
56+
if (currentIndex === -1) return
57+
58+
let nextIndex = currentIndex
59+
60+
if (key === 'ArrowLeft' || key === 'ArrowUp') {
61+
nextIndex = (currentIndex - 1 + radios.length) % radios.length
62+
} else if (key === 'ArrowRight' || key === 'ArrowDown') {
63+
nextIndex = (currentIndex + 1) % radios.length
64+
}
65+
66+
const nextRadio = radios[nextIndex]
67+
const radioType = nextRadio.dataset.radioType
68+
69+
if (radioType === 'all') {
70+
selectCategory(category)
71+
} else if (radioType === 'none') {
72+
deselectCategory(category)
73+
}
74+
75+
nextRadio.focus()
76+
}
2577
</script>
2678

2779
<template>
2880
<div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')">
2981
<div v-for="category in categoryOrder" :key="category">
3082
<!-- Category header with all/none buttons -->
31-
<div class="flex items-center gap-2 mb-2">
32-
<span class="text-3xs text-fg-subtle uppercase tracking-wider">
83+
<div
84+
class="flex items-center gap-2 mb-2"
85+
role="radiogroup"
86+
:aria-labelledby="`facet-category-label-${category}`"
87+
data-facet-category-radiogroup
88+
>
89+
<span
90+
:id="`facet-category-label-${category}`"
91+
class="text-3xs text-fg-subtle uppercase tracking-wider"
92+
>
3393
{{ getCategoryLabel(category) }}
3494
</span>
35-
<!-- TODO: These should be radios, since they are mutually exclusive, and currently this behavior is faked with buttons -->
3695
<ButtonBase
37-
:aria-label="
38-
$t('compare.facets.select_category', { category: getCategoryLabel(category) })
39-
"
40-
:aria-pressed="isCategoryAllSelected(category)"
41-
:disabled="isCategoryAllSelected(category)"
96+
role="radio"
97+
:aria-checked="isCategoryAllSelected(category)"
98+
:aria-disabled="isCategoryAllSelected(category)"
99+
:tabindex="getCategoryActiveControl(category) === 'all' ? 0 : -1"
100+
data-radio-type="all"
101+
@keydown="event => handleCategoryControlKeydown(category, event)"
42102
@click="selectCategory(category)"
43103
size="small"
44104
>
45105
{{ $t('compare.facets.all') }}
46106
</ButtonBase>
47107
<span class="text-2xs text-fg-muted/40">/</span>
48108
<ButtonBase
49-
:aria-label="
50-
$t('compare.facets.deselect_category', { category: getCategoryLabel(category) })
51-
"
52-
:aria-pressed="isCategoryNoneSelected(category)"
53-
:disabled="isCategoryNoneSelected(category)"
109+
role="radio"
110+
:aria-checked="isCategoryNoneSelected(category)"
111+
:aria-disabled="isCategoryNoneSelected(category)"
112+
:tabindex="getCategoryActiveControl(category) === 'none' ? 0 : -1"
113+
data-radio-type="none"
114+
@keydown="event => handleCategoryControlKeydown(category, event)"
54115
@click="deselectCategory(category)"
55116
size="small"
56117
>
@@ -59,17 +120,21 @@ function isCategoryNoneSelected(category: string): boolean {
59120
</div>
60121

61122
<!-- Facet buttons -->
62-
<div class="flex items-center gap-1.5 flex-wrap" role="group">
63-
<!-- TODO: These should be checkboxes -->
123+
<div
124+
class="flex items-center gap-1.5 flex-wrap"
125+
role="group"
126+
:aria-labelledby="`facet-category-label-${category}`"
127+
data-facet-category-facets
128+
>
64129
<ButtonBase
65130
v-for="facet in facetsByCategory[category]"
66131
:key="facet.id"
67132
size="small"
68133
:title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description"
69-
:disabled="facet.comingSoon"
70-
:aria-pressed="isFacetSelected(facet.id)"
71-
:aria-label="facet.label"
72-
class="gap-1 px-1.5 rounded transition-colors focus-visible:outline-accent/70"
134+
:aria-disabled="facet.comingSoon"
135+
role="checkbox"
136+
:aria-checked="isFacetSelected(facet.id)"
137+
class="gap-1 px-1.5 rounded transition-colors"
73138
:class="
74139
facet.comingSoon
75140
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'

test/nuxt/components/compare/FacetSelector.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,14 @@ describe('FacetSelector', () => {
164164
expect(component.find('.i-lucide\\:plus').exists()).toBe(true)
165165
})
166166

167-
it('applies aria-pressed for selected state', async () => {
167+
it('applies aria-checked for selected checkbox state', async () => {
168168
mockSelectedFacets.value = ['downloads']
169169
mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads')
170170

171171
const component = await mountSuspended(FacetSelector)
172172

173-
const buttons = component.findAll('button[aria-pressed]')
174-
const selectedButton = buttons.find(b => b.attributes('aria-pressed') === 'true')
173+
const buttons = component.findAll('button[role="checkbox"][aria-checked="true"]')
174+
const selectedButton = buttons[0]
175175
expect(selectedButton).toBeDefined()
176176
})
177177

0 commit comments

Comments
 (0)