|
| 1 | +import type { PropType, VNode } from 'vue' |
| 2 | +import type { JrComponent } from './_shared' |
| 3 | +import { useBoundProp } from '@json-render/vue' |
| 4 | +import { computed, defineComponent, h, ref } from 'vue' |
| 5 | +import { Badge } from './Badge' |
| 6 | +import { Icon } from './Icon' |
| 7 | + |
| 8 | +interface TabDescriptor { |
| 9 | + value: string |
| 10 | + label: string |
| 11 | + /** Icon name resolved at runtime (e.g. `ph:list`). */ |
| 12 | + icon?: string |
| 13 | + badge?: string |
| 14 | + badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger' |
| 15 | +} |
| 16 | + |
| 17 | +interface TabsProps { |
| 18 | + /** `children[i]` renders under `tabs[i]` — the two arrays are positional. */ |
| 19 | + tabs?: TabDescriptor[] |
| 20 | + /** Two-way bindable via `{ $bindState: '...' }`; otherwise local, uncontrolled. */ |
| 21 | + value?: string |
| 22 | + /** Seeds the uncontrolled case only. */ |
| 23 | + defaultValue?: string |
| 24 | + orientation?: 'horizontal' | 'vertical' |
| 25 | +} |
| 26 | + |
| 27 | +// `@antfu/design`'s LayoutTabs takes a static icon *class*, but tab icons here |
| 28 | +// are runtime-resolved *names* — so this is a thin custom component over the |
| 29 | +// shared semantic tokens (like Text/Stack), using the Icon component. Stateful |
| 30 | +// so the uncontrolled selection persists across renders (a JrComponent render |
| 31 | +// fn can't hold a ref); binds to the state store when `bindingPath` is set. |
| 32 | +const TabsImpl = defineComponent({ |
| 33 | + name: 'JrTabsImpl', |
| 34 | + props: { |
| 35 | + tabs: { type: Array as PropType<TabDescriptor[]>, default: () => [] }, |
| 36 | + value: { type: String, default: undefined }, |
| 37 | + defaultValue: { type: String, default: undefined }, |
| 38 | + orientation: { type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal' }, |
| 39 | + bindingPath: { type: String, default: undefined }, |
| 40 | + onChange: { type: Function as PropType<() => void>, default: undefined }, |
| 41 | + }, |
| 42 | + setup(props, { slots }) { |
| 43 | + // `props.value` is already the live bound value; `useBoundProp` is used |
| 44 | + // only for its store setter. |
| 45 | + const [, setBound] = useBoundProp<string>(props.value, props.bindingPath) |
| 46 | + const controlled = props.bindingPath != null |
| 47 | + const local = ref<string | undefined>(props.defaultValue ?? props.value ?? props.tabs[0]?.value) |
| 48 | + const active = computed(() => (controlled ? props.value : local.value)) |
| 49 | + const isVertical = computed(() => props.orientation === 'vertical') |
| 50 | + |
| 51 | + const setActive = (next: string) => { |
| 52 | + if (controlled) |
| 53 | + setBound(next) |
| 54 | + else local.value = next |
| 55 | + props.onChange?.() |
| 56 | + } |
| 57 | + |
| 58 | + // Roving tabindex + arrow-key navigation per WAI-ARIA. |
| 59 | + const move = (fromIndex: number, delta: number, list: HTMLElement) => { |
| 60 | + const tabs = props.tabs |
| 61 | + if (tabs.length === 0) |
| 62 | + return |
| 63 | + const nextIndex = (fromIndex + delta + tabs.length) % tabs.length |
| 64 | + setActive(tabs[nextIndex]!.value) |
| 65 | + requestAnimationFrame(() => { |
| 66 | + (list.querySelectorAll<HTMLElement>('[role="tab"]')[nextIndex])?.focus() |
| 67 | + }) |
| 68 | + } |
| 69 | + |
| 70 | + return () => { |
| 71 | + const tabs = props.tabs |
| 72 | + const activeValue = active.value |
| 73 | + const panels = slots.default?.() ?? [] |
| 74 | + const panelArr = (Array.isArray(panels) ? panels : [panels]) as VNode[] |
| 75 | + const activeIndex = tabs.findIndex(tab => tab.value === activeValue) |
| 76 | + |
| 77 | + const triggers = tabs.map((tab, index) => { |
| 78 | + const isActive = tab.value === activeValue |
| 79 | + return h('button', { |
| 80 | + 'type': 'button', |
| 81 | + 'role': 'tab', |
| 82 | + 'aria-selected': isActive ? 'true' : 'false', |
| 83 | + 'tabindex': isActive ? '0' : '-1', |
| 84 | + 'class': [ |
| 85 | + 'inline-flex items-center gap-1.5 px-3 py-2 text-sm whitespace-nowrap outline-none transition focus-visible:ring-2 focus-visible:ring-primary-500/40', |
| 86 | + isVertical.value ? 'border-r-2 -mr-px' : 'border-b-2 -mb-px', |
| 87 | + isActive |
| 88 | + ? 'color-active border-primary-500 dark:border-primary-400 font-medium' |
| 89 | + : 'color-muted border-transparent hover:color-base', |
| 90 | + ], |
| 91 | + 'onClick': () => setActive(tab.value), |
| 92 | + 'onKeydown': (e: KeyboardEvent) => { |
| 93 | + const list = (e.currentTarget as HTMLElement).parentElement |
| 94 | + if (!list) |
| 95 | + return |
| 96 | + const forward = isVertical.value ? 'ArrowDown' : 'ArrowRight' |
| 97 | + const backward = isVertical.value ? 'ArrowUp' : 'ArrowLeft' |
| 98 | + if (e.key === forward) { |
| 99 | + e.preventDefault() |
| 100 | + move(index, 1, list) |
| 101 | + } |
| 102 | + else if (e.key === backward) { |
| 103 | + e.preventDefault() |
| 104 | + move(index, -1, list) |
| 105 | + } |
| 106 | + else if (e.key === 'Home') { |
| 107 | + e.preventDefault() |
| 108 | + move(index, -index, list) |
| 109 | + } |
| 110 | + else if (e.key === 'End') { |
| 111 | + e.preventDefault() |
| 112 | + move(index, tabs.length - 1 - index, list) |
| 113 | + } |
| 114 | + }, |
| 115 | + }, [ |
| 116 | + tab.icon ? Icon({ props: { name: tab.icon, size: 14 } } as Parameters<typeof Icon>[0]) : null, |
| 117 | + h('span', tab.label), |
| 118 | + tab.badge ? Badge({ props: { text: tab.badge, variant: tab.badgeVariant ?? 'default' } } as Parameters<typeof Badge>[0]) : null, |
| 119 | + ]) |
| 120 | + }) |
| 121 | + |
| 122 | + return h('div', { class: isVertical.value ? 'flex gap-3' : 'flex flex-col gap-2' }, [ |
| 123 | + h('div', { |
| 124 | + 'role': 'tablist', |
| 125 | + 'aria-orientation': props.orientation, |
| 126 | + 'class': isVertical.value ? 'flex flex-col border-r border-base shrink-0' : 'flex border-b border-base', |
| 127 | + }, triggers), |
| 128 | + h('div', { role: 'tabpanel', class: 'flex-1 min-w-0' }, activeIndex >= 0 ? [panelArr[activeIndex]] : []), |
| 129 | + ]) |
| 130 | + } |
| 131 | + }, |
| 132 | +}) |
| 133 | + |
| 134 | +export const Tabs: JrComponent<TabsProps> = ({ props, children, on, bindings }) => |
| 135 | + h(TabsImpl, { |
| 136 | + tabs: props.tabs ?? [], |
| 137 | + value: props.value, |
| 138 | + defaultValue: props.defaultValue, |
| 139 | + orientation: props.orientation ?? 'horizontal', |
| 140 | + bindingPath: bindings?.value, |
| 141 | + onChange: () => on('change').emit(), |
| 142 | + }, () => children) |
0 commit comments