Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/0/src/components/Tabs/TabsList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @module TabsList
*
* @remarks
* Container component for tab triggers. Provides the `tablist` ARIA role
* and orientation attribute for accessibility. Does not manage state -
* purely structural.
*
* @example
* ```ts
* // Basic usage with label for accessibility
* h(Tabs.List, { label: 'Account settings' }, () => [
* h(Tabs.Tab, { value: 'profile' }, () => 'Profile'),
* h(Tabs.Tab, { value: 'password' }, () => 'Password'),
* ])
* ```
*/

<script lang="ts">
// Components
import { Atom } from '#v0/components/Atom'
import { useTabsRoot } from './TabsRoot.vue'

// Utilities
import { toRef, toValue } from 'vue'

// Types
import type { AtomProps } from '#v0/components/Atom'
import type { TabsOrientation } from './TabsRoot.vue'

export interface TabsListProps extends AtomProps {
/** Accessible label for the tablist */
label?: string
/** Namespace for dependency injection */
namespace?: string
}

export interface TabsListSlotProps {
/** Current orientation */
orientation: TabsOrientation
/** Whether the tabs instance is disabled */
isDisabled: boolean
/** Attributes to bind to the tablist element */
attrs: {
'role': 'tablist'
'aria-orientation': TabsOrientation
'aria-label': string | undefined
'aria-disabled': boolean | undefined
}
}
</script>

<script lang="ts" setup>
defineOptions({ name: 'TabsList' })

defineSlots<{
default: (props: TabsListSlotProps) => any
}>()

const {
as = 'div',
renderless,
label,
namespace = 'v0:tabs',
} = defineProps<TabsListProps>()

const tabs = useTabsRoot(namespace)

const isDisabled = toRef(() => toValue(tabs.disabled))

const slotProps = toRef((): TabsListSlotProps => ({
orientation: tabs.orientation.value,
isDisabled: isDisabled.value,
attrs: {
'role': 'tablist',
'aria-orientation': tabs.orientation.value,
'aria-label': label,
'aria-disabled': isDisabled.value || undefined,
},
}))
</script>

<template>
<Atom
v-bind="slotProps.attrs"
:as
:renderless
>
<slot v-bind="slotProps" />
</Atom>
</template>
110 changes: 110 additions & 0 deletions packages/0/src/components/Tabs/TabsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @module TabsPanel
*
* @remarks
* Content panel associated with a tab. Matches with TabsTab via the `value`
* prop. Provides ARIA tabpanel role and labelledby relationship with the
* corresponding tab trigger.
*
* @example
* ```ts
* // Panel content is shown when its tab is selected
* h(Tabs.Panel, { value: 'profile', class: 'p-4' }, () => [
* h('h3', 'Profile Settings'),
* h('p', 'Manage your profile information.'),
* ])
* ```
*/

<script lang="ts">
// Components
import { Atom } from '#v0/components/Atom'
import { useTabsRoot } from './TabsRoot.vue'

// Utilities
import { toRef, toValue } from 'vue'

// Types
import type { AtomProps } from '#v0/components/Atom'

export interface TabsPanelProps<V = unknown> extends AtomProps {
/** Value to match with corresponding TabsTab */
value: V
/** Namespace for dependency injection */
namespace?: string
}

export interface TabsPanelSlotProps {
/** Whether this panel's tab is currently selected */
isSelected: boolean
/** Attributes to bind to the panel element */
attrs: {
'id': string
'role': 'tabpanel'
'aria-labelledby': string
'tabindex': 0 | -1
'hidden': boolean
'data-selected': true | undefined
}
}
</script>

<script lang="ts" setup generic="V = unknown">
defineOptions({ name: 'TabsPanel' })

defineSlots<{
default: (props: TabsPanelSlotProps) => any
}>()

const {
as = 'div',
renderless,
value,
namespace = 'v0:tabs',
} = defineProps<TabsPanelProps<V>>()

const tabs = useTabsRoot(namespace)

// Find the ticket that matches this panel's value (O(1) lookup)
const ticket = toRef(() => {
// Try value-based lookup first
const ids = tabs.browse(value)
if (ids && ids.length > 0) {
return tabs.get(ids[0]!) ?? null
}
// Fall back to ID-based lookup (for valueIsIndex cases)
return tabs.get(value as string | number) ?? null
})

const isSelected = toRef(() => {
const t = ticket.value
return t ? toValue(t.isSelected) : false
})

const ticketId = toRef(() => ticket.value?.id ?? value)

const panelId = toRef(() => `${tabs.rootId}-panel-${ticketId.value}`)
const tabId = toRef(() => `${tabs.rootId}-tab-${ticketId.value}`)

const slotProps = toRef((): TabsPanelSlotProps => ({
isSelected: isSelected.value,
attrs: {
'id': panelId.value,
'role': 'tabpanel',
'aria-labelledby': tabId.value,
'tabindex': isSelected.value ? 0 : -1,
'hidden': !isSelected.value,
'data-selected': isSelected.value || undefined,
},
}))
</script>

<template>
<Atom
v-bind="slotProps.attrs"
:as
:renderless
>
<slot v-bind="slotProps" />
</Atom>
</template>
164 changes: 164 additions & 0 deletions packages/0/src/components/Tabs/TabsRoot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* @module TabsRoot
*
* @remarks
* Root component for tabs navigation. Creates and provides step context
* to child TabsTab and TabsPanel components. Supports horizontal/vertical
* orientation and automatic/manual activation modes.
*/

<script lang="ts">
// Foundational
import { createContext } from '#v0/composables/createContext'

// Types
import type { StepContext, StepTicket } from '#v0/composables/createStep'
import type { ID } from '#v0/types'
import type { Ref } from 'vue'

export type TabsOrientation = 'horizontal' | 'vertical'
export type TabsActivation = 'automatic' | 'manual'

export interface TabsRootProps {
/** Namespace for dependency injection (must match child namespace) */
namespace?: string
/** Disables the entire tabs instance */
disabled?: boolean
/** Auto-select non-disabled items on registration */
enroll?: boolean
/**
* Controls mandatory tab behavior:
* - false: No mandatory tab enforcement
* - true: Prevents deselecting the last selected item
* - `force` (default): Automatically selects the first non-disabled tab
*/
mandatory?: boolean | 'force'
/** Whether arrow key navigation wraps around */
loop?: boolean
/** Tab orientation for keyboard navigation */
orientation?: TabsOrientation
/**
* Activation mode:
* - `automatic`: Tab activates on focus (arrow keys)
* - `manual`: Tab activates on Enter/Space only
*/
activation?: TabsActivation
}

export interface TabsRootSlotProps {
/** Whether the tabs instance is disabled */
isDisabled: boolean
/** Current orientation */
orientation: TabsOrientation
/** Current activation mode */
activation: TabsActivation
/** Select the first tab */
first: () => void
/** Select the last tab */
last: () => void
/** Select the next tab */
next: () => void
/** Select the previous tab */
prev: () => void
/** Step forward or backward by a specific count */
step: (count: number) => void
/** Select a tab by ID */
select: (id: ID) => void
/** Unselect a tab by ID */
unselect: (id: ID) => void
/** Toggle a tab's selection state by ID */
toggle: (id: ID) => void
/** Attributes to bind to the root element */
attrs: {
'aria-multiselectable': false
}
}

export interface TabsContext extends StepContext<StepTicket> {
/** Tab orientation */
orientation: Readonly<Ref<TabsOrientation>>
/** Activation mode */
activation: Readonly<Ref<TabsActivation>>
/** Whether navigation loops */
loop: Readonly<Ref<boolean>>
/** Root ID for generating tab/panel IDs */
rootId: string
}

export const [useTabsRoot, provideTabsRoot] = createContext<TabsContext>()
</script>

<script lang="ts" setup generic="T = unknown">
// Composables
import { createStep } from '#v0/composables/createStep'
import { useProxyModel } from '#v0/composables/useProxyModel'

// Utilities
import { genId } from '#v0/utilities'
import { toRef, toValue } from 'vue'

defineOptions({ name: 'TabsRoot' })

defineSlots<{
default: (props: TabsRootSlotProps) => any
}>()

defineEmits<{
'update:model-value': [value: T | T[]]
}>()

const {
namespace = 'v0:tabs',
disabled = false,
enroll = false,
mandatory = 'force',
loop = true,
orientation = 'horizontal',
activation = 'automatic',
} = defineProps<TabsRootProps>()

const model = defineModel<T | T[]>()

const rootId = genId()

const step = createStep({
disabled: toRef(() => disabled),
enroll,
mandatory,
circular: loop,
events: true,
})

useProxyModel(step, model, { multiple: false })

const context: TabsContext = {
...step,
orientation: toRef(() => orientation),
activation: toRef(() => activation),
loop: toRef(() => loop),
rootId,
}

provideTabsRoot(namespace, context)

const slotProps = toRef((): TabsRootSlotProps => ({
isDisabled: toValue(step.disabled),
orientation,
activation,
first: step.first,
last: step.last,
next: step.next,
prev: step.prev,
step: step.step,
select: step.select,
unselect: step.unselect,
toggle: step.toggle,
attrs: {
'aria-multiselectable': false,
},
}))
</script>

<template>
<slot v-bind="slotProps" />
</template>
Loading
Loading