Skip to content

Commit 0275456

Browse files
committed
fix(Tabs): improve type safety and test coverage
- Remove unsafe type assertion in TabsPanel ticket lookup - Change onBeforeUnmount to onUnmounted for consistency with Radio - Add template ref for focus management instead of DOM queries - Add TabsTicket interface with el property for focus tracking - Add edge case tests: empty/single tabs, all-disabled, dynamic lifecycle - Add loop=false boundary behavior tests
1 parent 75d95a5 commit 0275456

File tree

5 files changed

+433
-20
lines changed

5 files changed

+433
-20
lines changed

packages/0/src/components/Tabs/TabsPanel.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,13 @@
6565
6666
const tabs = useTabsRoot(namespace)
6767
68-
// Find the ticket that matches this panel's value (O(1) lookup)
68+
// Find the ticket that matches this panel's value
6969
const ticket = toRef(() => {
70-
// Try value-based lookup first
7170
const ids = tabs.browse(value)
7271
if (ids && ids.length > 0) {
7372
return tabs.get(ids[0]!) ?? null
7473
}
75-
// Fall back to ID-based lookup (for valueIsIndex cases)
76-
return tabs.get(value as string | number) ?? null
74+
return null
7775
})
7876
7977
const isSelected = toRef(() => {

packages/0/src/components/Tabs/TabsRoot.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
// Types
1515
import type { StepContext, StepTicket } from '#v0/composables/createStep'
1616
import type { ID } from '#v0/types'
17-
import type { Ref } from 'vue'
17+
import type { MaybeRef, Ref } from 'vue'
1818
1919
export type TabsOrientation = 'horizontal' | 'vertical'
20+
21+
/** Ticket for tab items with element reference for focus management */
22+
export interface TabsTicket extends StepTicket {
23+
/** Element reference for roving tabindex focus management */
24+
el?: MaybeRef<HTMLElement | null | undefined>
25+
}
2026
export type TabsActivation = 'automatic' | 'manual'
2127
2228
export interface TabsRootProps {
@@ -74,7 +80,7 @@
7480
}
7581
}
7682
77-
export interface TabsContext extends StepContext<StepTicket> {
83+
export interface TabsContext extends StepContext<TabsTicket> {
7884
/** Tab orientation */
7985
orientation: Readonly<Ref<TabsOrientation>>
8086
/** Activation mode */

packages/0/src/components/Tabs/TabsTab.vue

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
import { useTabsRoot } from './TabsRoot.vue'
2525
2626
// Utilities
27-
import { nextTick, onBeforeUnmount, toRef, toValue } from 'vue'
27+
import { nextTick, onUnmounted, toRef, toValue, useTemplateRef } from 'vue'
2828
2929
// Types
30-
import type { AtomProps } from '#v0/components/Atom'
30+
import type { AtomExpose, AtomProps } from '#v0/components/Atom'
3131
import type { MaybeRef } from 'vue'
3232
3333
export interface TabsTabProps<V = unknown> extends AtomProps {
@@ -70,6 +70,8 @@
7070
</script>
7171

7272
<script lang="ts" setup generic="V = unknown">
73+
const rootRef = useTemplateRef<AtomExpose>('root')
74+
7375
defineOptions({ name: 'TabsTab' })
7476
7577
defineSlots<{
@@ -86,23 +88,27 @@
8688
} = defineProps<TabsTabProps<V>>()
8789
8890
const tabs = useTabsRoot(namespace)
89-
const ticket = tabs.register({ id, value, disabled })
91+
92+
// Vue auto-unwraps exposed refs when accessed via template ref,
93+
// but TypeScript doesn't reflect this - cast corrects the type
94+
const el = toRef(() => (rootRef.value?.element as HTMLElement | null | undefined) ?? undefined)
95+
const ticket = tabs.register({ id, value, disabled, el })
9096
9197
const isDisabled = toRef(() => toValue(ticket.disabled) || toValue(tabs.disabled))
9298
9399
const tabId = toRef(() => `${tabs.rootId}-tab-${ticket.id}`)
94100
const panelId = toRef(() => `${tabs.rootId}-panel-${ticket.id}`)
95101
96-
onBeforeUnmount(() => {
102+
onUnmounted(() => {
97103
tabs.unregister(ticket.id)
98104
})
99105
100-
function focusSelectedTab (currentTarget: EventTarget | null) {
106+
function focusSelectedTab () {
101107
nextTick(() => {
102-
const current = currentTarget as HTMLElement | null
103-
const tablist = current?.closest('[role="tablist"]')
104-
const selectedTab = tablist?.querySelector('[role="tab"][aria-selected="true"]') as HTMLElement | null
105-
selectedTab?.focus()
108+
const selected = tabs.selectedItem.value
109+
if (selected) {
110+
toValue(selected.el)?.focus()
111+
}
106112
})
107113
}
108114
@@ -117,7 +123,7 @@
117123
) {
118124
e.preventDefault()
119125
tabs.next()
120-
focusSelectedTab(e.currentTarget)
126+
focusSelectedTab()
121127
return
122128
}
123129
@@ -127,22 +133,22 @@
127133
) {
128134
e.preventDefault()
129135
tabs.prev()
130-
focusSelectedTab(e.currentTarget)
136+
focusSelectedTab()
131137
return
132138
}
133139
134140
// Home/End navigation
135141
if (e.key === 'Home') {
136142
e.preventDefault()
137143
tabs.first()
138-
focusSelectedTab(e.currentTarget)
144+
focusSelectedTab()
139145
return
140146
}
141147
142148
if (e.key === 'End') {
143149
e.preventDefault()
144150
tabs.last()
145-
focusSelectedTab(e.currentTarget)
151+
focusSelectedTab()
146152
return
147153
}
148154
@@ -191,6 +197,7 @@
191197

192198
<template>
193199
<Atom
200+
ref="root"
194201
v-bind="slotProps.attrs"
195202
:as
196203
:renderless

0 commit comments

Comments
 (0)