@@ -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'
0 commit comments