Skip to content

Commit 315eda2

Browse files
feat(ui): add reusable Tab component and wire URL persistence
- Add Radix-style Tab compound components (TabRoot, TabList, TabItem, TabPanel) with data-attribute-driven styling and arrow-key roving focus - Replace raw button tab markup in TrendsChart and compare page - Wire usePermalink for chart layout and comparison view tab state - Add empty state for charts panel when no chartable facets Co-Authored-By: Alec Lloyd Probert <graphieros@users.noreply.github.com>
1 parent 102a96d commit 315eda2

File tree

8 files changed

+345
-128
lines changed

8 files changed

+345
-128
lines changed

app/components/Package/TrendsChart.vue

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,7 +1624,15 @@ 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+
permanent: props.permalink,
1629+
})
1630+
const isSparklineLayout = computed({
1631+
get: () => chartLayout.value === 'split',
1632+
set: (v: boolean) => {
1633+
chartLayout.value = v ? 'split' : 'combined'
1634+
},
1635+
})
16281636
</script>
16291637

16301638
<template>
@@ -1633,50 +1641,25 @@ const isSparklineLayout = shallowRef(false)
16331641
id="trends-chart"
16341642
:aria-busy="activeMetricState.pending ? 'true' : 'false'"
16351643
>
1636-
<div
1644+
<TabRoot
16371645
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')"
1646+
v-model="chartLayout"
1647+
id-prefix="chart-layout"
1648+
class="mt-4 mb-8"
16411649
>
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>
1650+
<TabList :aria-label="$t('package.trends.chart_view_toggle')">
1651+
<TabItem value="combined" tab-id="combined-chart-layout-tab" icon="i-lucide:chart-line">
1652+
{{ $t('package.trends.chart_view_combined') }}
1653+
</TabItem>
1654+
<TabItem
1655+
value="split"
1656+
tab-id="split-chart-layout-tab"
1657+
icon="i-lucide:square-split-horizontal"
1658+
>
1659+
{{ $t('package.trends.chart_view_split') }}
1660+
</TabItem>
1661+
</TabList>
1662+
</TabRoot>
16801663

16811664
<div class="w-full mb-4 flex flex-col gap-3">
16821665
<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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<script setup lang="ts">
2+
import type { IconClass } from '~/types'
3+
4+
/**
5+
* Tab — a single tab button. Must be used inside TabList, within a TabRoot.
6+
*
7+
* Styling is driven by `data-selected` attribute — no conditional class logic.
8+
* Extra classes and attrs are passed through to ButtonBase.
9+
*/
10+
11+
defineOptions({ name: 'TabItem', inheritAttrs: false })
12+
13+
const props = withDefaults(
14+
defineProps<{
15+
/** Unique value identifying this tab. Must match a TabPanel's value. */
16+
value: string
17+
/** Optional icon class displayed before the label. */
18+
icon?: IconClass
19+
/** Optional explicit element id (default: auto-generated from TabRoot idPrefix). */
20+
tabId?: string
21+
/** @default "secondary" */
22+
variant?: 'primary' | 'secondary'
23+
/** @default "md" */
24+
size?: 'sm' | 'md'
25+
}>(),
26+
{
27+
variant: 'secondary',
28+
size: 'md',
29+
},
30+
)
31+
32+
const attrs = useAttrs()
33+
34+
const selected = inject<WritableComputedRef<string>>('tabs-selected')!
35+
const getTabId = inject<(value: string) => string>('tabs-tab-id')!
36+
const getPanelId = inject<(value: string) => string>('tabs-panel-id')!
37+
38+
const isSelected = computed(() => selected.value === props.value)
39+
const resolvedTabId = computed(() => props.tabId ?? getTabId(props.value))
40+
const resolvedPanelId = computed(() => getPanelId(props.value))
41+
42+
function select() {
43+
selected.value = props.value
44+
}
45+
</script>
46+
47+
<template>
48+
<ButtonBase
49+
:id="resolvedTabId"
50+
role="tab"
51+
:aria-selected="isSelected ? 'true' : 'false'"
52+
:aria-controls="resolvedPanelId"
53+
:tabindex="isSelected ? -1 : 0"
54+
:data-selected="isSelected ? '' : undefined"
55+
:variant
56+
:size
57+
class="tab-item"
58+
v-bind="attrs"
59+
@click="select"
60+
>
61+
<span v-if="icon" :class="icon" class="size-[1em]" aria-hidden="true" />
62+
<slot />
63+
</ButtonBase>
64+
</template>
65+
66+
<style scoped>
67+
.tab-item {
68+
border-radius: var(--radius, 0.375rem);
69+
border-style: solid;
70+
border-color: transparent;
71+
color: var(--fg-subtle);
72+
transition:
73+
color 150ms,
74+
background-color 150ms,
75+
border-color 150ms;
76+
}
77+
78+
.tab-item:hover {
79+
color: var(--fg);
80+
}
81+
82+
.tab-item[data-selected] {
83+
background-color: var(--bg);
84+
border-color: var(--border);
85+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
86+
color: var(--fg);
87+
}
88+
</style>

app/components/Tab/List.vue

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

app/components/Tab/Panel.vue

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
/**
3+
* TabPanel — content panel associated with a Tab.
4+
*
5+
* Automatically shows/hides based on the selected tab value.
6+
* Uses `data-selected` for CSS-driven visibility when needed.
7+
*/
8+
9+
defineOptions({ name: 'TabPanel' })
10+
11+
const props = defineProps<{
12+
/** Must match the corresponding Tab's value. */
13+
value: string
14+
/** Optional explicit element id (default: auto-generated from Tabs idPrefix). */
15+
panelId?: string
16+
}>()
17+
18+
const selected = inject<WritableComputedRef<string>>('tabs-selected')!
19+
const getTabId = inject<(value: string) => string>('tabs-tab-id')!
20+
const getPanelId = inject<(value: string) => string>('tabs-panel-id')!
21+
22+
const isSelected = computed(() => selected.value === props.value)
23+
const resolvedPanelId = computed(() => props.panelId ?? getPanelId(props.value))
24+
const resolvedTabId = computed(() => getTabId(props.value))
25+
</script>
26+
27+
<template>
28+
<div
29+
v-show="isSelected"
30+
:id="resolvedPanelId"
31+
role="tabpanel"
32+
:aria-labelledby="resolvedTabId"
33+
:data-selected="isSelected ? '' : undefined"
34+
:tabindex="0"
35+
>
36+
<slot />
37+
</div>
38+
</template>

app/components/Tab/Root.vue

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<script setup lang="ts">
2+
/**
3+
* Root container for the accessible Tabs compound component.
4+
*
5+
* Provides shared state to TabList, Tab, and TabPanel children
6+
* via provide/inject. Minimal JS — visual states are driven by
7+
* `data-selected` attributes on Tab and TabPanel elements.
8+
*
9+
* @example
10+
* <Tabs v-model="activeTab" default-value="overview">
11+
* <TabList aria-label="Section navigation">
12+
* <Tab value="overview">Overview</Tab>
13+
* <Tab value="details">Details</Tab>
14+
* </TabList>
15+
* <TabPanel value="overview">Overview content</TabPanel>
16+
* <TabPanel value="details">Details content</TabPanel>
17+
* </Tabs>
18+
*/
19+
20+
defineOptions({ name: 'TabRoot' })
21+
22+
const props = withDefaults(
23+
defineProps<{
24+
/** Currently active tab value. */
25+
modelValue?: string
26+
/** Fallback value when modelValue is not provided. */
27+
defaultValue?: string
28+
/** Optional id prefix for generated tab/panel ids. */
29+
idPrefix?: string
30+
}>(),
31+
{
32+
idPrefix: 'tab',
33+
},
34+
)
35+
36+
const emit = defineEmits<{
37+
'update:modelValue': [value: string]
38+
}>()
39+
40+
const internalValue = shallowRef(props.defaultValue ?? '')
41+
42+
const selectedValue = computed({
43+
get: () => props.modelValue ?? internalValue.value,
44+
set: (v: string) => {
45+
internalValue.value = v
46+
emit('update:modelValue', v)
47+
},
48+
})
49+
50+
function tabId(value: string): string {
51+
return `${props.idPrefix}-${value}`
52+
}
53+
54+
function panelId(value: string): string {
55+
return `${props.idPrefix}-panel-${value}`
56+
}
57+
58+
provide('tabs-selected', selectedValue)
59+
provide('tabs-tab-id', tabId)
60+
provide('tabs-panel-id', panelId)
61+
</script>
62+
63+
<template>
64+
<div class="tab-root">
65+
<slot />
66+
</div>
67+
</template>

0 commit comments

Comments
 (0)