add Tabs component (#674)

* add `Tabs` component (React)

* expose `Tabs` component (React)

* add `Tabs` example (React)

* add `Tabs` component (Vue)

* expose `Tabs` component (Vue)

* update changelog
This commit is contained in:
Robin Malfait
2021-07-13 19:23:55 +02:00
committed by GitHub
parent 1ec5e74689
commit 9af04a0a7e
12 changed files with 4816 additions and 2 deletions
+6 -2
View File
@@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased - React]
- Nothing yet!
### Added
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
## [Unreleased - Vue]
- Nothing yet!
### Added
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
## [@headlessui/react@v1.3.0] - 2021-06-21
@@ -0,0 +1,89 @@
import React, { useState } from 'react'
import { Tabs, Switch } from '@headlessui/react'
import { classNames } from '../../src/utils/class-names'
export default function Home() {
let tabs = [
{ name: 'My Account', content: 'Tab content for my account' },
{ name: 'Company', content: 'Tab content for company', disabled: true },
{ name: 'Team Members', content: 'Tab content for team members' },
{ name: 'Billing', content: 'Tab content for billing' },
]
let [manual, setManual] = useState(true)
return (
<div className="flex flex-col items-start w-screen h-full p-12 bg-gray-50 space-y-12">
<Switch.Group as="div" className="flex items-center space-x-4">
<Switch.Label>Manual keyboard activation</Switch.Label>
<Switch
as="button"
checked={manual}
onChange={setManual}
className={({ checked }) =>
classNames(
'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200',
checked ? 'bg-indigo-600' : 'bg-gray-200'
)
}
>
{({ checked }) => (
<span
className={classNames(
'inline-block w-5 h-5 bg-white rounded-full transform transition ease-in-out duration-200',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
)}
</Switch>
</Switch.Group>
<Tabs className="flex flex-col max-w-3xl w-full" as="div">
<Tabs.List
className="relative z-0 rounded-lg shadow flex divide-x divide-gray-200"
lazy={manual}
>
{tabs.map((tab, tabIdx) => (
<Tabs.Tab
key={tab.name}
disabled={tab.disabled}
className={({ selected }) =>
classNames(
selected ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700',
tabIdx === 0 ? 'rounded-l-lg' : '',
tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '',
tab.disabled && 'opacity-50',
'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10'
)
}
>
{({ selected }) => (
<>
<span>{tab.name}</span>
{tab.disabled && <small className="inline-block px-4 text-xs">(disabled)</small>}
<span
aria-hidden="true"
className={classNames(
selected ? 'bg-indigo-500' : 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5'
)}
/>
</>
)}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panels className="mt-4">
{tabs.map(tab => (
<Tabs.Panel className="bg-white rounded-lg p-4 shadow" key={tab.name}>
{tab.content}
</Tabs.Panel>
))}
</Tabs.Panels>
</Tabs>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
import React, {
Fragment,
createContext,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
useEffect,
// Types
ElementType,
MutableRefObject,
KeyboardEvent as ReactKeyboardEvent,
Dispatch,
ContextType,
} from 'react'
import { Props } from '../../types'
import { render, Features, PropsForFeatures } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { match } from '../../utils/match'
import { Keys } from '../../components/keyboard'
import { focusIn, Focus } from '../../utils/focus-management'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'
interface StateDefinition {
selectedIndex: number | null
orientation: 'horizontal' | 'vertical'
activation: 'auto' | 'manual'
tabs: MutableRefObject<HTMLElement | null>[]
panels: MutableRefObject<HTMLElement | null>[]
}
enum ActionTypes {
SetSelectedIndex,
SetOrientation,
SetActivation,
RegisterTab,
UnregisterTab,
RegisterPanel,
UnregisterPanel,
ForceRerender,
}
type Actions =
| { type: ActionTypes.SetSelectedIndex; index: number }
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.SetActivation; activation: StateDefinition['activation'] }
| { type: ActionTypes.RegisterTab; tab: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.UnregisterTab; tab: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.RegisterPanel; panel: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.UnregisterPanel; panel: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.ForceRerender }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.SetSelectedIndex](state, action) {
if (state.selectedIndex === action.index) return state
return { ...state, selectedIndex: action.index }
},
[ActionTypes.SetOrientation](state, action) {
if (state.orientation === action.orientation) return state
return { ...state, orientation: action.orientation }
},
[ActionTypes.SetActivation](state, action) {
if (state.activation === action.activation) return state
return { ...state, activation: action.activation }
},
[ActionTypes.RegisterTab](state, action) {
if (state.tabs.includes(action.tab)) return state
return { ...state, tabs: [...state.tabs, action.tab] }
},
[ActionTypes.UnregisterTab](state, action) {
return { ...state, tabs: state.tabs.filter(tab => tab !== action.tab) }
},
[ActionTypes.RegisterPanel](state, action) {
if (state.panels.includes(action.panel)) return state
return { ...state, panels: [...state.panels, action.panel] }
},
[ActionTypes.UnregisterPanel](state, action) {
return { ...state, panels: state.panels.filter(panel => panel !== action.panel) }
},
[ActionTypes.ForceRerender](state) {
return { ...state }
},
}
let TabsContext = createContext<
[StateDefinition, { change(index: number): void; dispatch: Dispatch<Actions> }] | null
>(null)
TabsContext.displayName = 'TabsContext'
function useTabsContext(component: string) {
let context = useContext(TabsContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Tabs.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext)
throw err
}
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_TABS_TAG = Fragment
interface TabsRenderPropArg {
selectedIndex: number
}
export function Tabs<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(
props: Props<TTag, TabsRenderPropArg> & {
defaultIndex?: number
onChange?: (index: number) => void
vertical?: boolean
manual?: boolean
}
) {
let { defaultIndex = 0, vertical = false, manual = false, onChange, ...passThroughProps } = props
const orientation = vertical ? 'vertical' : 'horizontal'
const activation = manual ? 'manual' : 'auto'
let [state, dispatch] = useReducer(stateReducer, {
selectedIndex: null,
tabs: [],
panels: [],
orientation,
activation,
} as StateDefinition)
let slot = useMemo(() => ({ selectedIndex: state.selectedIndex }), [state.selectedIndex])
let onChangeRef = useRef<(index: number) => void>(() => {})
useEffect(() => {
dispatch({ type: ActionTypes.SetOrientation, orientation })
}, [orientation])
useEffect(() => {
dispatch({ type: ActionTypes.SetActivation, activation })
}, [activation])
useEffect(() => {
if (typeof onChange === 'function') {
onChangeRef.current = onChange
}
}, [onChange])
useEffect(() => {
if (state.tabs.length <= 0) return
if (state.selectedIndex !== null) return
let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled'))
// Underflow
if (defaultIndex < 0) {
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) })
}
// Overflow
else if (defaultIndex > state.tabs.length) {
dispatch({
type: ActionTypes.SetSelectedIndex,
index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
})
}
// Middle
else {
let before = tabs.slice(0, defaultIndex)
let after = tabs.slice(defaultIndex)
let next = [...after, ...before].find(tab => focusableTabs.includes(tab))
if (!next) return
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) })
}
}, [defaultIndex, state.tabs, state.selectedIndex])
let lastChangedIndex = useRef(state.selectedIndex)
let providerBag = useMemo<ContextType<typeof TabsContext>>(
() => [
state,
{
dispatch,
change(index: number) {
if (lastChangedIndex.current !== index) onChangeRef.current(index)
lastChangedIndex.current = index
dispatch({ type: ActionTypes.SetSelectedIndex, index })
},
},
],
[state, dispatch]
)
return (
<TabsContext.Provider value={providerBag}>
{render({
props: { ...passThroughProps },
slot,
defaultTag: DEFAULT_TABS_TAG,
name: 'Tabs',
})}
</TabsContext.Provider>
)
}
// ---
let DEFAULT_LIST_TAG = 'div' as const
interface ListRenderPropArg {
selectedIndex: number
}
type ListPropsWeControl = 'role' | 'aria-orientation'
function List<TTag extends ElementType = typeof DEFAULT_LIST_TAG>(
props: Props<TTag, ListRenderPropArg, ListPropsWeControl> & {}
) {
let [{ selectedIndex, orientation }] = useTabsContext([Tabs.name, List.name].join('.'))
let slot = { selectedIndex }
let propsWeControl = {
role: 'tablist',
'aria-orientation': orientation,
}
let passThroughProps = props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_LIST_TAG,
name: 'Tabs.List',
})
}
// ---
let DEFAULT_TAB_TAG = 'button' as const
interface TabRenderPropArg {
selected: boolean
}
type TabPropsWeControl = 'id' | 'role' | 'type' | 'aria-controls' | 'aria-selected' | 'tabIndex'
function Tab<TTag extends ElementType = typeof DEFAULT_TAB_TAG>(
props: Props<TTag, TabRenderPropArg, TabPropsWeControl>
) {
let id = `headlessui-tabs-tab-${useId()}`
let [
{ selectedIndex, tabs, panels, orientation, activation },
{ dispatch, change },
] = useTabsContext([Tabs.name, Tab.name].join('.'))
let internalTabRef = useRef<HTMLElement>(null)
let tabRef = useSyncRefs(internalTabRef, element => {
if (!element) return
dispatch({ type: ActionTypes.ForceRerender })
})
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterTab, tab: internalTabRef })
return () => dispatch({ type: ActionTypes.UnregisterTab, tab: internalTabRef })
}, [dispatch, internalTabRef])
let myIndex = tabs.indexOf(internalTabRef)
let selected = myIndex === selectedIndex
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLElement>) => {
let list = tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[]
if (event.key === Keys.Space || event.key === Keys.Enter) {
event.preventDefault()
event.stopPropagation()
change(myIndex)
return
}
switch (event.key) {
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.Last)
}
return match(orientation, {
vertical() {
if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
horizontal() {
if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
})
},
[tabs, orientation, myIndex, change]
)
let handleFocus = useCallback(() => {
internalTabRef.current?.focus()
}, [internalTabRef])
let handleSelection = useCallback(() => {
internalTabRef.current?.focus()
change(myIndex)
}, [change, myIndex, internalTabRef])
let type = props?.type ?? (props.as || DEFAULT_TAB_TAG) === 'button' ? 'button' : undefined
let slot = useMemo(() => ({ selected }), [selected])
let propsWeControl = {
ref: tabRef,
onKeyDown: handleKeyDown,
onFocus: activation === 'manual' ? handleFocus : handleSelection,
onClick: handleSelection,
id,
role: 'tab',
type,
'aria-controls': panels[myIndex]?.current?.id,
'aria-selected': selected,
tabIndex: selected ? 0 : -1,
}
let passThroughProps = props
if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
}
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_TAB_TAG,
name: 'Tabs.Tab',
})
}
// ---
let DEFAULT_PANELS_TAG = 'div' as const
interface PanelsRenderPropArg {
selectedIndex: number
}
function Panels<TTag extends ElementType = typeof DEFAULT_PANELS_TAG>(
props: Props<TTag, PanelsRenderPropArg>
) {
let [{ selectedIndex }] = useTabsContext([Tabs.name, Panels.name].join('.'))
let slot = useMemo(() => ({ selectedIndex }), [selectedIndex])
return render({
props,
slot,
defaultTag: DEFAULT_PANELS_TAG,
name: 'Tabs.Panels',
})
}
// ---
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
selected: boolean
}
type PanelPropsWeControl = 'id' | 'role' | 'aria-labelledby' | 'tabIndex'
let PanelRenderFeatures = Features.RenderStrategy | Features.Static
function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg, PanelPropsWeControl> &
PropsForFeatures<typeof PanelRenderFeatures>
) {
let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext(
[Tabs.name, Panel.name].join('.')
)
let id = `headlessui-tabs-panel-${useId()}`
let internalPanelRef = useRef<HTMLElement>(null)
let panelRef = useSyncRefs(internalPanelRef, element => {
if (!element) return
dispatch({ type: ActionTypes.ForceRerender })
})
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterPanel, panel: internalPanelRef })
return () => dispatch({ type: ActionTypes.UnregisterPanel, panel: internalPanelRef })
}, [dispatch, internalPanelRef])
let myIndex = panels.indexOf(internalPanelRef)
let selected = myIndex === selectedIndex
let slot = useMemo(() => ({ selected }), [selected])
let propsWeControl = {
ref: panelRef,
id,
role: 'tabpanel',
'aria-labelledby': tabs[myIndex]?.current?.id,
tabIndex: selected ? 0 : -1,
}
if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
}
let passThroughProps = props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible: selected,
name: 'Tabs.Panel',
})
}
// ---
Tabs.List = List
Tabs.Tab = Tab
Tabs.Panels = Panels
Tabs.Panel = Panel
@@ -15,6 +15,7 @@ it('should expose the correct components', () => {
'Portal',
'RadioGroup',
'Switch',
'Tabs',
'Transition',
])
})
+1
View File
@@ -7,4 +7,5 @@ export * from './components/popover/popover'
export * from './components/portal/portal'
export * from './components/radio-group/radio-group'
export * from './components/switch/switch'
export * from './components/tabs/tabs'
export * from './components/transitions/transition'
@@ -1187,6 +1187,88 @@ export function assertRadioGroupLabel(
// ---
export function getTabList(): HTMLElement | null {
return document.querySelector('[role="tablist"]')
}
export function getTabs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]'))
}
export function getPanels(): HTMLElement[] {
return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]'))
}
// ---
export function assertTabs(
{
active,
orientation = 'horizontal',
}: {
active: number
orientation?: 'vertical' | 'horizontal'
},
list = getTabList(),
tabs = getTabs(),
panels = getPanels()
) {
try {
if (list === null) return expect(list).not.toBe(null)
expect(list).toHaveAttribute('role', 'tablist')
expect(list).toHaveAttribute('aria-orientation', orientation)
let activeTab = tabs.find(tab => tab.dataset.headlessuiIndex === '' + active)
let activePanel = panels.find(panel => panel.dataset.headlessuiIndex === '' + active)
for (let tab of tabs) {
expect(tab).toHaveAttribute('id')
expect(tab).toHaveAttribute('role', 'tab')
expect(tab).toHaveAttribute('type', 'button')
if (tab === activeTab) {
expect(tab).toHaveAttribute('aria-selected', 'true')
expect(tab).toHaveAttribute('tabindex', '0')
} else {
expect(tab).toHaveAttribute('aria-selected', 'false')
expect(tab).toHaveAttribute('tabindex', '-1')
}
if (tab.hasAttribute('aria-controls')) {
let controlsId = tab.getAttribute('aria-controls')!
let panel = document.getElementById(controlsId)
expect(panel).not.toBe(null)
expect(panels).toContain(panel)
expect(panel).toHaveAttribute('aria-labelledby', tab.id)
}
}
for (let panel of panels) {
expect(panel).toHaveAttribute('id')
expect(panel).toHaveAttribute('role', 'tabpanel')
let controlledById = panel.getAttribute('aria-labelledby')!
let tab = document.getElementById(controlledById)
expect(tabs).toContain(tab)
expect(tab).toHaveAttribute('aria-controls', panel.id)
if (panel === activePanel) {
expect(panel).toHaveAttribute('tabindex', '0')
} else {
expect(panel).toHaveAttribute('tabindex', '-1')
}
}
} catch (err) {
Error.captureStackTrace(err, assertTabs)
throw err
}
}
// ---
export function assertActiveElement(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,356 @@
import {
defineComponent,
ref,
provide,
inject,
onMounted,
onUnmounted,
computed,
InjectionKey,
Ref,
} from 'vue'
import { Features, render, omit } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { dom } from '../../utils/dom'
import { match } from '../../utils/match'
import { focusIn, Focus } from '../../utils/focus-management'
type StateDefinition = {
// State
selectedIndex: Ref<number | null>
orientation: Ref<'vertical' | 'horizontal'>
activation: Ref<'auto' | 'manual'>
tabs: Ref<Ref<HTMLElement | null>[]>
panels: Ref<Ref<HTMLElement | null>[]>
// State mutators
setSelectedIndex(index: number): void
registerTab(tab: Ref<HTMLElement | null>): void
unregisterTab(tab: Ref<HTMLElement | null>): void
registerPanel(panel: Ref<HTMLElement | null>): void
unregisterPanel(panel: Ref<HTMLElement | null>): void
}
let TabsContext = Symbol('TabsContext') as InjectionKey<StateDefinition>
function useTabsContext(component: string) {
let context = inject(TabsContext, null)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Tabs /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext)
throw err
}
return context
}
// ---
export let Tabs = defineComponent({
name: 'Tabs',
emits: ['change'],
props: {
as: { type: [Object, String], default: 'template' },
defaultIndex: { type: [Number], default: 0 },
vertical: { type: [Boolean], default: false },
manual: { type: [Boolean], default: false },
},
setup(props, { slots, attrs, emit }) {
let selectedIndex = ref<StateDefinition['selectedIndex']['value']>(null)
let tabs = ref<StateDefinition['tabs']['value']>([])
let panels = ref<StateDefinition['panels']['value']>([])
let api = {
selectedIndex,
orientation: computed(() => (props.vertical ? 'vertical' : 'horizontal')),
activation: computed(() => (props.manual ? 'manual' : 'auto')),
tabs,
panels,
setSelectedIndex(index: number) {
if (selectedIndex.value === index) return
selectedIndex.value = index
emit('change', index)
},
registerTab(tab: typeof tabs['value'][number]) {
if (!tabs.value.includes(tab)) tabs.value.push(tab)
},
unregisterTab(tab: typeof tabs['value'][number]) {
let idx = tabs.value.indexOf(tab)
if (idx !== -1) tabs.value.slice(idx, 1)
},
registerPanel(panel: typeof panels['value'][number]) {
if (!panels.value.includes(panel)) panels.value.push(panel)
},
unregisterPanel(panel: typeof panels['value'][number]) {
let idx = panels.value.indexOf(panel)
if (idx !== -1) panels.value.slice(idx, 1)
},
}
provide(TabsContext, api)
onMounted(() => {
if (api.tabs.value.length <= 0) return console.log('bail')
if (selectedIndex.value !== null) return console.log('bail 2')
let tabs = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled'))
// Underflow
if (props.defaultIndex < 0) {
selectedIndex.value = tabs.indexOf(focusableTabs[0])
}
// Overflow
else if (props.defaultIndex > api.tabs.value.length) {
selectedIndex.value = tabs.indexOf(focusableTabs[focusableTabs.length - 1])
}
// Middle
else {
let before = tabs.slice(0, props.defaultIndex)
let after = tabs.slice(props.defaultIndex)
let next = [...after, ...before].find(tab => focusableTabs.includes(tab))
if (!next) return
selectedIndex.value = tabs.indexOf(next)
}
})
return () => {
let slot = { selectedIndex: selectedIndex.value }
return render({
props: omit(props, ['defaultIndex', 'manual', 'vertical']),
slot,
slots,
attrs,
name: 'Tabs',
})
}
},
})
// ---
export let TabsList = defineComponent({
name: 'TabsList',
props: {
as: { type: [Object, String], default: 'div' },
},
setup(props, { attrs, slots }) {
let api = useTabsContext('TabsList')
return () => {
let slot = { selectedIndex: api.selectedIndex.value }
let propsWeControl = {
role: 'tablist',
'aria-orientation': api.orientation.value,
}
let passThroughProps = props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs,
slots,
name: 'TabsList',
})
}
},
})
// ---
export let TabsTab = defineComponent({
name: 'TabsTab',
props: {
as: { type: [Object, String], default: 'button' },
disabled: { type: [Boolean], default: false },
},
render() {
let api = useTabsContext('TabsTab')
let slot = { selected: this.selected }
let propsWeControl = {
ref: 'el',
onKeydown: this.handleKeyDown,
onFocus: api.activation.value === 'manual' ? this.handleFocus : this.handleSelection,
onClick: this.handleSelection,
id: this.id,
role: 'tab',
type: this.type,
'aria-controls': api.panels.value[this.myIndex]?.value?.id,
'aria-selected': this.selected,
tabIndex: this.selected ? 0 : -1,
disabled: this.$props.disabled ? true : undefined,
}
if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: this.myIndex })
}
return render({
props: { ...this.$props, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'TabsTab',
})
},
setup(props, { attrs }) {
let api = useTabsContext('TabsTab')
let id = `headlessui-tabs-tab-${useId()}`
let tabRef = ref()
onMounted(() => api.registerTab(tabRef))
onUnmounted(() => api.unregisterTab(tabRef))
let myIndex = computed(() => api.tabs.value.indexOf(tabRef))
let selected = computed(() => myIndex.value === api.selectedIndex.value)
let type = computed(() => attrs.type ?? (props.as === 'button' ? 'button' : undefined))
function handleKeyDown(event: KeyboardEvent) {
let list = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[]
if (event.key === Keys.Space || event.key === Keys.Enter) {
event.preventDefault()
event.stopPropagation()
api.setSelectedIndex(myIndex.value)
return
}
switch (event.key) {
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.Last)
}
return match(api.orientation.value, {
vertical() {
if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
horizontal() {
if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
})
}
function handleFocus() {
dom(tabRef)?.focus()
}
function handleSelection() {
if (props.disabled) return
dom(tabRef)?.focus()
api.setSelectedIndex(myIndex.value)
}
return {
el: tabRef,
id,
selected,
myIndex,
type,
handleKeyDown,
handleFocus,
handleSelection,
}
},
})
// ---
export let TabsPanels = defineComponent({
name: 'TabsPanels',
props: {
as: { type: [Object, String], default: 'div' },
},
setup(props, { slots, attrs }) {
let api = useTabsContext('TabsPanels')
return () => {
let slot = { selectedIndex: api.selectedIndex.value }
return render({
props,
slot,
attrs,
slots,
name: 'TabsPanels',
})
}
},
})
export let TabsPanel = defineComponent({
name: 'TabsPanel',
props: {
as: { type: [Object, String], default: 'div' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
},
render() {
let api = useTabsContext('TabsPanel')
let slot = { selected: this.selected }
let propsWeControl = {
ref: 'el',
id: this.id,
role: 'tabpanel',
'aria-labelledby': api.tabs.value[this.myIndex]?.value?.id,
tabIndex: this.selected ? 0 : -1,
}
if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: this.myIndex })
}
return render({
props: { ...this.$props, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.Static | Features.RenderStrategy,
visible: this.selected,
name: 'TabsPanel',
})
},
setup() {
let api = useTabsContext('TabsPanel')
let id = `headlessui-tabs-panel-${useId()}`
let panelRef = ref()
onMounted(() => api.registerPanel(panelRef))
onUnmounted(() => api.unregisterPanel(panelRef))
let myIndex = computed(() => api.panels.value.indexOf(panelRef))
let selected = computed(() => myIndex.value === api.selectedIndex.value)
return { id, el: panelRef, selected, myIndex }
},
})
@@ -56,6 +56,13 @@ it('should expose the correct components', () => {
'SwitchLabel',
'SwitchDescription',
// Tabs
'Tabs',
'TabsList',
'TabsTab',
'TabsPanels',
'TabsPanel',
// Transition
'TransitionChild',
'TransitionRoot',
+1
View File
@@ -7,4 +7,5 @@ export * from './components/popover/popover'
export * from './components/portal/portal'
export * from './components/radio-group/radio-group'
export * from './components/switch/switch'
export * from './components/tabs/tabs'
export * from './components/transitions/transition'
@@ -1187,6 +1187,88 @@ export function assertRadioGroupLabel(
// ---
export function getTabList(): HTMLElement | null {
return document.querySelector('[role="tablist"]')
}
export function getTabs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]'))
}
export function getPanels(): HTMLElement[] {
return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]'))
}
// ---
export function assertTabs(
{
active,
orientation = 'horizontal',
}: {
active: number
orientation?: 'vertical' | 'horizontal'
},
list = getTabList(),
tabs = getTabs(),
panels = getPanels()
) {
try {
if (list === null) return expect(list).not.toBe(null)
expect(list).toHaveAttribute('role', 'tablist')
expect(list).toHaveAttribute('aria-orientation', orientation)
let activeTab = tabs.find(tab => tab.dataset.headlessuiIndex === '' + active)
let activePanel = panels.find(panel => panel.dataset.headlessuiIndex === '' + active)
for (let tab of tabs) {
expect(tab).toHaveAttribute('id')
expect(tab).toHaveAttribute('role', 'tab')
expect(tab).toHaveAttribute('type', 'button')
if (tab === activeTab) {
expect(tab).toHaveAttribute('aria-selected', 'true')
expect(tab).toHaveAttribute('tabindex', '0')
} else {
expect(tab).toHaveAttribute('aria-selected', 'false')
expect(tab).toHaveAttribute('tabindex', '-1')
}
if (tab.hasAttribute('aria-controls')) {
let controlsId = tab.getAttribute('aria-controls')!
let panel = document.getElementById(controlsId)
expect(panel).not.toBe(null)
expect(panels).toContain(panel)
expect(panel).toHaveAttribute('aria-labelledby', tab.id)
}
}
for (let panel of panels) {
expect(panel).toHaveAttribute('id')
expect(panel).toHaveAttribute('role', 'tabpanel')
let controlledById = panel.getAttribute('aria-labelledby')!
let tab = document.getElementById(controlledById)
expect(tabs).toContain(tab)
expect(tab).toHaveAttribute('aria-controls', panel.id)
if (panel === activePanel) {
expect(panel).toHaveAttribute('tabindex', '0')
} else {
expect(panel).toHaveAttribute('tabindex', '-1')
}
}
} catch (err) {
Error.captureStackTrace(err, assertTabs)
throw err
}
}
// ---
export function assertActiveElement(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)