Skip to content

Commit 42cb187

Browse files
committed
refactor: extract explicit tag components with enforce a11y
1 parent 44c6c4c commit 42cb187

File tree

9 files changed

+235
-71
lines changed

9 files changed

+235
-71
lines changed

app/components/Filter/Panel.vue

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,16 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
243243
role="radiogroup"
244244
:aria-label="$t('filters.weekly_downloads')"
245245
>
246-
<TagClickable
246+
<TagRadioButton
247247
v-for="range in DOWNLOAD_RANGES"
248248
: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"
254253
>
255254
{{ $t(getDownloadRangeLabelKey(range.value)) }}
256-
</TagClickable>
255+
</TagRadioButton>
257256
</div>
258257
</fieldset>
259258

@@ -267,17 +266,16 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
267266
role="radiogroup"
268267
:aria-label="$t('filters.updated_within')"
269268
>
270-
<TagClickable
269+
<TagRadioButton
271270
v-for="option in UPDATED_WITHIN_OPTIONS"
272271
: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)"
278276
>
279277
{{ $t(getUpdatedWithinLabelKey(option.value)) }}
280-
</TagClickable>
278+
</TagRadioButton>
281279
</div>
282280
</fieldset>
283281

@@ -290,17 +288,16 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
290288
</span>
291289
</legend>
292290
<div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')">
293-
<TagClickable
291+
<TagRadioButton
294292
v-for="security in SECURITY_FILTER_VALUES"
295293
:key="security"
296-
type="button"
297-
role="radio"
298294
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"
301298
>
302299
{{ $t(getSecurityLabelKey(security)) }}
303-
</TagClickable>
300+
</TagRadioButton>
304301
</div>
305302
</fieldset>
306303

@@ -310,16 +307,14 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
310307
{{ $t('filters.keywords') }}
311308
</legend>
312309
<div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')">
313-
<TagClickable
310+
<TagButton
314311
v-for="keyword in displayedKeywords"
315312
: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)"
319314
@click="emit('toggleKeyword', keyword)"
320315
>
321316
{{ keyword }}
322-
</TagClickable>
317+
</TagButton>
323318
<button
324319
v-if="hasMoreKeywords"
325320
type="button"

app/components/Package/Card.vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,15 @@ const pkgDescription = useMarkdown(() => ({
162162
:aria-label="$t('package.card.keywords')"
163163
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"
164164
>
165-
<TagClickable
165+
<TagButton
166166
v-for="keyword in result.package.keywords.slice(0, 5)"
167167
:key="keyword"
168-
type="button"
169-
class="pointer-events-auto"
170-
:status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'"
168+
:pressed="props.filters?.keywords.includes(keyword)"
171169
:title="`Filter by ${keyword}`"
172170
@click.stop="emit('clickKeyword', keyword)"
173171
>
174172
{{ keyword }}
175-
</TagClickable>
173+
</TagButton>
176174
<span
177175
v-if="result.package.keywords.length > 5"
178176
class="text-fg-subtle text-xs pointer-events-auto"

app/components/Package/Keywords.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
<script setup lang="ts">
2-
import { NuxtLink } from '#components'
3-
42
defineProps<{
53
keywords?: string[]
64
}>()
@@ -9,9 +7,9 @@ defineProps<{
97
<CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords">
108
<ul class="flex flex-wrap gap-1.5 list-none m-0 p-0">
119
<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}` } }">
1311
{{ keyword }}
14-
</TagClickable>
12+
</TagLink>
1513
</li>
1614
</ul>
1715
</CollapsibleSection>

app/components/Package/TableRow.vue

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,15 @@ const allMaintainersText = computed(() => {
123123
class="flex flex-wrap gap-1"
124124
:aria-label="$t('package.card.keywords')"
125125
>
126-
<TagClickable
126+
<TagButton
127127
v-for="keyword in pkg.keywords.slice(0, 3)"
128128
:key="keyword"
129-
type="button"
130-
:status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'"
129+
:pressed="props.filters?.keywords.includes(keyword)"
131130
:title="`Filter by ${keyword}`"
132131
@click.stop="emit('clickKeyword', keyword)"
133132
>
134133
{{ keyword }}
135-
</TagClickable>
134+
</TagButton>
136135
<span
137136
v-if="pkg.keywords.length > 3"
138137
class="text-fg-subtle text-xs"

app/components/Tag/Button.vue

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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>

app/components/Tag/Clickable.vue

Lines changed: 0 additions & 25 deletions
This file was deleted.

app/components/Tag/Link.vue

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
<span v-if="disabled" class="opacity-50"><slot /></span>
24+
<NuxtLink
25+
v-else
26+
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"
27+
:class="{
28+
'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)': !current,
29+
'bg-fg text-bg border-fg hover:(text-text-bg/50)': current,
30+
'opacity-50 cursor-not-allowed': disabled,
31+
}"
32+
v-bind="props"
33+
>
34+
<slot />
35+
</NuxtLink>
36+
</template>

app/components/Tag/RadioButton.vue

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
const model = defineModel()
3+
4+
const props = defineProps<{
5+
disabled?: boolean
6+
/**
7+
* type should never be used, because this will always be a 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+
21+
const checked = computed(() => model.value === props.value)
22+
23+
const onChange = () => {
24+
model.value = props.value
25+
}
26+
27+
const emit = defineEmits<{
28+
(e: 'update:modelValue', value: string): void
29+
}>()
30+
</script>
31+
32+
<template>
33+
<label
34+
class="inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 focus-within:ring-2 focus-within:ring-fg"
35+
:class="[
36+
/** TODO: This should ideally be done in CSS only, but right now I can't get it working with UnoCSS */
37+
checked
38+
? 'peer-checked:(bg-fg text-bg border-fg hover:(text-text-bg/50))'
39+
: 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)',
40+
{
41+
'opacity-50 cursor-not-allowed': props.disabled,
42+
},
43+
]"
44+
:htmlFor="internalId"
45+
>
46+
<input
47+
type="radio"
48+
:name="props.value"
49+
:id="internalId"
50+
:value="props.value"
51+
:checked="checked"
52+
:disabled="props.disabled ? true : undefined"
53+
@change="onChange"
54+
/>
55+
<slot />
56+
</label>
57+
</template>
58+
59+
<style scoped>
60+
input[type='radio'] {
61+
position: absolute;
62+
opacity: 0;
63+
width: 1px;
64+
height: 1px;
65+
margin: -1px;
66+
overflow: hidden;
67+
clip: rect(0 0 0 0);
68+
clip-path: inset(50%);
69+
white-space: nowrap;
70+
border: 0;
71+
}
72+
</style>

0 commit comments

Comments
 (0)