Skip to content

Commit be5ba46

Browse files
committed
fix: some a11y fixes
1 parent 7ff25eb commit be5ba46

2 files changed

Lines changed: 88 additions & 16 deletions

File tree

app/components/AppTooltip.vue

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const props = defineProps<{
77
}>()
88
99
const isVisible = ref(false)
10+
const tooltipId = useId()
1011
1112
const positionClasses: Record<string, string> = {
1213
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1',
@@ -16,26 +17,36 @@ const positionClasses: Record<string, string> = {
1617
}
1718
1819
const tooltipPosition = computed(() => positionClasses[props.position || 'bottom'])
20+
21+
function show() {
22+
isVisible.value = true
23+
}
24+
25+
function hide() {
26+
isVisible.value = false
27+
}
1928
</script>
2029

2130
<template>
2231
<div
2332
class="relative inline-flex"
24-
@mouseenter="isVisible = true"
25-
@mouseleave="isVisible = false"
26-
@focus="isVisible = true"
27-
@blur="isVisible = false"
33+
:aria-describedby="isVisible ? tooltipId : undefined"
34+
@mouseenter="show"
35+
@mouseleave="hide"
36+
@focusin="show"
37+
@focusout="hide"
2838
>
2939
<slot />
3040

3141
<Transition
32-
enter-active-class="transition-opacity duration-150"
33-
leave-active-class="transition-opacity duration-100"
42+
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
43+
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
3444
enter-from-class="opacity-0"
3545
leave-to-class="opacity-0"
3646
>
3747
<div
3848
v-if="isVisible"
49+
:id="tooltipId"
3950
role="tooltip"
4051
class="absolute px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-[100] pointer-events-none"
4152
:class="tooltipPosition"

app/components/PackagePlaygrounds.vue

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ function getColor(provider: string): string {
4343
// Dropdown state
4444
const isOpen = ref(false)
4545
const dropdownRef = ref<HTMLElement>()
46+
const menuRef = ref<HTMLElement>()
47+
const focusedIndex = ref(-1)
4648
4749
onClickOutside(dropdownRef, () => {
4850
isOpen.value = false
@@ -52,12 +54,64 @@ onClickOutside(dropdownRef, () => {
5254
const hasSingleLink = computed(() => props.links.length === 1)
5355
const hasMultipleLinks = computed(() => props.links.length > 1)
5456
const firstLink = computed(() => props.links[0])
57+
58+
function closeDropdown() {
59+
isOpen.value = false
60+
focusedIndex.value = -1
61+
}
62+
63+
function handleKeydown(event: KeyboardEvent) {
64+
if (!isOpen.value) {
65+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
66+
event.preventDefault()
67+
isOpen.value = true
68+
focusedIndex.value = 0
69+
nextTick(() => focusMenuItem(0))
70+
}
71+
return
72+
}
73+
74+
switch (event.key) {
75+
case 'Escape':
76+
event.preventDefault()
77+
closeDropdown()
78+
break
79+
case 'ArrowDown':
80+
event.preventDefault()
81+
focusedIndex.value = (focusedIndex.value + 1) % props.links.length
82+
focusMenuItem(focusedIndex.value)
83+
break
84+
case 'ArrowUp':
85+
event.preventDefault()
86+
focusedIndex.value = focusedIndex.value <= 0 ? props.links.length - 1 : focusedIndex.value - 1
87+
focusMenuItem(focusedIndex.value)
88+
break
89+
case 'Home':
90+
event.preventDefault()
91+
focusedIndex.value = 0
92+
focusMenuItem(0)
93+
break
94+
case 'End':
95+
event.preventDefault()
96+
focusedIndex.value = props.links.length - 1
97+
focusMenuItem(props.links.length - 1)
98+
break
99+
case 'Tab':
100+
closeDropdown()
101+
break
102+
}
103+
}
104+
105+
function focusMenuItem(index: number) {
106+
const items = menuRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]')
107+
items?.[index]?.focus()
108+
}
55109
</script>
56110

57111
<template>
58112
<section v-if="links.length > 0" aria-labelledby="playgrounds-heading">
59113
<h2 id="playgrounds-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
60-
Playgrounds
114+
Try it out
61115
</h2>
62116

63117
<div ref="dropdownRef" class="relative">
@@ -67,7 +121,7 @@ const firstLink = computed(() => props.links[0])
67121
:href="firstLink.url"
68122
target="_blank"
69123
rel="noopener noreferrer"
70-
class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated transition-colors duration-200"
124+
class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
71125
>
72126
<span
73127
:class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']"
@@ -81,40 +135,47 @@ const firstLink = computed(() => props.links[0])
81135
<button
82136
v-if="hasMultipleLinks"
83137
type="button"
84-
class="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated transition-colors duration-200"
138+
aria-haspopup="true"
139+
:aria-expanded="isOpen"
140+
class="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
85141
@click="isOpen = !isOpen"
142+
@keydown="handleKeydown"
86143
>
87144
<span class="flex items-center gap-2">
88145
<span class="i-carbon-play w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" />
89146
<span class="text-fg-muted">{{ links.length }} Playgrounds</span>
90147
</span>
91148
<span
92-
class="i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200"
149+
class="i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none"
93150
:class="{ 'rotate-180': isOpen }"
94151
aria-hidden="true"
95152
/>
96153
</button>
97154

98155
<!-- Dropdown menu -->
99156
<Transition
100-
enter-active-class="transition duration-150 ease-out"
101-
enter-from-class="opacity-0 scale-95"
157+
enter-active-class="transition duration-150 ease-out motion-reduce:transition-none"
158+
enter-from-class="opacity-0 scale-95 motion-reduce:scale-100"
102159
enter-to-class="opacity-100 scale-100"
103-
leave-active-class="transition duration-100 ease-in"
160+
leave-active-class="transition duration-100 ease-in motion-reduce:transition-none"
104161
leave-from-class="opacity-100 scale-100"
105-
leave-to-class="opacity-0 scale-95"
162+
leave-to-class="opacity-0 scale-95 motion-reduce:scale-100"
106163
>
107164
<div
108165
v-if="isOpen && hasMultipleLinks"
166+
ref="menuRef"
167+
role="menu"
109168
class="absolute top-full left-0 right-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 overflow-visible"
169+
@keydown="handleKeydown"
110170
>
111171
<AppTooltip v-for="link in links" :key="link.url" :text="link.providerName" class="block">
112172
<a
113173
:href="link.url"
114174
target="_blank"
115175
rel="noopener noreferrer"
116-
class="flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted transition-colors duration-150"
117-
@click="isOpen = false"
176+
role="menuitem"
177+
class="flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted focus-visible:outline-none focus-visible:text-fg focus-visible:bg-bg-muted transition-colors duration-150"
178+
@click="closeDropdown"
118179
>
119180
<span
120181
:class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']"

0 commit comments

Comments
 (0)