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:
+6
-2
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user