Files
headlessui/packages/@headlessui-vue/src/components/tabs/tabs.ts
T
Robin Malfait fdd2629795 Improve overal codebase, use modern tech like esbuild and TypeScript 4! (#1055)
* 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
2022-01-27 17:07:38 +01:00

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 }
},
})