Skip to content

Commit cdec42f

Browse files
authored
feat: add Tab component + tests (#2282)
2 parents d321354 + 8187c56 commit cdec42f

File tree

10 files changed

+505
-128
lines changed

10 files changed

+505
-128
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,7 +1624,13 @@ watch(selectedMetric, value => {
16241624
})
16251625
16261626
// Sparkline charts (a11y alternative display for multi series)
1627-
const isSparklineLayout = shallowRef(false)
1627+
const chartLayout = usePermalink<'combined' | 'split'>('layout', 'combined')
1628+
const isSparklineLayout = computed({
1629+
get: () => chartLayout.value === 'split',
1630+
set: (v: boolean) => {
1631+
chartLayout.value = v ? 'split' : 'combined'
1632+
},
1633+
})
16281634
</script>
16291635

16301636
<template>
@@ -1633,50 +1639,25 @@ const isSparklineLayout = shallowRef(false)
16331639
id="trends-chart"
16341640
:aria-busy="activeMetricState.pending ? 'true' : 'false'"
16351641
>
1636-
<div
1642+
<TabRoot
16371643
v-if="isMultiPackageMode"
1638-
class="inline-flex items-center gap-1 rounded-md border border-border-subtle bg-bg-subtle p-0.5 mt-4 mb-8"
1639-
role="tablist"
1640-
:aria-label="$t('package.trends.chart_view_toggle')"
1644+
v-model="chartLayout"
1645+
id-prefix="chart-layout"
1646+
class="mt-4 mb-8"
16411647
>
1642-
<button
1643-
id="combined-chart-layout-tab"
1644-
type="button"
1645-
role="tab"
1646-
:aria-selected="isSparklineLayout ? 'false' : 'true'"
1647-
aria-controls="combined-chart-layout-panel"
1648-
:tabindex="isSparklineLayout ? 0 : -1"
1649-
class="flex items-center justify-center gap-x-2 rounded px-3 py-2 font-mono text-sm border border-solid transition-colors duration-150 focus-visible:outline-accent/70"
1650-
:class="
1651-
isSparklineLayout
1652-
? 'border-transparent text-fg-subtle hover:text-fg'
1653-
: 'bg-bg border-border shadow-sm text-fg'
1654-
"
1655-
@click="isSparklineLayout = false"
1656-
>
1657-
<span class="i-lucide:chart-line size-[1em]" aria-hidden="true" />
1658-
<span>{{ $t('package.trends.chart_view_combined') }}</span>
1659-
</button>
1660-
1661-
<button
1662-
id="split-chart-layout-tab"
1663-
type="button"
1664-
role="tab"
1665-
:aria-selected="isSparklineLayout ? 'true' : 'false'"
1666-
aria-controls="split-chart-layout-panel"
1667-
:tabindex="!isSparklineLayout ? 0 : -1"
1668-
class="flex items-center justify-center gap-x-2 rounded px-3 py-2 font-mono text-sm border border-solid transition-colors duration-150 focus-visible:outline-accent/70"
1669-
:class="
1670-
isSparklineLayout
1671-
? 'bg-bg border-border shadow-sm text-fg'
1672-
: 'border-transparent text-fg-subtle hover:text-fg'
1673-
"
1674-
@click="isSparklineLayout = true"
1675-
>
1676-
<span class="i-lucide:square-split-horizontal size-[1em]" aria-hidden="true" />
1677-
<span>{{ $t('package.trends.chart_view_split') }}</span>
1678-
</button>
1679-
</div>
1648+
<TabList :aria-label="$t('package.trends.chart_view_toggle')">
1649+
<TabItem value="combined" tab-id="combined-chart-layout-tab" icon="i-lucide:chart-line">
1650+
{{ $t('package.trends.chart_view_combined') }}
1651+
</TabItem>
1652+
<TabItem
1653+
value="split"
1654+
tab-id="split-chart-layout-tab"
1655+
icon="i-lucide:square-split-horizontal"
1656+
>
1657+
{{ $t('package.trends.chart_view_split') }}
1658+
</TabItem>
1659+
</TabList>
1660+
</TabRoot>
16801661

16811662
<div class="w-full mb-4 flex flex-col gap-3">
16821663
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-2 sm:items-end">

app/components/Tab/Item.vue

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<script setup lang="ts">
2+
import type { IconClass } from '~/types'
3+
4+
defineOptions({ name: 'TabItem', inheritAttrs: false })
5+
6+
const props = withDefaults(
7+
defineProps<{
8+
value: string
9+
icon?: IconClass
10+
tabId?: string
11+
variant?: 'primary' | 'secondary'
12+
size?: 'sm' | 'md'
13+
}>(),
14+
{
15+
variant: 'secondary',
16+
size: 'md',
17+
},
18+
)
19+
20+
const attrs = useAttrs()
21+
22+
const selected = inject<WritableComputedRef<string>>('tabs-selected')!
23+
const getTabId = inject<(value: string) => string>('tabs-tab-id')!
24+
const getPanelId = inject<(value: string) => string>('tabs-panel-id')!
25+
26+
const isSelected = computed(() => selected.value === props.value)
27+
const resolvedTabId = computed(() => props.tabId ?? getTabId(props.value))
28+
const resolvedPanelId = computed(() => getPanelId(props.value))
29+
30+
function select() {
31+
selected.value = props.value
32+
}
33+
</script>
34+
35+
<template>
36+
<ButtonBase
37+
:id="resolvedTabId"
38+
role="tab"
39+
:aria-selected="isSelected ? 'true' : 'false'"
40+
:aria-controls="resolvedPanelId"
41+
:tabindex="isSelected ? -1 : 0"
42+
:data-selected="isSelected ? '' : undefined"
43+
:variant
44+
:size
45+
class="tab-item"
46+
v-bind="attrs"
47+
@click="select"
48+
>
49+
<span v-if="icon" :class="icon" class="size-[1em]" aria-hidden="true" />
50+
<slot />
51+
</ButtonBase>
52+
</template>
53+
54+
<style scoped>
55+
.tab-item {
56+
border-radius: var(--radius, 0.375rem);
57+
border-style: solid;
58+
border-color: transparent;
59+
color: var(--fg-subtle);
60+
transition:
61+
color 150ms,
62+
background-color 150ms,
63+
border-color 150ms;
64+
}
65+
66+
.tab-item:hover {
67+
color: var(--fg);
68+
}
69+
70+
.tab-item[data-selected] {
71+
background-color: var(--bg);
72+
border-color: var(--border);
73+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
74+
color: var(--fg);
75+
}
76+
</style>

app/components/Tab/List.vue

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup lang="ts">
2+
defineOptions({ name: 'TabList' })
3+
4+
defineProps<{
5+
ariaLabel: string
6+
}>()
7+
8+
const listRef = useTemplateRef<HTMLElement>('list')
9+
10+
function onKeydown(event: KeyboardEvent) {
11+
const el = listRef.value
12+
if (!el) return
13+
14+
const tabs = Array.from(el.querySelectorAll<HTMLElement>('[role="tab"]'))
15+
const current = tabs.indexOf(document.activeElement as HTMLElement)
16+
if (current === -1) return
17+
18+
let next = -1
19+
20+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
21+
next = (current + 1) % tabs.length
22+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
23+
next = (current - 1 + tabs.length) % tabs.length
24+
} else if (event.key === 'Home') {
25+
next = 0
26+
} else if (event.key === 'End') {
27+
next = tabs.length - 1
28+
}
29+
30+
if (next !== -1) {
31+
event.preventDefault()
32+
tabs[next]?.focus()
33+
tabs[next]?.click()
34+
}
35+
}
36+
</script>
37+
38+
<template>
39+
<div
40+
ref="list"
41+
role="tablist"
42+
:aria-label
43+
class="inline-flex items-center gap-1 rounded-md border border-border-subtle bg-bg-subtle p-0.5"
44+
@keydown="onKeydown"
45+
>
46+
<slot />
47+
</div>
48+
</template>

app/components/Tab/Panel.vue

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script setup lang="ts">
2+
defineOptions({ name: 'TabPanel' })
3+
4+
const props = defineProps<{
5+
value: string
6+
panelId?: string
7+
}>()
8+
9+
const selected = inject<WritableComputedRef<string>>('tabs-selected')!
10+
const getTabId = inject<(value: string) => string>('tabs-tab-id')!
11+
const getPanelId = inject<(value: string) => string>('tabs-panel-id')!
12+
13+
const isSelected = computed(() => selected.value === props.value)
14+
const resolvedPanelId = computed(() => props.panelId ?? getPanelId(props.value))
15+
const resolvedTabId = computed(() => getTabId(props.value))
16+
</script>
17+
18+
<template>
19+
<div
20+
v-show="isSelected"
21+
:id="resolvedPanelId"
22+
role="tabpanel"
23+
:aria-labelledby="resolvedTabId"
24+
:data-selected="isSelected ? '' : undefined"
25+
:tabindex="0"
26+
>
27+
<slot />
28+
</div>
29+
</template>

app/components/Tab/Root.vue

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
defineOptions({ name: 'TabRoot' })
3+
4+
const props = withDefaults(
5+
defineProps<{
6+
modelValue?: string
7+
defaultValue?: string
8+
idPrefix?: string
9+
}>(),
10+
{
11+
idPrefix: 'tab',
12+
},
13+
)
14+
15+
const emit = defineEmits<{
16+
'update:modelValue': [value: string]
17+
}>()
18+
19+
const internalValue = shallowRef(props.defaultValue ?? '')
20+
21+
const selectedValue = computed({
22+
get: () => props.modelValue ?? internalValue.value,
23+
set: (v: string) => {
24+
internalValue.value = v
25+
emit('update:modelValue', v)
26+
},
27+
})
28+
29+
function tabId(value: string): string {
30+
return `${props.idPrefix}-${value}`
31+
}
32+
33+
function panelId(value: string): string {
34+
return `${props.idPrefix}-panel-${value}`
35+
}
36+
37+
provide('tabs-selected', selectedValue)
38+
provide('tabs-tab-id', tabId)
39+
provide('tabs-panel-id', panelId)
40+
</script>
41+
42+
<template>
43+
<div class="tab-root">
44+
<slot />
45+
</div>
46+
</template>

0 commit comments

Comments
 (0)