fdd2629795
* use esbuild for React instead of tsdx * remove tsdx from Vue * use consistent names * add jest and prettier * update scripts * ignore some folders for prettier * run lint script instead of tsdx lint * run prettier en-masse This has a few changes because of the new prettier version. * bump typescript to latest version * make typescript happy * cleanup playground package.json * make esbuild a dev dependency * make scripts consistent * fix husky hooks * add dedicated watch script * add `yarn playground-react` and `yarn react-playground` (alias) This will make sure to run a watcher for the actual @headlessui/react package, and start a development server in the playground-react package. * ignore formatting in the .next folder * run prettier on playground-react package * setup playground-vue Still not 100% working, but getting there! * add playground aliases in @headlessui/vue and @headlessui/react This allows you to run `yarn react playground` or `yarn vue playground` from the root. * add `clean` script * move examples folder in playground-vue to root * ensure new lines for consistency in scripts * fix typescript issue * fix typescript issues in playgrounds * make sure to run prettier on everything it can * run prettier on all files * improve error output If you minify the code, then it could happen that the errors are a bit obscure. This will hardcode the component name to improve errors. * add the `prettier-plugin-tailwindcss` plugin, party! * update changelog
358 lines
9.4 KiB
TypeScript
358 lines
9.4 KiB
TypeScript
import {
|
|
defineComponent,
|
|
ref,
|
|
provide,
|
|
inject,
|
|
onMounted,
|
|
onUnmounted,
|
|
computed,
|
|
InjectionKey,
|
|
Ref,
|
|
watchEffect,
|
|
} 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'
|
|
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
|
|
|
|
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 <TabGroup /> component.`)
|
|
if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext)
|
|
throw err
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
// ---
|
|
|
|
export let TabGroup = defineComponent({
|
|
name: 'TabGroup',
|
|
emits: {
|
|
change: (_index: number) => true,
|
|
},
|
|
props: {
|
|
as: { type: [Object, String], default: 'template' },
|
|
selectedIndex: { type: [Number], default: null },
|
|
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.splice(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.splice(idx, 1)
|
|
},
|
|
}
|
|
|
|
provide(TabsContext, api)
|
|
|
|
watchEffect(() => {
|
|
if (api.tabs.value.length <= 0) return
|
|
if (props.selectedIndex === null && selectedIndex.value !== null) return
|
|
|
|
let tabs = api.tabs.value.map((tab) => dom(tab)).filter(Boolean) as HTMLElement[]
|
|
let focusableTabs = tabs.filter((tab) => !tab.hasAttribute('disabled'))
|
|
|
|
let indexToSet = props.selectedIndex ?? props.defaultIndex
|
|
|
|
// Underflow
|
|
if (indexToSet < 0) {
|
|
selectedIndex.value = tabs.indexOf(focusableTabs[0])
|
|
}
|
|
|
|
// Overflow
|
|
else if (indexToSet > api.tabs.value.length) {
|
|
selectedIndex.value = tabs.indexOf(focusableTabs[focusableTabs.length - 1])
|
|
}
|
|
|
|
// Middle
|
|
else {
|
|
let before = tabs.slice(0, indexToSet)
|
|
let after = tabs.slice(indexToSet)
|
|
|
|
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, ['selectedIndex', 'defaultIndex', 'manual', 'vertical', 'onChange']),
|
|
slot,
|
|
slots,
|
|
attrs,
|
|
name: 'TabGroup',
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
// ---
|
|
|
|
export let TabList = defineComponent({
|
|
name: 'TabList',
|
|
props: {
|
|
as: { type: [Object, String], default: 'div' },
|
|
},
|
|
setup(props, { attrs, slots }) {
|
|
let api = useTabsContext('TabList')
|
|
|
|
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: 'TabList',
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
// ---
|
|
|
|
export let Tab = defineComponent({
|
|
name: 'Tab',
|
|
props: {
|
|
as: { type: [Object, String], default: 'button' },
|
|
disabled: { type: [Boolean], default: false },
|
|
},
|
|
render() {
|
|
let api = useTabsContext('Tab')
|
|
|
|
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,
|
|
}
|
|
|
|
return render({
|
|
props: { ...this.$props, ...propsWeControl },
|
|
slot,
|
|
attrs: this.$attrs,
|
|
slots: this.$slots,
|
|
name: 'Tab',
|
|
})
|
|
},
|
|
setup(props, { attrs }) {
|
|
let api = useTabsContext('Tab')
|
|
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)
|
|
|
|
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: useResolveButtonType(
|
|
computed(() => ({ as: props.as, type: attrs.type })),
|
|
tabRef
|
|
),
|
|
handleKeyDown,
|
|
handleFocus,
|
|
handleSelection,
|
|
}
|
|
},
|
|
})
|
|
|
|
// ---
|
|
|
|
export let TabPanels = defineComponent({
|
|
name: 'TabPanels',
|
|
props: {
|
|
as: { type: [Object, String], default: 'div' },
|
|
},
|
|
setup(props, { slots, attrs }) {
|
|
let api = useTabsContext('TabPanels')
|
|
|
|
return () => {
|
|
let slot = { selectedIndex: api.selectedIndex.value }
|
|
|
|
return render({
|
|
props,
|
|
slot,
|
|
attrs,
|
|
slots,
|
|
name: 'TabPanels',
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
export let TabPanel = defineComponent({
|
|
name: 'TabPanel',
|
|
props: {
|
|
as: { type: [Object, String], default: 'div' },
|
|
static: { type: Boolean, default: false },
|
|
unmount: { type: Boolean, default: true },
|
|
},
|
|
render() {
|
|
let api = useTabsContext('TabPanel')
|
|
|
|
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,
|
|
}
|
|
|
|
return render({
|
|
props: { ...this.$props, ...propsWeControl },
|
|
slot,
|
|
attrs: this.$attrs,
|
|
slots: this.$slots,
|
|
features: Features.Static | Features.RenderStrategy,
|
|
visible: this.selected,
|
|
name: 'TabPanel',
|
|
})
|
|
},
|
|
setup() {
|
|
let api = useTabsContext('TabPanel')
|
|
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 }
|
|
},
|
|
})
|