1d2130f279
Publish to NPM / Check version changes and publish (push) Successful in 11m33s
Consuming the package under `verbatimModuleSyntax` + `strictTemplates` surfaced four defects that forced workarounds downstream. - Drop the deprecated `SelectValue` string alias. It collided with the `SelectValue` component exported from the same barrel, so the component resolved to the type meaning and could not be imported (TS1484). - Narrow the select's model to `SelectModelValue<T, Multiple>` and make `TabsRoot` generic over its value, so a plain `v-model` on a `Ref<string>` type-checks. Both roots declare the value prop and emit explicitly instead of via `defineModel`, which would widen the payload with `| undefined` even though neither control ever clears its value. - Stop declaring `defineModel` keys in `defineEmits` as well. The duplicate erased the payload type from the generated declarations, shipping `(...args: unknown[]) => any` for eleven components' model events. - Let every part accept global DOM attributes (`id`, `role`, `aria-*`, `data-*`, ...) through `PrimitiveAttributes`. The heritage clause is marked `@vue-ignore`, so they stay out of the runtime props and keep falling through via `$attrs` exactly as before. - Give `SelectContent` and `SelectViewport` a single styleable root: the content forwards `$attrs` onto the panel, and the viewport's scrollbar CSS moves to a reference-counted `<head>` style tag. A forwarded `class` was previously dropped, leaving the panel unstyled. The tsconfig vue preset gains `htmlAttributes: ["aria-*", "data-*"]` so hyphenated data attributes are not camelized before they reach those types. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
6.8 KiB
Vue
208 lines
6.8 KiB
Vue
<script lang="ts">
|
|
import type { PrimitiveProps } from '../../internal/primitive';
|
|
import type { RovingDirection } from '../../internal/utils/roving-focus';
|
|
import type { TabsValue } from './context';
|
|
|
|
/**
|
|
* A set of layered sections of content — known as tab panels — where only one
|
|
* panel is shown at a time, each surfaced by its own trigger. Use it to split
|
|
* related content into switchable views without leaving the page: settings
|
|
* panes, dashboards, or product detail sections.
|
|
*
|
|
* The root owns the selected value (controlled via `v-model` or uncontrolled
|
|
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
|
|
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
|
|
*/
|
|
export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
|
|
/** Controlled selected value. Bind with `v-model`. */
|
|
modelValue?: Value;
|
|
/** Uncontrolled initial value. */
|
|
defaultValue?: Value;
|
|
/** Orientation of the tab list. @default 'horizontal' */
|
|
orientation?: 'horizontal' | 'vertical';
|
|
/**
|
|
* Writing direction. When omitted, inherits from a `ConfigProvider`,
|
|
* falling back to `'ltr'`.
|
|
*/
|
|
dir?: RovingDirection;
|
|
/** Wrap keyboard navigation. @default true */
|
|
loop?: boolean;
|
|
/** Disable all tabs. */
|
|
disabled?: boolean;
|
|
/** How tabs are activated. @default 'automatic' */
|
|
activationMode?: 'automatic' | 'manual';
|
|
/**
|
|
* Unmount inactive panels from the DOM instead of keeping them mounted but
|
|
* hidden. When `false`, panels stay mounted (hidden) so their state/animation
|
|
* survives switching. Individual panels can opt in via `forceMount`.
|
|
* @default true
|
|
*/
|
|
unmountOnHide?: boolean;
|
|
}
|
|
|
|
export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
|
|
/** Fired when the selected value changes. */
|
|
'update:modelValue': [value: Value];
|
|
}
|
|
</script>
|
|
|
|
<script setup lang="ts" generic="Value extends TabsValue = TabsValue">
|
|
import type { Ref } from 'vue';
|
|
import { computed, ref, shallowRef, toRef } from 'vue';
|
|
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
|
|
import { useCollectionProvider } from '../../utilities/collection';
|
|
import { useForwardExpose } from '@robonen/vue';
|
|
import { useDirection, useId } from '../../utilities/config-provider';
|
|
import { Primitive } from '../../internal/primitive';
|
|
import { provideTabsContext } from './context';
|
|
|
|
const {
|
|
orientation = 'horizontal',
|
|
dir,
|
|
loop = true,
|
|
disabled = false,
|
|
activationMode = 'automatic',
|
|
unmountOnHide = true,
|
|
defaultValue,
|
|
modelValue,
|
|
as = 'div',
|
|
} = defineProps<TabsRootProps<Value>>();
|
|
|
|
const emit = defineEmits<TabsRootEmits<Value>>();
|
|
|
|
defineSlots<{
|
|
default?: (props: {
|
|
/** Current selected value. */
|
|
value: Value | undefined;
|
|
}) => unknown;
|
|
}>();
|
|
|
|
const { forwardRef } = useForwardExpose();
|
|
|
|
const direction = useDirection(() => dir);
|
|
|
|
// `defineModel` would type `update:modelValue` as `TabsValue | undefined`,
|
|
// forcing every consumer's `v-model` target to accept `undefined` even though
|
|
// a tab is never deselected. The prop and the emit are declared separately so
|
|
// the emitted payload stays exactly `TabsValue` (see AGENTS §3.2.3).
|
|
const localValue = ref<Value | undefined>(defaultValue) as Ref<Value | undefined>;
|
|
|
|
const value = computed<Value | undefined>({
|
|
get: () => modelValue ?? localValue.value,
|
|
set: (v) => {
|
|
localValue.value = v;
|
|
if (v !== undefined) emit('update:modelValue', v);
|
|
},
|
|
});
|
|
|
|
// The tab parts read and write plain `TabsValue`s through the context; the
|
|
// narrowed `Value` only exists to keep the consumer's `v-model` typed.
|
|
const contextValue = value as unknown as Ref<TabsValue | undefined>;
|
|
|
|
const baseId = useId(undefined, 'tabs');
|
|
const tabsListElement = shallowRef<HTMLElement>();
|
|
|
|
function getTriggerId(v: TabsValue): string {
|
|
return `${baseId.value}-trigger-${v}`;
|
|
}
|
|
|
|
function getContentId(v: TabsValue): string {
|
|
return `${baseId.value}-content-${v}`;
|
|
}
|
|
|
|
// Replace-wholesale `Set` of mounted panel values, kept in a `shallowRef` so
|
|
// reading it in triggers does not pay for deep reactivity on the Set.
|
|
const contentIds = shallowRef<Set<TabsValue>>(new Set());
|
|
|
|
function registerContent(v: TabsValue): void {
|
|
contentIds.value = new Set(contentIds.value).add(v);
|
|
}
|
|
|
|
function unregisterContent(v: TabsValue): void {
|
|
const next = new Set(contentIds.value);
|
|
next.delete(v);
|
|
contentIds.value = next;
|
|
}
|
|
|
|
function select(v: TabsValue): void {
|
|
if (disabled) return;
|
|
contextValue.value = v;
|
|
}
|
|
|
|
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
|
|
// teleport/portal children, unlike a mount-order array. Items carry their typed
|
|
// `value`, so numeric tab values keep their identity through navigation.
|
|
const { getItems, CollectionSlot } = useCollectionProvider<TabsValue>();
|
|
const tabElements = computed(() => getItems(true).map(i => i.ref));
|
|
|
|
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
|
// A fully disabled root should not roam focus at all.
|
|
if (disabled) return;
|
|
const action = rovingKeyToAction(event, { orientation, dir: direction.value, loop });
|
|
// `rovingKeyToAction` only handles Arrow/Home/End; map PageUp/PageDown to the
|
|
// first/last enabled tab locally (the shared util is intentionally minimal).
|
|
const pageAbsolute = event.key === 'PageUp'
|
|
? 'home'
|
|
: event.key === 'PageDown' ? 'end' : undefined;
|
|
if (!action && !pageAbsolute) return;
|
|
event.preventDefault();
|
|
|
|
const items = getItems(true).filter(i => !i.ref.hasAttribute('data-disabled'));
|
|
if (items.length === 0) return;
|
|
|
|
const current = items.findIndex(i => i.ref === el);
|
|
const absolute = action?.absolute ?? pageAbsolute;
|
|
let target: typeof items[number];
|
|
if (absolute === 'home') {
|
|
target = items[0]!;
|
|
}
|
|
else if (absolute === 'end') {
|
|
target = items[items.length - 1]!;
|
|
}
|
|
else {
|
|
const nextIdx = resolveNextIndex(current === -1 ? 0 : current, action!.delta, items.length, loop);
|
|
target = items[nextIdx]!;
|
|
}
|
|
|
|
target.ref.focus();
|
|
if (activationMode === 'automatic' && target.value !== undefined) {
|
|
select(target.value);
|
|
}
|
|
}
|
|
|
|
provideTabsContext({
|
|
value: contextValue,
|
|
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
|
|
orientation: toRef(() => orientation),
|
|
direction,
|
|
loop: toRef(() => loop),
|
|
disabled: toRef(() => disabled),
|
|
activationMode: toRef(() => activationMode),
|
|
unmountOnHide: toRef(() => unmountOnHide),
|
|
baseId,
|
|
tabsListElement,
|
|
contentIds,
|
|
tabElements,
|
|
getTriggerId,
|
|
getContentId,
|
|
registerContent,
|
|
unregisterContent,
|
|
select,
|
|
onTriggerKeyDown,
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<CollectionSlot>
|
|
<Primitive
|
|
:ref="forwardRef"
|
|
:as="as"
|
|
:dir="direction"
|
|
:data-orientation="orientation"
|
|
:data-disabled="disabled ? '' : undefined"
|
|
>
|
|
<slot :value="value" />
|
|
</Primitive>
|
|
</CollectionSlot>
|
|
</template>
|