[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(a11y): extract explicit tag components and enforced a11y (#779)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Marcus Blättermann
coderabbitai[bot]
autofix-ci[bot]
and committed by
GitHub
4124003f 75d1bf28

+210 -71
+20 -25
app/components/Filter/Panel.vue
··· 243 243 role="radiogroup" 244 244 :aria-label="$t('filters.weekly_downloads')" 245 245 > 246 - <TagClickable 246 + <TagRadioButton 247 247 v-for="range in DOWNLOAD_RANGES" 248 248 :key="range.value" 249 - type="button" 250 - role="radio" 251 - :aria-checked="filters.downloadRange === range.value" 252 - :status="filters.downloadRange === range.value ? 'active' : 'default'" 253 - @click="emit('update:downloadRange', range.value)" 249 + :model-value="filters.downloadRange" 250 + :value="range.value" 251 + @update:modelValue="emit('update:downloadRange', $event as DownloadRange)" 252 + name="range" 254 253 > 255 254 {{ $t(getDownloadRangeLabelKey(range.value)) }} 256 - </TagClickable> 255 + </TagRadioButton> 257 256 </div> 258 257 </fieldset> 259 258 ··· 267 266 role="radiogroup" 268 267 :aria-label="$t('filters.updated_within')" 269 268 > 270 - <TagClickable 269 + <TagRadioButton 271 270 v-for="option in UPDATED_WITHIN_OPTIONS" 272 271 :key="option.value" 273 - type="button" 274 - role="radio" 275 - :aria-checked="filters.updatedWithin === option.value" 276 - :status="filters.updatedWithin === option.value ? 'active' : 'default'" 277 - @click="emit('update:updatedWithin', option.value)" 272 + :model-value="filters.updatedWithin" 273 + :value="option.value" 274 + name="updatedWithin" 275 + @update:modelValue="emit('update:updatedWithin', $event as UpdatedWithin)" 278 276 > 279 277 {{ $t(getUpdatedWithinLabelKey(option.value)) }} 280 - </TagClickable> 278 + </TagRadioButton> 281 279 </div> 282 280 </fieldset> 283 281 ··· 290 288 </span> 291 289 </legend> 292 290 <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')"> 293 - <TagClickable 291 + <TagRadioButton 294 292 v-for="security in SECURITY_FILTER_VALUES" 295 293 :key="security" 296 - type="button" 297 - role="radio" 298 294 disabled 299 - :aria-checked="filters.security === security" 300 - :status="filters.security === security ? 'active' : 'default'" 295 + :model-value="filters.security" 296 + :value="security" 297 + name="security" 301 298 > 302 299 {{ $t(getSecurityLabelKey(security)) }} 303 - </TagClickable> 300 + </TagRadioButton> 304 301 </div> 305 302 </fieldset> 306 303 ··· 310 307 {{ $t('filters.keywords') }} 311 308 </legend> 312 309 <div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')"> 313 - <TagClickable 310 + <TagButton 314 311 v-for="keyword in displayedKeywords" 315 312 :key="keyword" 316 - type="button" 317 - :aria-pressed="filters.keywords.includes(keyword)" 318 - :status="filters.keywords.includes(keyword) ? 'active' : 'default'" 313 + :pressed="filters.keywords.includes(keyword)" 319 314 @click="emit('toggleKeyword', keyword)" 320 315 > 321 316 {{ keyword }} 322 - </TagClickable> 317 + </TagButton> 323 318 <button 324 319 v-if="hasMoreKeywords" 325 320 type="button"
+4 -5
app/components/Package/Card.vue
··· 162 162 :aria-label="$t('package.card.keywords')" 163 163 class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none" 164 164 > 165 - <TagClickable 165 + <TagButton 166 166 v-for="keyword in result.package.keywords.slice(0, 5)" 167 + class="pointer-events-auto" 167 168 :key="keyword" 168 - type="button" 169 - class="pointer-events-auto" 170 - :status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'" 169 + :pressed="props.filters?.keywords.includes(keyword)" 171 170 :title="`Filter by ${keyword}`" 172 171 @click.stop="emit('clickKeyword', keyword)" 173 172 > 174 173 {{ keyword }} 175 - </TagClickable> 174 + </TagButton> 176 175 <span 177 176 v-if="result.package.keywords.length > 5" 178 177 class="text-fg-subtle text-xs pointer-events-auto"
+2 -4
app/components/Package/Keywords.vue
··· 1 1 <script setup lang="ts"> 2 - import { NuxtLink } from '#components' 3 - 4 2 defineProps<{ 5 3 keywords?: string[] 6 4 }>() ··· 9 7 <CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords"> 10 8 <ul class="flex flex-wrap gap-1.5 list-none m-0 p-0"> 11 9 <li v-for="keyword in keywords.slice(0, 15)" :key="keyword"> 12 - <TagClickable :as="NuxtLink" :to="{ name: 'search', query: { q: `keywords:${keyword}` } }"> 10 + <TagLink :to="{ name: 'search', query: { q: `keywords:${keyword}` } }"> 13 11 {{ keyword }} 14 - </TagClickable> 12 + </TagLink> 15 13 </li> 16 14 </ul> 17 15 </CollapsibleSection>
+3 -4
app/components/Package/TableRow.vue
··· 126 126 class="flex flex-wrap gap-1 justify-end" 127 127 :aria-label="$t('package.card.keywords')" 128 128 > 129 - <TagClickable 129 + <TagButton 130 130 v-for="keyword in pkg.keywords.slice(0, 3)" 131 131 :key="keyword" 132 - type="button" 133 - :status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'" 132 + :pressed="props.filters?.keywords.includes(keyword)" 134 133 :title="`Filter by ${keyword}`" 135 134 @click.stop="emit('clickKeyword', keyword)" 136 135 :class="{ 'group-hover:bg-bg-elevated': !props.filters?.keywords.includes(keyword) }" 137 136 > 138 137 {{ keyword }} 139 - </TagClickable> 138 + </TagButton> 140 139 <span 141 140 v-if="pkg.keywords.length > 3" 142 141 class="text-fg-subtle text-xs"
+31
app/components/Tag/Button.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + disabled?: boolean 4 + /** 5 + * type should never be used, because this will always be a button. 6 + * 7 + * If you want a link use `TagLink` instead. 8 + * */ 9 + type?: never 10 + pressed?: boolean 11 + }>() 12 + </script> 13 + 14 + <template> 15 + <button 16 + class="inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 17 + :class="[ 18 + pressed 19 + ? 'bg-fg text-bg border-fg hover:(text-text-bg/50)' 20 + : 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)', 21 + { 22 + 'opacity-50 cursor-not-allowed': disabled, 23 + }, 24 + ]" 25 + type="button" 26 + :disabled="disabled ? true : undefined" 27 + :aria-pressed="pressed" 28 + > 29 + <slot /> 30 + </button> 31 + </template>
-25
app/components/Tag/Clickable.vue
··· 1 - <script setup lang="ts"> 2 - const props = withDefaults( 3 - defineProps<{ as?: string | Component; status?: 'default' | 'active'; disabled?: boolean }>(), 4 - { 5 - status: 'default', 6 - as: 'button', 7 - }, 8 - ) 9 - </script> 10 - 11 - <template> 12 - <component 13 - :is="props.as" 14 - class="inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 15 - :class="{ 16 - 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)': 17 - status === 'default', 18 - 'bg-fg text-bg border-fg hover:(text-text-bg/50)': status === 'active', 19 - 'opacity-50 cursor-not-allowed': disabled, 20 - }" 21 - :disabled="disabled" 22 - > 23 - <slot /> 24 - </component> 25 - </template>
+37
app/components/Tag/Link.vue
··· 1 + <script setup lang="ts"> 2 + import type { NuxtLinkProps } from '#app' 3 + 4 + const { current, ...props } = defineProps< 5 + { 6 + /** Disabled links will be displayed as plain text */ 7 + disabled?: boolean 8 + /** 9 + * `type` should never be used, because this will always be a link. 10 + * 11 + * If you want a button use `TagButton` instead. 12 + * */ 13 + type?: never 14 + current?: boolean 15 + } & 16 + /** This makes sure the link always has either `to` or `href` */ 17 + (Required<Pick<NuxtLinkProps, 'to'>> | Required<Pick<NuxtLinkProps, 'href'>>) & 18 + NuxtLinkProps 19 + >() 20 + </script> 21 + 22 + <template> 23 + <!-- This is only a placeholder implementation yet. It will probably need some additional styling, but note: A disabled link is just text. --> 24 + <span v-if="disabled" class="opacity-50"><slot /></span> 25 + <NuxtLink 26 + v-else 27 + class="inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 28 + :class="{ 29 + 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)': !current, 30 + 'bg-fg text-bg border-fg hover:(text-text-bg/50)': current, 31 + 'opacity-50 cursor-not-allowed': disabled, 32 + }" 33 + v-bind="props" 34 + > 35 + <slot /> 36 + </NuxtLink> 37 + </template>
+45
app/components/Tag/RadioButton.vue
··· 1 + <script setup lang="ts"> 2 + const model = defineModel<string>() 3 + 4 + const props = defineProps<{ 5 + disabled?: boolean 6 + /** 7 + * type should never be used, because this will always be a radio button. 8 + * 9 + * If you want a link use `TagLink` instead. 10 + * */ 11 + type?: never 12 + 13 + /** Shouldn't try to set `checked` explicitly, is handled internally */ 14 + checked?: never 15 + value: string 16 + }>() 17 + 18 + const uid = useId() 19 + const internalId = `${model.value}-${uid}` 20 + const checked = computed(() => model.value === props.value) 21 + /** Todo: This shouldn't be necessary, but using v-model on `input type=radio` doesn't work as expected in Vue */ 22 + const onChange = () => { 23 + model.value = props.value 24 + } 25 + </script> 26 + 27 + <template> 28 + <div> 29 + <input 30 + type="radio" 31 + :id="internalId" 32 + :value="props.value" 33 + :checked="checked" 34 + :disabled="props.disabled ? true : undefined" 35 + @change="onChange" 36 + class="peer sr-only" 37 + /> 38 + <label 39 + class="bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover) inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 peer-focus:ring-2 peer-focus:ring-fg border-none peer-checked:(bg-fg text-bg border-fg hover:(text-text-bg/50)) peer-disabled:(opacity-50 pointer-events-none)" 40 + :htmlFor="internalId" 41 + > 42 + <slot /> 43 + </label> 44 + </div> 45 + </template>
+68 -8
test/nuxt/a11y.spec.ts
··· 120 120 SettingsBgThemePicker, 121 121 SettingsToggle, 122 122 TagStatic, 123 - TagClickable, 123 + TagButton, 124 + TagLink, 125 + TagRadioButton, 124 126 TerminalExecute, 125 127 TerminalInstall, 126 128 TooltipAnnounce, ··· 251 253 }) 252 254 }) 253 255 254 - describe('TagClickable', () => { 256 + describe('TagButton', () => { 255 257 it('should have no accessibility violations', async () => { 256 - const component = await mountSuspended(TagClickable, { 258 + const component = await mountSuspended(TagButton, { 257 259 slots: { default: 'Tag content' }, 258 260 }) 259 261 const results = await runAxe(component) 260 262 expect(results.violations).toEqual([]) 261 263 }) 262 264 263 - it('should have no accessibility violationst for active state', async () => { 264 - const component = await mountSuspended(TagClickable, { 265 - props: { status: 'active' }, 265 + it('should have no accessibility violations when pressed', async () => { 266 + const component = await mountSuspended(TagButton, { 267 + props: { pressed: true }, 266 268 slots: { default: 'Tag content' }, 267 269 }) 268 270 const results = await runAxe(component) 269 271 expect(results.violations).toEqual([]) 270 272 }) 271 273 272 - it('should have no accessibility violationst for disabled state', async () => { 273 - const component = await mountSuspended(TagClickable, { 274 + it('should have no accessibility violations for disabled state', async () => { 275 + const component = await mountSuspended(TagButton, { 274 276 props: { disabled: true }, 277 + slots: { default: 'Tag content' }, 278 + }) 279 + const results = await runAxe(component) 280 + expect(results.violations).toEqual([]) 281 + }) 282 + }) 283 + 284 + describe('TagLink', () => { 285 + it('should have no accessibility violations', async () => { 286 + const component = await mountSuspended(TagLink, { 287 + props: { href: 'http://example.com' }, 288 + slots: { default: 'Tag content' }, 289 + }) 290 + const results = await runAxe(component) 291 + expect(results.violations).toEqual([]) 292 + }) 293 + 294 + it("should have no accessibility violations when it's the current link", async () => { 295 + const component = await mountSuspended(TagLink, { 296 + props: { href: 'http://example.com', current: true }, 297 + slots: { default: 'Tag content' }, 298 + }) 299 + const results = await runAxe(component) 300 + expect(results.violations).toEqual([]) 301 + }) 302 + 303 + it('should have no accessibility violations when disabled (plain text)', async () => { 304 + const component = await mountSuspended(TagLink, { 305 + props: { href: 'http://example.com', disabled: true }, 306 + slots: { default: 'Tag content' }, 307 + }) 308 + const results = await runAxe(component) 309 + expect(results.violations).toEqual([]) 310 + }) 311 + }) 312 + 313 + describe('TagRadioButton', () => { 314 + it('should have no accessibility violations', async () => { 315 + const component = await mountSuspended(TagRadioButton, { 316 + props: { value: 'option1', modelValue: 'option2' }, 317 + slots: { default: 'Tag content' }, 318 + }) 319 + const results = await runAxe(component) 320 + expect(results.violations).toEqual([]) 321 + }) 322 + 323 + it('should have no accessibility violations when checked', async () => { 324 + const component = await mountSuspended(TagRadioButton, { 325 + props: { value: 'option1', modelValue: 'option1' }, 326 + slots: { default: 'Tag content' }, 327 + }) 328 + const results = await runAxe(component) 329 + expect(results.violations).toEqual([]) 330 + }) 331 + 332 + it('should have no accessibility violations when disabled', async () => { 333 + const component = await mountSuspended(TagRadioButton, { 334 + props: { value: 'option1', modelValue: 'option2', disabled: true }, 275 335 slots: { default: 'Tag content' }, 276 336 }) 277 337 const results = await runAxe(component)