Skip to content

Commit 2a51961

Browse files
feat: Tabs
1 parent 24aaed6 commit 2a51961

File tree

13 files changed

+821
-1
lines changed

13 files changed

+821
-1
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Ref } from 'vue'
2+
import { createContext } from '~/hooks/createContext.ts'
3+
import type { PrimitiveProps } from '~/primitive/index.ts'
4+
import type { RovingFocusGroupProps } from '~/roving-focus'
5+
6+
export interface TabsProps extends PrimitiveProps {
7+
/** The value for the selected tab, if controlled */
8+
value?: string
9+
/** The value of the tab to select by default, if uncontrolled */
10+
defaultValue?: string
11+
/**
12+
* The orientation the tabs are layed out.
13+
* Mainly so arrow navigation is done accordingly (left & right vs. up & down)
14+
* @defaultValue horizontal
15+
*/
16+
orientation?: RovingFocusGroupProps['orientation']
17+
/**
18+
* The direction of navigation between toolbar items.
19+
*/
20+
dir?: RovingFocusGroupProps['dir']
21+
/**
22+
* Whether a tab is activated automatically or manually.
23+
* @defaultValue automatic
24+
*/
25+
activationMode?: 'automatic' | 'manual'
26+
}
27+
28+
// eslint-disable-next-line ts/consistent-type-definitions
29+
export type TabsEmits = {
30+
/** A function called when a new tab is selected */
31+
'update:value': [value: string]
32+
}
33+
34+
export interface TabsContext {
35+
baseId: string
36+
value: Ref<string>
37+
onValueChange: (value: string) => void
38+
orientation: TabsProps['orientation']
39+
dir: Ref<TabsProps['dir']>
40+
activationMode: TabsProps['activationMode']
41+
}
42+
43+
export const [provideTabsContext, useTabsContext] = createContext<TabsContext>('Tabs')
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import { type TabsEmits, type TabsProps, provideTabsContext } from './Tabs.ts'
3+
import { useControllableState } from '~/hooks/useControllableState.ts'
4+
import { useDirection } from '~/direction/Direction.ts'
5+
import { useId } from '~/hooks/useId.ts'
6+
import { Primitive } from '~/primitive/index.ts'
7+
8+
defineOptions({
9+
name: 'Tabs',
10+
})
11+
12+
const props = withDefaults(defineProps<TabsProps>(), {
13+
orientation: 'horizontal',
14+
activationMode: 'automatic',
15+
})
16+
17+
const emit = defineEmits<TabsEmits>()
18+
19+
const direction = useDirection(() => props.dir)
20+
21+
const value = useControllableState(props, emit, 'value', props.defaultValue)
22+
23+
provideTabsContext({
24+
baseId: useId(),
25+
value,
26+
onValueChange(newValue) {
27+
value.value = newValue
28+
},
29+
orientation: props.orientation,
30+
dir: direction,
31+
activationMode: props.activationMode,
32+
})
33+
</script>
34+
35+
<template>
36+
<Primitive
37+
:as="as"
38+
:as-child="asChild"
39+
:dir="direction"
40+
:data-orientation="orientation"
41+
>
42+
<slot />
43+
</Primitive>
44+
</template>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { PrimitiveProps } from '~/primitive/index.ts'
2+
3+
export interface TabsContentProps extends PrimitiveProps {
4+
value: string
5+
6+
/**
7+
* Used to force mounting when more control is needed. Useful when
8+
* controlling animation with React animation libraries.
9+
*/
10+
forceMount?: true
11+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue'
3+
import type { TabsContentProps } from './TabsContent.ts'
4+
import { useTabsContext } from './Tabs.ts'
5+
import { makeContentId, makeTriggerId } from './utils.ts'
6+
import { Primitive } from '~/primitive/index.ts'
7+
import { usePresence } from '~/presence/usePresence.ts'
8+
9+
defineOptions({
10+
name: 'TabsContent',
11+
})
12+
13+
const props = defineProps<TabsContentProps>()
14+
15+
const elRef = shallowRef<HTMLElement>()
16+
17+
const context = useTabsContext()
18+
const triggerId = computed(() => makeTriggerId(context.baseId, props.value))
19+
const contentId = computed(() => makeContentId(context.baseId, props.value))
20+
const isSelected = computed(() => context.value.value === props.value)
21+
22+
let isMountAnimationPrevented = isSelected.value
23+
24+
let rAf: number
25+
26+
onMounted(() => {
27+
rAf = requestAnimationFrame(() => {
28+
isMountAnimationPrevented = false
29+
})
30+
})
31+
32+
onBeforeUnmount(() => {
33+
cancelAnimationFrame(rAf)
34+
})
35+
36+
const isPresent = usePresence(elRef, () => props.forceMount || isSelected.value)
37+
</script>
38+
39+
<template>
40+
<Primitive
41+
:id="contentId"
42+
:as="as"
43+
:as-child="asChild"
44+
:data-state="isSelected ? 'active' : 'inactive'"
45+
:data-orientation="context.orientation"
46+
role="tabpanel"
47+
:aria-labelledby="triggerId"
48+
:hidden="!isPresent"
49+
tabindex="0"
50+
:style="{
51+
animationDuration: isMountAnimationPrevented ? '0s !important' : undefined,
52+
}"
53+
>
54+
<slot v-if="isPresent" />
55+
</Primitive>
56+
</template>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { PrimitiveProps } from '~/primitive/index.ts'
2+
import type { RovingFocusGroupProps } from '~/roving-focus/index.ts'
3+
4+
export interface TabsListProps extends PrimitiveProps {
5+
loop?: RovingFocusGroupProps['loop']
6+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import type { TabsListProps } from './TabsList.ts'
4+
import { useTabsContext } from './Tabs.ts'
5+
import { Primitive } from '~/primitive/index.ts'
6+
import { RovingFocusGroup } from '~/roving-focus/index.ts'
7+
8+
defineOptions({
9+
name: 'TabsList',
10+
inheritAttrs: false,
11+
})
12+
13+
withDefaults(defineProps<TabsListProps>(), {
14+
loop: true,
15+
})
16+
const elRef = shallowRef<HTMLElement>()
17+
18+
const context = useTabsContext()
19+
20+
defineExpose({
21+
$el: elRef,
22+
})
23+
</script>
24+
25+
<template>
26+
<RovingFocusGroup
27+
as-child
28+
:orientation="context.orientation"
29+
:dir="context.dir.value"
30+
:loop="loop"
31+
>
32+
<Primitive
33+
:ref="(el: any) => {
34+
const node = (el?.$el ?? el)
35+
elRef = node?.hasAttribute ? node : undefined
36+
}"
37+
:as="as"
38+
:as-child="asChild"
39+
v-bind="$attrs"
40+
role="tablist"
41+
:aria-orientation="context.orientation"
42+
>
43+
<slot />
44+
</Primitive>
45+
</RovingFocusGroup>
46+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { PrimitiveProps } from '~/primitive/index.ts'
2+
3+
export interface TabsTriggerProps extends PrimitiveProps {
4+
value: string
5+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script setup lang="ts">
2+
import { computed, shallowRef, useAttrs } from 'vue'
3+
import { useTabsContext } from './Tabs.ts'
4+
import { makeContentId, makeTriggerId } from './utils.ts'
5+
import type { TabsTriggerProps } from './TabsTrigger.ts'
6+
import { Primitive } from '~/primitive/index.ts'
7+
import { RovingFocusItem } from '~/roving-focus/index.ts'
8+
import { composeEventHandlers } from '~/utils/composeEventHandlers.ts'
9+
10+
defineOptions({
11+
name: 'TabsTrigger',
12+
inheritAttrs: false,
13+
})
14+
15+
const props = withDefaults(defineProps<TabsTriggerProps>(), {
16+
as: 'button',
17+
})
18+
const attrs = useAttrs()
19+
20+
const elRef = shallowRef<HTMLElement>()
21+
22+
const context = useTabsContext()
23+
const triggerId = computed(() => makeTriggerId(context.baseId, props.value))
24+
const contentId = computed(() => makeContentId(context.baseId, props.value))
25+
const isSelected = computed(() => context.value.value === props.value)
26+
27+
const onMousedown = composeEventHandlers<MouseEvent>((event) => {
28+
(attrs.onMousedown as Function | undefined)?.(event)
29+
}, (event) => {
30+
// only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
31+
// but not when the control key is pressed (avoiding MacOS right click)
32+
if (!attrs.disabled && event.button === 0 && event.ctrlKey === false) {
33+
context.onValueChange(props.value)
34+
}
35+
else {
36+
// prevent focus to avoid accidental activation
37+
event.preventDefault()
38+
}
39+
})
40+
41+
const onKeydown = composeEventHandlers<KeyboardEvent>((event) => {
42+
(attrs.onKeydown as Function | undefined)?.(event)
43+
}, (event) => {
44+
if ([' ', 'Enter'].includes(event.key))
45+
context.onValueChange(props.value)
46+
})
47+
48+
const onFocus = composeEventHandlers<FocusEvent>((event) => {
49+
(attrs.onFocus as Function | undefined)?.(event)
50+
}, () => {
51+
// handle "automatic" activation if necessary
52+
// ie. activate tab following focus
53+
const isAutomaticActivation = context.activationMode !== 'manual'
54+
if (!isSelected.value && !attrs.disabled && isAutomaticActivation) {
55+
context.onValueChange(props.value)
56+
}
57+
})
58+
59+
defineExpose({
60+
$el: elRef,
61+
})
62+
</script>
63+
64+
<template>
65+
<RovingFocusItem as-child :focusable="!attrs.disabled" :active="isSelected">
66+
<Primitive
67+
:id="triggerId"
68+
:ref="(el: any) => {
69+
const node = (el?.$el ?? el)
70+
elRef = node?.hasAttribute ? node : undefined
71+
}"
72+
:as="as"
73+
:as-child="asChild"
74+
v-bind="{
75+
...attrs,
76+
onMousedown,
77+
onKeydown,
78+
onFocus,
79+
}"
80+
type="button"
81+
role="tab"
82+
:aria-selected="isSelected"
83+
:aria-controls="contentId"
84+
:data-state="isSelected ? 'active' : 'inactive'"
85+
:data-disabled="attrs.disabled ? '' : undefined"
86+
:disabled="attrs.disabled"
87+
>
88+
<slot />
89+
</Primitive>
90+
</RovingFocusItem>
91+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { default as Tabs } from './Tabs.vue'
2+
export { default as TabsList } from './TabsList.vue'
3+
export { default as TabsTrigger } from './TabsTrigger.vue'
4+
export { default as TabsContent } from './TabsContent.vue'
5+
6+
export type { TabsProps } from './Tabs.ts'
7+
export type { TabsListProps } from './TabsList.ts'
8+
export type { TabsTriggerProps } from './TabsTrigger.ts'
9+
export type { TabsContentProps } from './TabsContent.ts'

0 commit comments

Comments
 (0)