diff --git a/apps/docs/build/shiki-api-transformer.ts b/apps/docs/build/shiki-api-transformer.ts index 251e5cbd..94c6376d 100644 --- a/apps/docs/build/shiki-api-transformer.ts +++ b/apps/docs/build/shiki-api-transformer.ts @@ -32,6 +32,7 @@ const V0_COMPONENTS = new Set([ 'Selection', 'Single', 'Step', + 'Tabs', ]) // v0 composables with API entries diff --git a/apps/docs/src/examples/components/tabs/basic.vue b/apps/docs/src/examples/components/tabs/basic.vue new file mode 100644 index 00000000..0b01e5bf --- /dev/null +++ b/apps/docs/src/examples/components/tabs/basic.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/docs/src/pages/components/disclosure/tabs.md b/apps/docs/src/pages/components/disclosure/tabs.md new file mode 100644 index 00000000..b6c5d494 --- /dev/null +++ b/apps/docs/src/pages/components/disclosure/tabs.md @@ -0,0 +1,106 @@ +--- +title: Tabs - Accessible Tab Navigation for Vue 3 +meta: +- name: description + content: Accessible tab navigation with automatic and manual activation modes. WAI-ARIA compliant compound component with roving tabindex and keyboard navigation. +- name: keywords + content: tabs, tab panel, navigation, Vue 3, headless, accessibility, WAI-ARIA, roving tabindex +features: + category: Component + label: 'T: Tabs' + github: /components/Tabs/ + renderless: false + level: 2 +related: + - /components/disclosure/expansion-panel + - /components/disclosure/dialog +--- + + + +# Tabs + +A component for creating accessible tabbed interfaces with proper ARIA support and keyboard navigation. + + + +## Usage + +The Tabs component provides a compound pattern for building accessible tab interfaces. It uses the `createStep` composable internally for navigation and provides full v-model support with automatic state synchronization. + + + + + +## Anatomy + +```vue Anatomy playground + + + +``` + + + +## Features + +### Keyboard Navigation + +The component implements full WAI-ARIA keyboard support: + +- **Arrow Left/Right** (horizontal) or **Arrow Up/Down** (vertical): Navigate between tabs +- **Home**: Jump to first tab +- **End**: Jump to last tab +- **Enter/Space**: Activate tab (in manual mode) + +### Activation Modes + +Control when tabs activate with the `activation` prop: + +- **automatic** (default): Tab activates when focused via arrow keys +- **manual**: Tab only activates on Enter/Space key press + +```vue + +``` + +### Orientation + +Support for both horizontal and vertical tab layouts: + +```vue + +``` + +### Circular Navigation + +Control whether navigation wraps around at boundaries: + +```vue + +``` diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index 25ad19f7..db3fe636 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -79,6 +79,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/components/disclosure/tabs': RouteRecordInfo< + '/components/disclosure/tabs', + '/components/disclosure/tabs', + Record, + Record, + | never + >, '/components/forms/checkbox': RouteRecordInfo< '/components/forms/checkbox', '/components/forms/checkbox', @@ -623,6 +630,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/components/disclosure/tabs.md': { + routes: + | '/components/disclosure/tabs' + views: + | never + } 'src/pages/components/forms/checkbox.md': { routes: | '/components/forms/checkbox' diff --git a/packages/0/src/components/Tabs/TabsItem.vue b/packages/0/src/components/Tabs/TabsItem.vue new file mode 100644 index 00000000..2f3499c3 --- /dev/null +++ b/packages/0/src/components/Tabs/TabsItem.vue @@ -0,0 +1,224 @@ +/** + * @module TabsItem + * + * @remarks + * Individual tab trigger that registers with the parent TabsRoot. + * Provides complete ARIA attributes, roving tabindex, and keyboard + * handling for accessibility. Supports both automatic and manual + * activation modes. + * + * @example + * ```ts + * // Using slot props for conditional styling + * h(Tabs.Item, { value: 'profile' }, { + * default: ({ isSelected }) => h('button', { + * class: isSelected ? 'border-b-2 border-blue-500' : '' + * }, 'Profile') + * }) + * ``` + */ + + + + + + diff --git a/packages/0/src/components/Tabs/TabsList.vue b/packages/0/src/components/Tabs/TabsList.vue new file mode 100644 index 00000000..52a850bb --- /dev/null +++ b/packages/0/src/components/Tabs/TabsList.vue @@ -0,0 +1,95 @@ +/** + * @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'), + * ]) + * ``` + */ + + + + + + diff --git a/packages/0/src/components/Tabs/TabsPanel.vue b/packages/0/src/components/Tabs/TabsPanel.vue new file mode 100644 index 00000000..fc8f432b --- /dev/null +++ b/packages/0/src/components/Tabs/TabsPanel.vue @@ -0,0 +1,110 @@ +/** + * @module TabsPanel + * + * @remarks + * Content panel associated with a tab. Matches with TabsItem 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.'), + * ]) + * ``` + */ + + + + + + diff --git a/packages/0/src/components/Tabs/TabsRoot.vue b/packages/0/src/components/Tabs/TabsRoot.vue new file mode 100644 index 00000000..10ef98d1 --- /dev/null +++ b/packages/0/src/components/Tabs/TabsRoot.vue @@ -0,0 +1,169 @@ +/** + * @module TabsRoot + * + * @remarks + * Root component for tabs navigation. Creates and provides step context + * to child TabsItem and TabsPanel components. Supports horizontal/vertical + * orientation and automatic/manual activation modes. + */ + + + + + + diff --git a/packages/0/src/components/Tabs/index.test.ts b/packages/0/src/components/Tabs/index.test.ts new file mode 100644 index 00000000..19441839 --- /dev/null +++ b/packages/0/src/components/Tabs/index.test.ts @@ -0,0 +1,1498 @@ +import { describe, expect, it } from 'vitest' +import { renderToString } from 'vue/server-renderer' + +// Utilities +import { mount } from '@vue/test-utils' +import { createSSRApp, defineComponent, h, nextTick, ref } from 'vue' + +import { Tabs } from './index' + +describe('tabs', () => { + describe('root', () => { + describe('rendering', () => { + it('should be renderless by default', () => { + const wrapper = mount(Tabs.Root, { + slots: { + default: () => h('div', { class: 'wrapper' }, 'Content'), + }, + }) + + expect(wrapper.find('.wrapper').exists()).toBe(true) + }) + + it('should expose slot props', () => { + let slotProps: any + + mount(Tabs.Root, { + slots: { + default: (props: any) => { + slotProps = props + return h('div', 'Content') + }, + }, + }) + + expect(slotProps).toBeDefined() + expect(typeof slotProps.isDisabled).toBe('boolean') + expect(slotProps.orientation).toBe('horizontal') + expect(slotProps.activation).toBe('automatic') + expect(typeof slotProps.first).toBe('function') + expect(typeof slotProps.last).toBe('function') + expect(typeof slotProps.next).toBe('function') + expect(typeof slotProps.prev).toBe('function') + expect(typeof slotProps.step).toBe('function') + expect(typeof slotProps.select).toBe('function') + expect(typeof slotProps.unselect).toBe('function') + expect(typeof slotProps.toggle).toBe('function') + }) + }) + + describe('orientation prop', () => { + it('should default to horizontal', () => { + let listProps: any + + mount(Tabs.Root, { + slots: { + default: () => h(Tabs.List as any, {}, { + default: (props: any) => { + listProps = props + return h('div', 'List') + }, + }), + }, + }) + + expect(listProps.orientation).toBe('horizontal') + expect(listProps.attrs['aria-orientation']).toBe('horizontal') + }) + + it('should support vertical orientation', () => { + let listProps: any + + mount(Tabs.Root, { + props: { + orientation: 'vertical', + }, + slots: { + default: () => h(Tabs.List as any, {}, { + default: (props: any) => { + listProps = props + return h('div', 'List') + }, + }), + }, + }) + + expect(listProps.orientation).toBe('vertical') + expect(listProps.attrs['aria-orientation']).toBe('vertical') + }) + }) + + describe('mandatory prop', () => { + it('should auto-select first tab when mandatory=force (default)', async () => { + let tabProps: any + + mount(Tabs.Root, { + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.isSelected).toBe(true) + }) + + it('should not auto-select when mandatory=false', async () => { + let tabProps: any + + mount(Tabs.Root, { + props: { + mandatory: false, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.isSelected).toBe(false) + }) + }) + }) + + describe('list', () => { + it('should render with tablist role', async () => { + const wrapper = mount(Tabs.Root, { + slots: { + default: () => h(Tabs.List as any, { as: 'div' }, () => 'Tabs'), + }, + }) + + await nextTick() + + expect(wrapper.find('[role="tablist"]').exists()).toBe(true) + }) + + it('should accept aria-label via label prop', async () => { + let listProps: any + + mount(Tabs.Root, { + slots: { + default: () => h(Tabs.List as any, { label: 'Navigation' }, { + default: (props: any) => { + listProps = props + return h('div', 'List') + }, + }), + }, + }) + + await nextTick() + + expect(listProps.attrs['aria-label']).toBe('Navigation') + }) + }) + + describe('tab', () => { + describe('aria attributes', () => { + it('should have correct ARIA attributes', async () => { + let tabProps: any + + mount(Tabs.Root, { + props: { + mandatory: false, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.attrs.role).toBe('tab') + expect(tabProps.attrs['aria-selected']).toBe(false) + expect(tabProps.attrs['aria-controls']).toContain('-panel-') + expect(tabProps.attrs.tabindex).toBe(-1) + }) + + it('should update aria-selected when selected', async () => { + let tabProps: any + + mount(Tabs.Root, { + props: { + modelValue: 'tab-1', + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.attrs['aria-selected']).toBe(true) + expect(tabProps.attrs.tabindex).toBe(0) + }) + }) + + describe('roving tabindex', () => { + it('should have tabindex=0 only on selected tab', async () => { + let tab1Props: any + let tab2Props: any + + mount(Tabs.Root, { + props: { + modelValue: 'tab-1', + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + ], + }, + }) + + await nextTick() + + expect(tab1Props.attrs.tabindex).toBe(0) + expect(tab2Props.attrs.tabindex).toBe(-1) + }) + }) + + describe('selection', () => { + it('should select tab on click', async () => { + const selected = ref() + let tabProps: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.isSelected).toBe(false) + + tabProps.attrs.onClick() + await nextTick() + + expect(selected.value).toBe('tab-1') + expect(tabProps.isSelected).toBe(true) + }) + }) + + describe('disabled state', () => { + it('should not select when disabled', async () => { + const selected = ref() + let tabProps: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1', disabled: true }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + tabProps.attrs.onClick() + await nextTick() + + expect(selected.value).toBeUndefined() + expect(tabProps.isDisabled).toBe(true) + expect(tabProps.attrs['aria-disabled']).toBe(true) + }) + + it('should allow selection after disabled prop changes to false', async () => { + const selected = ref() + const disabled = ref(true) + let tabProps: any + + // Pass the ref directly - component accepts MaybeRef + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1', disabled }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + // Initially disabled + expect(tabProps.isDisabled).toBe(true) + tabProps.attrs.onClick() + await nextTick() + expect(selected.value).toBeUndefined() + + // Enable the tab + disabled.value = false + await nextTick() + + // Now should allow selection + expect(tabProps.isDisabled).toBe(false) + tabProps.attrs.onClick() + await nextTick() + expect(selected.value).toBe('tab-1') + }) + + it('should retain selection when selected tab becomes disabled', async () => { + const selected = ref('tab-1') + const disabled = ref(false) + let tabProps: any + + // Pass the ref directly - component accepts MaybeRef + mount(Tabs.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1', disabled }, { + default: (props: any) => { + tabProps = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + expect(tabProps.isSelected).toBe(true) + expect(tabProps.isDisabled).toBe(false) + + // Disable the selected tab + disabled.value = true + await nextTick() + + // Selection retained, but now disabled + expect(tabProps.isSelected).toBe(true) + expect(tabProps.isDisabled).toBe(true) + }) + + it('should skip newly disabled tab during keyboard navigation', async () => { + const selected = ref('tab-1') + const tab2Disabled = ref(false) + let tab1Props: any + + // Pass the ref directly - component accepts MaybeRef + mount(Tabs.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2', disabled: tab2Disabled }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3' }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + // Navigate right - tab-2 is enabled + const event1 = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event1, 'preventDefault', { value: () => {} }) + tab1Props.attrs.onKeydown(event1) + await nextTick() + expect(selected.value).toBe('tab-2') + + // Reset to tab-1 and disable tab-2 + selected.value = 'tab-1' + tab2Disabled.value = true + await nextTick() + + // Navigate right - should skip disabled tab-2 + const event2 = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event2, 'preventDefault', { value: () => {} }) + tab1Props.attrs.onKeydown(event2) + await nextTick() + expect(selected.value).toBe('tab-3') + }) + }) + }) + + describe('panel', () => { + describe('aria attributes', () => { + it('should have correct ARIA attributes', async () => { + let panelProps: any + + mount(Tabs.Root, { + props: { + modelValue: 'tab-1', + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Panel as any, { value: 'tab-1' }, { + default: (props: any) => { + panelProps = props + return h('div', 'Panel 1') + }, + }), + ], + }, + }) + + await nextTick() + + expect(panelProps.attrs.role).toBe('tabpanel') + expect(panelProps.attrs['aria-labelledby']).toContain('-tab-') + expect(panelProps.attrs.tabindex).toBe(0) + expect(panelProps.attrs.hidden).toBe(false) + }) + + it('should be hidden when not selected', async () => { + let panelProps: any + + mount(Tabs.Root, { + props: { + modelValue: 'tab-2', + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, () => h('button', 'Tab 2')), + h(Tabs.Panel as any, { value: 'tab-1' }, { + default: (props: any) => { + panelProps = props + return h('div', 'Panel 1') + }, + }), + ], + }, + }) + + await nextTick() + + expect(panelProps.isSelected).toBe(false) + expect(panelProps.attrs.hidden).toBe(true) + expect(panelProps.attrs.tabindex).toBe(-1) + }) + }) + + describe('tab-panel relationship', () => { + it('should match aria-controls and aria-labelledby', async () => { + let tabProps: any + let panelProps: any + + mount(Tabs.Root, { + props: { + modelValue: 'profile', + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'profile' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Profile') + }, + }), + h(Tabs.Panel as any, { value: 'profile' }, { + default: (props: any) => { + panelProps = props + return h('div', 'Profile content') + }, + }), + ], + }, + }) + + await nextTick() + + // aria-controls on tab should match panel id + expect(tabProps.attrs['aria-controls']).toBe(panelProps.attrs.id) + // aria-labelledby on panel should match tab id + expect(panelProps.attrs['aria-labelledby']).toBe(tabProps.attrs.id) + }) + + it('should fallback to ID-based lookup when value not found in registry', async () => { + const selected = ref('tab-1') + let panelProps: any + let tabId: string | undefined + + const Component = defineComponent({ + render: () => h(Tabs.Root as any, { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tabId = props.attrs.id + return h('button', 'Tab 1') + }, + }), + // Panel that tries to match by value but falls back to ID lookup + h(Tabs.Panel as any, { value: 'tab-1' }, { + default: (props: any) => { + panelProps = props + return h('div', 'Panel content') + }, + }), + ]), + }) + + mount(Component) + await nextTick() + + // Panel should render with correct ID even if browse returns empty + expect(panelProps).toBeDefined() + expect(panelProps.attrs.id).toBeDefined() + // Panel's aria-labelledby should reference the tab's ID + expect(panelProps.attrs['aria-labelledby']).toBe(tabId) + }) + }) + }) + + describe('keyboard navigation', () => { + async function setupTabs (orientation: 'horizontal' | 'vertical' = 'horizontal') { + const selected = ref('tab-1') + let tab1Props: any + let tab2Props: any + let tab3Props: any + + mount(Tabs.Root, { + props: { + orientation, + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + h(Tabs.Item as any, { value: 'tab-3' }, { + default: (props: any) => { + tab3Props = props + return h('button', 'Tab 3') + }, + }), + ], + }, + }) + + await nextTick() + + return { selected, tab1Props, tab2Props, tab3Props } + } + + it('should navigate with ArrowRight in horizontal mode', async () => { + const { selected, tab1Props } = await setupTabs('horizontal') + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-2') + }) + + it('should navigate with ArrowLeft in horizontal mode', async () => { + const { selected, tab2Props } = await setupTabs('horizontal') + + // First select tab-2 + tab2Props.select() + await nextTick() + + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab2Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + + it('should navigate with ArrowDown in vertical mode', async () => { + const { selected, tab1Props } = await setupTabs('vertical') + + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-2') + }) + + it('should navigate with ArrowUp in vertical mode', async () => { + const { selected, tab2Props } = await setupTabs('vertical') + + // First select tab-2 + tab2Props.select() + await nextTick() + + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab2Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + + it('should navigate to first tab with Home', async () => { + const { selected, tab3Props } = await setupTabs() + + // First select tab-3 + tab3Props.select() + await nextTick() + + const event = new KeyboardEvent('keydown', { key: 'Home' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab3Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + + it('should navigate to last tab with End', async () => { + const { selected, tab1Props } = await setupTabs() + + const event = new KeyboardEvent('keydown', { key: 'End' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-3') + }) + + it('should wrap navigation when circular is enabled (default)', async () => { + const { selected, tab3Props } = await setupTabs() + + // Select last tab + tab3Props.select() + await nextTick() + expect(selected.value).toBe('tab-3') + + // Navigate right from last tab should wrap to first + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab3Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + }) + + describe('activation modes', () => { + it('should select on focus in automatic mode (default)', async () => { + const selected = ref() + let tab2Props: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'activation': 'automatic', + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + ], + }, + }) + + await nextTick() + + // Simulate focus on tab 2 + tab2Props.attrs.onFocus() + await nextTick() + + expect(selected.value).toBe('tab-2') + }) + + it('should not select on focus in manual mode', async () => { + const selected = ref() + let tab2Props: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'activation': 'manual', + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + ], + }, + }) + + await nextTick() + + // Simulate focus on tab 2 + tab2Props.attrs.onFocus() + await nextTick() + + expect(selected.value).toBeUndefined() + }) + + it('should select with Enter in manual mode', async () => { + const selected = ref() + let tab1Props: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'activation': 'manual', + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + const event = new KeyboardEvent('keydown', { key: 'Enter' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + + it('should select with Space in manual mode', async () => { + const selected = ref() + let tab1Props: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'activation': 'manual', + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + const event = new KeyboardEvent('keydown', { key: ' ' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + }) + + describe('integration', () => { + it('should skip disabled tabs during navigation', async () => { + const selected = ref('tab-1') + let tab1Props: any + + mount(Tabs.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2', disabled: true }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3' }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + // Should skip disabled tab-2 and go to tab-3 + expect(selected.value).toBe('tab-3') + }) + + it('should use loop prop for wrapping navigation', async () => { + const selected = ref('tab-3') + let tab3Props: any + + mount(Tabs.Root, { + props: { + 'circular': true, + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3' }, { + default: (props: any) => { + tab3Props = props + return h('button', 'Tab 3') + }, + }), + ], + }, + }) + + await nextTick() + + // When circular is true and at the last tab, right arrow should wrap to first + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab3Props.attrs.onKeydown(event) + await nextTick() + + // The context's circular property enables this wrapping behavior + expect(selected.value).toBe('tab-1') + }) + + it('should support non-button tab elements', async () => { + let tabProps: any + + mount(Tabs.Root, { + props: { + modelValue: 'tab-1', + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'tab-1', as: 'a' }, { + default: (props: any) => { + tabProps = props + return h('a', { href: '#' }, 'Tab 1') + }, + }), + }, + }) + + await nextTick() + + // When as='a', disabled and type should be undefined + expect(tabProps.attrs.disabled).toBeUndefined() + expect(tabProps.attrs.type).toBeUndefined() + }) + + it('should use custom namespace for isolation', async () => { + let tabs1Props: any + let tabs2Props: any + + mount(defineComponent({ + render: () => [ + h(Tabs.Root as any, { namespace: 'tabs-1', mandatory: false }, () => + h(Tabs.Item as any, { value: 'item', namespace: 'tabs-1' }, { + default: (props: any) => { + tabs1Props = props + return h('button', 'Tab 1') + }, + }), + ), + h(Tabs.Root as any, { namespace: 'tabs-2', mandatory: false }, () => + h(Tabs.Item as any, { value: 'item', namespace: 'tabs-2' }, { + default: (props: any) => { + tabs2Props = props + return h('button', 'Tab 2') + }, + }), + ), + ], + })) + + await nextTick() + + // Select in tabs 1 + tabs1Props.select() + await nextTick() + + // Only tabs 1 item should be selected + expect(tabs1Props.isSelected).toBe(true) + expect(tabs2Props.isSelected).toBe(false) + }) + }) + + describe('edge cases', () => { + describe('empty and single tab scenarios', () => { + it('should handle empty tabs gracefully', async () => { + const wrapper = mount(Tabs.Root, { + props: { + mandatory: false, + }, + slots: { + default: () => h('div', 'No tabs'), + }, + }) + + await nextTick() + + // Should render without errors + expect(wrapper.text()).toContain('No tabs') + }) + + it('should allow selection with single tab', async () => { + let tabProps: any + + mount(Tabs.Root, { + props: { + mandatory: 'force', + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'only-tab' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Only Tab') + }, + }), + }, + }) + + await nextTick() + + // Single tab should be selected with mandatory='force' + expect(tabProps.isSelected).toBe(true) + }) + + it('should handle single tab with mandatory=false', async () => { + const selected = ref() + let tabProps: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => h(Tabs.Item as any, { value: 'only-tab' }, { + default: (props: any) => { + tabProps = props + return h('button', 'Only Tab') + }, + }), + }, + }) + + await nextTick() + + // Not auto-selected + expect(tabProps.isSelected).toBe(false) + + // Can select + tabProps.attrs.onClick() + await nextTick() + + expect(tabProps.isSelected).toBe(true) + }) + }) + + describe('all tabs disabled', () => { + it('should not navigate when all tabs are disabled', async () => { + const selected = ref() + let tab1Props: any + + mount(Tabs.Root, { + props: { + 'mandatory': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1', disabled: true }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2', disabled: true }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3', disabled: true }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + // Try to navigate + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + // Selection should remain undefined + expect(selected.value).toBeUndefined() + }) + + it('should not select first when mandatory=force and all disabled', async () => { + let tab1Props: any + + mount(Tabs.Root, { + props: { + mandatory: 'force', + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1', disabled: true }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2', disabled: true }, () => h('button', 'Tab 2')), + ], + }, + }) + + await nextTick() + + // No tab should be selected since all are disabled + expect(tab1Props.isSelected).toBe(false) + }) + }) + + describe('dynamic tab lifecycle', () => { + it('should handle dynamic tab addition', async () => { + const tabs = ref(['tab-1']) + const selected = ref('tab-1') + let newTabProps: any + + const Component = defineComponent({ + setup () { + return () => h(Tabs.Root as any, { + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, () => tabs.value.map(value => + h(Tabs.Item as any, { key: value, value }, { + default: (props: any) => { + if (value === 'tab-2') newTabProps = props + return h('button', value) + }, + }), + )) + }, + }) + + mount(Component) + await nextTick() + + // Add new tab + tabs.value = ['tab-1', 'tab-2'] + await nextTick() + + // New tab should be registered and selectable + expect(newTabProps).toBeDefined() + expect(newTabProps.isSelected).toBe(false) + + // Can select new tab + newTabProps.select() + await nextTick() + + expect(selected.value).toBe('tab-2') + }) + + it('should preserve selection when unrelated tab is removed', async () => { + const tabs = ref(['tab-1', 'tab-2', 'tab-3']) + const selected = ref('tab-2') + + const Component = defineComponent({ + setup () { + return () => h(Tabs.Root as any, { + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, () => tabs.value.map(value => + h(Tabs.Item as any, { key: value, value }, () => h('button', value)), + )) + }, + }) + + mount(Component) + await nextTick() + + // Remove tab-3 (not selected) + tabs.value = ['tab-1', 'tab-2'] + await nextTick() + + // Selection should be preserved + expect(selected.value).toBe('tab-2') + }) + + it('should handle removal of selected tab', async () => { + const tabs = ref(['tab-1', 'tab-2', 'tab-3']) + const selected = ref('tab-2') + + const Component = defineComponent({ + setup () { + return () => h(Tabs.Root as any, { + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, () => tabs.value.map(value => + h(Tabs.Item as any, { key: value, value }, () => h('button', value)), + )) + }, + }) + + mount(Component) + await nextTick() + + expect(selected.value).toBe('tab-2') + + // Remove selected tab + tabs.value = ['tab-1', 'tab-3'] + await nextTick() + + // Selection should be cleared or moved + // The exact behavior depends on implementation - just verify no error + expect(tabs.value).toEqual(['tab-1', 'tab-3']) + }) + }) + + describe('circular=false boundary behavior', () => { + it('should not wrap when circular is disabled and at last tab', async () => { + const selected = ref('tab-3') + let tab3Props: any + + mount(Tabs.Root, { + props: { + 'circular': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3' }, { + default: (props: any) => { + tab3Props = props + return h('button', 'Tab 3') + }, + }), + ], + }, + }) + + await nextTick() + + // Try to navigate right from last tab + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab3Props.attrs.onKeydown(event) + await nextTick() + + // Should stay on tab-3 (no wrap) + expect(selected.value).toBe('tab-3') + }) + + it('should not wrap when circular is disabled and at first tab', async () => { + const selected = ref('tab-1') + let tab1Props: any + + mount(Tabs.Root, { + props: { + 'circular': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => { + tab1Props = props + return h('button', 'Tab 1') + }, + }), + h(Tabs.Item as any, { value: 'tab-2' }, () => h('button', 'Tab 2')), + h(Tabs.Item as any, { value: 'tab-3' }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + // Try to navigate left from first tab + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab1Props.attrs.onKeydown(event) + await nextTick() + + // Should stay on tab-1 (no wrap) + expect(selected.value).toBe('tab-1') + }) + + it('should navigate right from middle position with loop disabled', async () => { + const selected = ref('tab-2') + let tab2Props: any + + mount(Tabs.Root, { + props: { + 'circular': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + h(Tabs.Item as any, { value: 'tab-3' }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + // Navigate right from middle + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab2Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-3') + }) + + it('should navigate left from middle position with loop disabled', async () => { + const selected = ref('tab-2') + let tab2Props: any + + mount(Tabs.Root, { + props: { + 'circular': false, + 'modelValue': selected.value, + 'onUpdate:modelValue': (v: unknown) => { + selected.value = v as string + }, + }, + slots: { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, () => h('button', 'Tab 1')), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => { + tab2Props = props + return h('button', 'Tab 2') + }, + }), + h(Tabs.Item as any, { value: 'tab-3' }, () => h('button', 'Tab 3')), + ], + }, + }) + + await nextTick() + + // Navigate left from middle + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + Object.defineProperty(event, 'preventDefault', { value: () => {} }) + + tab2Props.attrs.onKeydown(event) + await nextTick() + + expect(selected.value).toBe('tab-1') + }) + }) + }) + + describe('sSR/Hydration', () => { + it('should render to string on server without errors', async () => { + const app = createSSRApp(defineComponent({ + render: () => + h(Tabs.Root as any, { modelValue: 'tab-1' }, { + default: () => [ + h(Tabs.List as any, { label: 'Tabs' }, () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => h('button', { ...props.attrs }, 'Tab 1'), + }), + h(Tabs.Item as any, { value: 'tab-2' }, { + default: (props: any) => h('button', { ...props.attrs }, 'Tab 2'), + }), + ]), + h(Tabs.Panel as any, { value: 'tab-1' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Panel 1'), + }), + h(Tabs.Panel as any, { value: 'tab-2' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Panel 2'), + }), + ], + }), + })) + + const html = await renderToString(app) + + expect(html).toBeTruthy() + expect(html).toContain('Tab 1') + expect(html).toContain('Tab 2') + expect(html).toContain('Panel 1') + expect(html).toContain('role="tablist"') + expect(html).toContain('role="tab"') + expect(html).toContain('role="tabpanel"') + }) + + it('should render selected state on server', async () => { + const app = createSSRApp(defineComponent({ + render: () => + h(Tabs.Root as any, { modelValue: 'tab-1' }, { + default: () => + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => h('button', { ...props.attrs }, 'Tab 1'), + }), + }), + })) + + const html = await renderToString(app) + + expect(html).toContain('aria-selected="true"') + expect(html).toContain('data-selected="true"') + }) + + it('should hydrate without mismatches', async () => { + const Component = defineComponent({ + render: () => + h(Tabs.Root as any, { modelValue: 'tab-1' }, { + default: () => [ + h(Tabs.Item as any, { value: 'tab-1' }, { + default: (props: any) => h('button', { ...props.attrs }, 'Tab 1'), + }), + h(Tabs.Panel as any, { value: 'tab-1' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Panel 1'), + }), + ], + }), + }) + + const ssrApp = createSSRApp(Component) + const serverHtml = await renderToString(ssrApp) + + const container = document.createElement('div') + container.innerHTML = serverHtml + + const wrapper = mount(Component, { + attachTo: container, + }) + + await nextTick() + + expect(wrapper.text()).toContain('Tab 1') + expect(wrapper.text()).toContain('Panel 1') + + wrapper.unmount() + }) + }) +}) diff --git a/packages/0/src/components/Tabs/index.ts b/packages/0/src/components/Tabs/index.ts new file mode 100644 index 00000000..25eb2d01 --- /dev/null +++ b/packages/0/src/components/Tabs/index.ts @@ -0,0 +1,71 @@ +export { provideTabsRoot, useTabsRoot } from './TabsRoot.vue' +export { default as TabsRoot } from './TabsRoot.vue' +export { default as TabsList } from './TabsList.vue' +export { default as TabsItem } from './TabsItem.vue' +export { default as TabsPanel } from './TabsPanel.vue' + +export type { TabsActivation, TabsContext, TabsOrientation, TabsRootProps, TabsRootSlotProps, TabsTicket } from './TabsRoot.vue' +export type { TabsListProps, TabsListSlotProps } from './TabsList.vue' +export type { TabsItemProps, TabsItemSlotProps } from './TabsItem.vue' +export type { TabsPanelProps, TabsPanelSlotProps } from './TabsPanel.vue' + +// Components +import Item from './TabsItem.vue' +import List from './TabsList.vue' +import Panel from './TabsPanel.vue' +import Root from './TabsRoot.vue' + +/** + * Tabs component with sub-components for building accessible tab interfaces. + * + * @see https://0.vuetifyjs.com/components/tabs + * + * @example + * ```vue + * + * + * + * ``` + */ +export const Tabs = { + /** + * Root component that provides tabs context. + * + * @see https://0.vuetifyjs.com/components/tabs + */ + Root, + /** + * Container for tab triggers with tablist role. + * + * @see https://0.vuetifyjs.com/components/tabs#tabslist + */ + List, + /** + * Individual tab trigger. + * + * @see https://0.vuetifyjs.com/components/tabs#tabsitem + */ + Item, + /** + * Content panel associated with a tab. + * + * @see https://0.vuetifyjs.com/components/tabs#tabspanel + */ + Panel, +} diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index bfdf484c..97e7849c 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -10,3 +10,4 @@ export * from './Radio' export * from './Selection' export * from './Single' export * from './Step' +export * from './Tabs' diff --git a/playground/src/components.d.ts b/playground/src/components.d.ts index b4e4b2e5..b78a7629 100644 --- a/playground/src/components.d.ts +++ b/playground/src/components.d.ts @@ -48,6 +48,10 @@ declare module 'vue' { SingleRoot: typeof import('./../../packages/0/src/components/Single/SingleRoot.vue')['default'] StepItem: typeof import('./../../packages/0/src/components/Step/StepItem.vue')['default'] StepRoot: typeof import('./../../packages/0/src/components/Step/StepRoot.vue')['default'] + TabsList: typeof import('./../../packages/0/src/components/Tabs/TabsList.vue')['default'] + TabsPanel: typeof import('./../../packages/0/src/components/Tabs/TabsPanel.vue')['default'] + TabsRoot: typeof import('./../../packages/0/src/components/Tabs/TabsRoot.vue')['default'] + TabsTab: typeof import('./../../packages/0/src/components/Tabs/TabsTab.vue')['default'] V0Paper: typeof import('./../../packages/paper/src/components/V0Paper/V0Paper.vue')['default'] } }