Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/components/Filter/Chips.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const emit = defineEmits<{
<template>
<div v-if="chips.length > 0" class="flex flex-wrap items-center gap-2">
<TransitionGroup name="chip">
<span v-for="chip in chips" :key="chip.id" class="tag gap-1">
<TagStatic v-for="chip in chips" :key="chip.id" class="gap-1">
<span class="text-fg-subtle text-xs">{{ chip.label }}:</span>
<span class="max-w-32 truncate">{{
Array.isArray(chip.value) ? chip.value.join(', ') : chip.value
Expand All @@ -27,7 +27,7 @@ const emit = defineEmits<{
>
<span class="i-carbon-close w-3 h-3" aria-hidden="true" />
</button>
</span>
</TagStatic>
</TransitionGroup>

<button
Expand Down
40 changes: 12 additions & 28 deletions app/components/Filter/Panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -243,22 +243,17 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
role="radiogroup"
:aria-label="$t('filters.weekly_downloads')"
>
<button
<TagClickable
v-for="range in DOWNLOAD_RANGES"
:key="range.value"
type="button"
role="radio"
:aria-checked="filters.downloadRange === range.value"
class="tag transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.downloadRange === range.value
? 'bg-fg text-bg border-fg hover:text-bg/50'
: ''
"
:status="filters.downloadRange === range.value ? 'active' : 'default'"
@click="emit('update:downloadRange', range.value)"
>
{{ $t(getDownloadRangeLabelKey(range.value)) }}
</button>
</TagClickable>
</div>
</fieldset>

Expand All @@ -272,22 +267,17 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
role="radiogroup"
:aria-label="$t('filters.updated_within')"
>
<button
<TagClickable
v-for="option in UPDATED_WITHIN_OPTIONS"
:key="option.value"
type="button"
role="radio"
:aria-checked="filters.updatedWithin === option.value"
class="tag transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.updatedWithin === option.value
? 'bg-fg text-bg border-fg hover:text-bg/70'
: ''
"
:status="filters.updatedWithin === option.value ? 'active' : 'default'"
@click="emit('update:updatedWithin', option.value)"
>
{{ $t(getUpdatedWithinLabelKey(option.value)) }}
</button>
</TagClickable>
</div>
</fieldset>

Expand All @@ -300,20 +290,17 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
</span>
</legend>
<div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')">
<button
<TagClickable
v-for="security in SECURITY_FILTER_VALUES"
:key="security"
type="button"
role="radio"
disabled
:aria-checked="filters.security === security"
class="tag transition-colors duration-200 opacity-50 cursor-not-allowed focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.security === security ? 'bg-fg text-bg border-fg hover:text-bg/70' : ''
"
:status="filters.security === security ? 'active' : 'default'"
>
{{ $t(getSecurityLabelKey(security)) }}
</button>
</TagClickable>
</div>
</fieldset>

Expand All @@ -323,19 +310,16 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.keywords') }}
</legend>
<div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')">
<button
<TagClickable
v-for="keyword in displayedKeywords"
:key="keyword"
type="button"
:aria-pressed="filters.keywords.includes(keyword)"
class="tag text-xs transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.keywords.includes(keyword) ? 'bg-fg text-bg border-fg hover:text-bg/70' : ''
"
:status="filters.keywords.includes(keyword) ? 'active' : 'default'"
@click="emit('toggleKeyword', keyword)"
>
{{ keyword }}
</button>
</TagClickable>
<button
v-if="hasMoreKeywords"
type="button"
Expand Down
10 changes: 5 additions & 5 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -162,20 +162,20 @@ const pkgDescription = useMarkdown(() => ({
:aria-label="$t('package.card.keywords')"
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"
>
<button
<TagClickable
v-for="keyword in result.package.keywords.slice(0, 5)"
:key="keyword"
type="button"
class="tag text-xs hover:bg-fg hover:text-bg hover:border-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1 border-solid pointer-events-auto"
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
class="pointer-events-auto"
:status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'"
:title="`Filter by ${keyword}`"
@click.stop="emit('clickKeyword', keyword)"
>
{{ keyword }}
</button>
</TagClickable>
<span
v-if="result.package.keywords.length > 5"
class="tag text-fg-subtle text-xs border-none bg-transparent pointer-events-auto"
class="text-fg-subtle text-xs pointer-events-auto"
:title="result.package.keywords.slice(5).join(', ')"
>
+{{ result.package.keywords.length - 5 }}
Expand Down
9 changes: 4 additions & 5 deletions app/components/Package/TableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,19 @@ const allMaintainersText = computed(() => {
class="flex flex-wrap gap-1"
:aria-label="$t('package.card.keywords')"
>
<button
<TagClickable
v-for="keyword in pkg.keywords.slice(0, 3)"
:key="keyword"
type="button"
class="tag text-xs hover:bg-fg hover:text-bg hover:border-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1 border-solid"
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
:status="props.filters?.keywords.includes(keyword) ? 'active' : 'default'"
:title="`Filter by ${keyword}`"
@click.stop="emit('clickKeyword', keyword)"
>
{{ keyword }}
</button>
</TagClickable>
<span
v-if="pkg.keywords.length > 3"
class="tag text-fg-subtle text-xs border-none bg-transparent"
class="text-fg-subtle text-xs"
:title="pkg.keywords.slice(3).join(', ')"
>
+{{ pkg.keywords.length - 3 }}
Expand Down
25 changes: 25 additions & 0 deletions app/components/Tag/Clickable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{ as?: string | Component; status?: 'default' | 'active'; disabled?: boolean }>(),
{
status: 'default',
as: 'button',
},
)
</script>

<template>
<component
:is="props.as"
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"
:class="{
'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)':
status === 'default',
'bg-fg text-bg border-fg hover:(text-text-bg/50)': status === 'active',
'opacity-50 cursor-not-allowed': disabled,
}"
:disabled="disabled"
Copy link
Copy Markdown
Member

@knowler knowler Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@essenmitsosse In HTML the disabled attribute is a boolean attribute and it applies when it is present. I’m seeing it appear in the keyword lists as <a href=wherever disabled=false>. It’s not a huge issue in this case since it has no effect on <a> elements (i.e. disabled isn’t mapped to anything ARIA-related for anchor elements, see: https://www.w3.org/TR/html-aam-1.0/#att-disabled), but if the element did support the mapping then it would always be disabled. Also, worth noting that for the role=radio case, we’d need to manually map aria-disabled ourselves (as an ARIA boolean attribute which does use the "true" and "false" values).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comments. Will take then into account for the next PR. But also feel confident that it is good not to rebuild UI Elements from scratch every time.

Copy link
Copy Markdown
Member

@knowler knowler Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But also feel confident that it is good not to rebuild UI Elements from scratch every time.

I’m also totally for not needing to rebuild things. What I find though is that it’s really easy to identify and create presentational abstractions (i.e. in this case the class names that produce a tag visually), but how presentational abstractions get applied to elements with various semantics is much harder.

I think we’ll likely need some kind of protocol for what components can be used with the as prop. For example, if we had a primitive <Link> component which could build on <NuxtLink>, but knows that if the disabled prop is passed to it to use aria-disabled, then that could solve the issue without turning stuff like TagClickable into a monster component that needs to know how to handle every case. It can just focus on how to apply presentation to the more primitive UI components that we’ve defined.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's more or less what I wanted to sketch out. I think having specific components for their style + semantic combination is the most reasonable way to go.

>
<slot />
</component>
</template>
12 changes: 12 additions & 0 deletions app/components/Tag/Static.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ as?: string | Component }>(), { as: 'span' })
</script>

<template>
<component
:is="as"
class="inline-flex items-center px-2 py-0.5 text-xs font-mono text-fg-muted bg-bg-muted border border-border rounded"
>
<slot />
</component>
</template>
8 changes: 6 additions & 2 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { joinURL } from 'ufo'
import { areUrlsEquivalent } from '#shared/utils/url'
import { isEditableElement } from '~/utils/input'
import { formatBytes } from '~/utils/formatters'
import { NuxtLink } from '#components'

definePageMeta({
name: 'package',
Expand Down Expand Up @@ -1005,9 +1006,12 @@ defineOgImageComponent('Package', {
</h2>
<ul class="flex flex-wrap gap-1.5 list-none m-0 p-0">
<li v-for="keyword in displayVersion.keywords.slice(0, 15)" :key="keyword">
<NuxtLink :to="{ name: 'search', query: { q: `keywords:${keyword}` } }" class="tag">
<TagClickable
:as="NuxtLink"
:to="{ name: 'search', query: { q: `keywords:${keyword}` } }"
>
{{ keyword }}
</NuxtLink>
</TagClickable>
</li>
</ul>
</section>
Expand Down
40 changes: 40 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ import {
SettingsAccentColorPicker,
SettingsBgThemePicker,
SettingsToggle,
TagStatic,
TagClickable,
TerminalExecute,
TerminalInstall,
TooltipAnnounce,
Expand Down Expand Up @@ -232,6 +234,44 @@ describe('component accessibility audits', () => {
})
})

describe('TagStatic', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(TagStatic, {
slots: { default: 'Tag content' },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})

describe('TagClickable', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(TagClickable, {
slots: { default: 'Tag content' },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})

it('should have no accessibility violationst for active state', async () => {
const component = await mountSuspended(TagClickable, {
props: { status: 'active' },
slots: { default: 'Tag content' },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})

it('should have no accessibility violationst for disabled state', async () => {
const component = await mountSuspended(TagClickable, {
props: { disabled: true },
slots: { default: 'Tag content' },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})

describe('TooltipApp', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(TooltipApp, {
Expand Down
6 changes: 1 addition & 5 deletions uno.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,7 @@ export default defineConfig({
],
['link-subtle', 'text-fg-muted hover:text-fg transition-colors duration-200 focus-ring'],

// Tags/badges
[
'tag',
'inline-flex items-center px-2 py-0.5 text-xs font-mono text-fg-muted bg-bg-muted border border-border rounded transition-colors duration-200 hover:(text-fg border-border-hover)',
],
// badges
['badge-orange', 'bg-badge-orange/10 text-badge-orange'],
['badge-yellow', 'bg-badge-yellow/10 text-badge-yellow'],
['badge-green', 'bg-badge-green/10 text-badge-green'],
Expand Down
Loading