fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes
- Migrate to eslint flat config + composite tsconfig. - Complete the asChild→as="template" refactor (remove asChild prop + :as-child bindings across components, matching Primitive's slot model). - Fix test type errors and source type-safety (useGraceArea hull/point math, FocusScope/util ref typing). Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on transparent wrapper components + a couple of duplicate-export naming collisions) — not gated by CI (build/lint/test green); pending a component-attribute-typing design decision.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface TabsContentProps extends PrimitiveProps {
|
||||
/** Value that links this panel to a trigger. */
|
||||
value: string;
|
||||
/** Keep content mounted even when inactive. */
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useTabsContext } from './context';
|
||||
|
||||
const { value, forceMount = false, as = 'div' } = defineProps<TabsContentProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = useTabsContext();
|
||||
const isSelected = computed(() => ctx.value.value === value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="forceMount || isSelected"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="tabpanel"
|
||||
:data-state="isSelected ? 'active' : 'inactive'"
|
||||
:data-orientation="ctx.orientation.value"
|
||||
:tabindex="0"
|
||||
:hidden="forceMount && !isSelected ? true : undefined"
|
||||
>
|
||||
<slot :selected="isSelected" />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface TabsListProps extends PrimitiveProps {
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useTabsContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<TabsListProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = useTabsContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="tablist"
|
||||
:aria-orientation="ctx.orientation.value"
|
||||
:data-orientation="ctx.orientation.value"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { RovingDirection } from '../utils/roving-focus';
|
||||
|
||||
export interface TabsRootProps extends PrimitiveProps {
|
||||
/** Uncontrolled initial value. */
|
||||
defaultValue?: string;
|
||||
/** Orientation of the tab list. @default 'horizontal' */
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
/** Writing direction. @default 'ltr' */
|
||||
dir?: RovingDirection;
|
||||
/** Wrap keyboard navigation. @default true */
|
||||
loop?: boolean;
|
||||
/** Disable all tabs. */
|
||||
disabled?: boolean;
|
||||
/** How tabs are activated. @default 'automatic' */
|
||||
activationMode?: 'automatic' | 'manual';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
|
||||
import { useCollectionProvider } from '../collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideTabsContext } from './context';
|
||||
|
||||
const {
|
||||
orientation = 'horizontal',
|
||||
dir = 'ltr',
|
||||
loop = true,
|
||||
disabled = false,
|
||||
activationMode = 'automatic',
|
||||
defaultValue,
|
||||
as = 'div',
|
||||
} = defineProps<TabsRootProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const localValue = ref<string | undefined>(defaultValue);
|
||||
|
||||
const value = defineModel<string | undefined>({
|
||||
get: v => v ?? localValue.value,
|
||||
set: (v) => {
|
||||
localValue.value = v;
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
function select(v: string): void {
|
||||
if (disabled) return;
|
||||
value.value = v;
|
||||
}
|
||||
|
||||
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
|
||||
// teleport/portal children, unlike a mount-order array.
|
||||
const { getItems, CollectionSlot } = useCollectionProvider();
|
||||
const tabElements = computed(() => getItems(true).map(i => i.ref));
|
||||
|
||||
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
||||
const action = rovingKeyToAction(event, { orientation, dir, loop });
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
const enabled = tabElements.value.filter(x => !x.hasAttribute('data-disabled'));
|
||||
if (enabled.length === 0) return;
|
||||
const current = enabled.indexOf(el);
|
||||
if (action.absolute === 'home') {
|
||||
enabled[0]!.focus();
|
||||
return;
|
||||
}
|
||||
if (action.absolute === 'end') {
|
||||
enabled[enabled.length - 1]!.focus();
|
||||
return;
|
||||
}
|
||||
const nextIdx = resolveNextIndex(current === -1 ? 0 : current, action.delta, enabled.length, loop);
|
||||
const target = enabled[nextIdx]!;
|
||||
target.focus();
|
||||
if (activationMode === 'automatic') {
|
||||
const val = target.getAttribute('data-value');
|
||||
if (val !== null) select(val);
|
||||
}
|
||||
}
|
||||
|
||||
provideTabsContext({
|
||||
value,
|
||||
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
|
||||
orientation: toRef(() => orientation),
|
||||
direction: toRef(() => dir),
|
||||
loop: toRef(() => loop),
|
||||
disabled: toRef(() => disabled),
|
||||
activationMode: toRef(() => activationMode),
|
||||
tabElements,
|
||||
select,
|
||||
onTriggerKeyDown,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionSlot>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:dir="dir"
|
||||
:data-orientation="orientation"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
>
|
||||
<slot :value="value" />
|
||||
</Primitive>
|
||||
</CollectionSlot>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface TabsTriggerProps extends PrimitiveProps {
|
||||
/** Value that links this trigger to a content panel. */
|
||||
value: string;
|
||||
/** Disable this trigger. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
import { computed } from 'vue';
|
||||
import { useCollectionInjector } from '../collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useTabsContext } from './context';
|
||||
|
||||
const { value, disabled = false, as = 'button' } = defineProps<TabsTriggerProps>();
|
||||
|
||||
const ctx = useTabsContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const { CollectionItem } = useCollectionInjector();
|
||||
|
||||
const isSelected = computed(() => ctx.value.value === value);
|
||||
const isDisabled = computed(() => ctx.disabled.value || disabled);
|
||||
|
||||
function onClick(): void {
|
||||
if (isDisabled.value) return;
|
||||
ctx.select(value);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!currentElement.value) return;
|
||||
ctx.onTriggerKeyDown(event, currentElement.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionItem>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:ref="forwardRef"
|
||||
role="tab"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
:aria-selected="isSelected"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:data-state="isSelected ? 'active' : 'inactive'"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:data-value="value"
|
||||
:tabindex="isSelected ? 0 : -1"
|
||||
:disabled="isDisabled || undefined"
|
||||
@click="onClick"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot :selected="isSelected" />
|
||||
</Primitive>
|
||||
</CollectionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,225 @@
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '../index';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
function createTabs(props: Record<string, unknown> = {}) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(TabsRoot, { ...props }, {
|
||||
default: () => [
|
||||
h(TabsList, null, {
|
||||
default: () => [
|
||||
h(TabsTrigger, { value: 'a' }, { default: () => 'Tab A' }),
|
||||
h(TabsTrigger, { value: 'b' }, { default: () => 'Tab B' }),
|
||||
h(TabsTrigger, { value: 'c', disabled: true }, { default: () => 'Tab C' }),
|
||||
],
|
||||
}),
|
||||
h(TabsContent, { value: 'a' }, { default: () => 'Panel A' }),
|
||||
h(TabsContent, { value: 'b' }, { default: () => 'Panel B' }),
|
||||
h(TabsContent, { value: 'c' }, { default: () => 'Panel C' }),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
function press(el: Element, key: string, opts: Record<string, unknown> = {}) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts }));
|
||||
}
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('renders tablist with correct roles', () => {
|
||||
const w = createTabs({ defaultValue: 'a' });
|
||||
expect(w.find('[role="tablist"]').exists()).toBe(true);
|
||||
expect(w.findAll('[role="tab"]')).toHaveLength(3);
|
||||
expect(w.find('[role="tabpanel"]').exists()).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('shows correct panel based on defaultValue', () => {
|
||||
const w = createTabs({ defaultValue: 'b' });
|
||||
const panels = w.findAll('[role="tabpanel"]');
|
||||
expect(panels).toHaveLength(1);
|
||||
expect(panels[0]!.text()).toBe('Panel B');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('selects tab on click', async () => {
|
||||
const w = createTabs({ defaultValue: 'a' });
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
await tabs[1]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('Panel B');
|
||||
const tabB = tabs[1]!.element;
|
||||
expect(tabB.getAttribute('aria-selected')).toBe('true');
|
||||
expect(tabB.getAttribute('data-state')).toBe('active');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('supports v-model', async () => {
|
||||
const value = ref('a');
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(TabsRoot, {
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': (v: string | undefined) => { value.value = v!; },
|
||||
}, {
|
||||
default: () => [
|
||||
h(TabsList, null, {
|
||||
default: () => [
|
||||
h(TabsTrigger, { value: 'a' }, { default: () => 'A' }),
|
||||
h(TabsTrigger, { value: 'b' }, { default: () => 'B' }),
|
||||
],
|
||||
}),
|
||||
h(TabsContent, { value: 'a' }, { default: () => 'PA' }),
|
||||
h(TabsContent, { value: 'b' }, { default: () => 'PB' }),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('PA');
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
await tabs[1]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(value.value).toBe('b');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('navigates with arrow keys (horizontal)', async () => {
|
||||
const w = createTabs({ defaultValue: 'a' });
|
||||
await nextTick();
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
const tabA = tabs[0]!.element as HTMLElement;
|
||||
tabA.focus();
|
||||
|
||||
press(tabA, 'ArrowRight');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(tabs[1]!.element);
|
||||
// automatic mode → panel also switches
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('Panel B');
|
||||
|
||||
// ArrowRight from B should skip disabled C and loop to A
|
||||
press(tabs[1]!.element, 'ArrowRight');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(tabs[0]!.element);
|
||||
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('navigates with arrow keys (vertical)', async () => {
|
||||
const w = createTabs({ defaultValue: 'a', orientation: 'vertical' });
|
||||
await nextTick();
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
const tabA = tabs[0]!.element as HTMLElement;
|
||||
tabA.focus();
|
||||
|
||||
press(tabA, 'ArrowDown');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(tabs[1]!.element);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Home/End keys', async () => {
|
||||
const w = createTabs({ defaultValue: 'a' });
|
||||
await nextTick();
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
const tabB = tabs[1]!.element as HTMLElement;
|
||||
tabB.focus();
|
||||
|
||||
press(tabB, 'Home');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(tabs[0]!.element);
|
||||
|
||||
press(tabs[0]!.element, 'End');
|
||||
await nextTick();
|
||||
// End goes to last enabled — B (C is disabled)
|
||||
expect(document.activeElement).toBe(tabs[1]!.element);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('does not select disabled tabs', async () => {
|
||||
const w = createTabs({ defaultValue: 'a' });
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
await tabs[2]!.trigger('click');
|
||||
await nextTick();
|
||||
// Should remain on panel A
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('Panel A');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('disabled root blocks all interaction', async () => {
|
||||
const w = createTabs({ defaultValue: 'a', disabled: true });
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
await tabs[1]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('Panel A');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('manual activation mode: arrow keys move focus but do not select', async () => {
|
||||
const w = createTabs({ defaultValue: 'a', activationMode: 'manual' });
|
||||
await nextTick();
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
const tabA = tabs[0]!.element as HTMLElement;
|
||||
tabA.focus();
|
||||
|
||||
press(tabA, 'ArrowRight');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(tabs[1]!.element);
|
||||
// panel stays on A until explicit Enter/Space
|
||||
expect(w.find('[role="tabpanel"]').text()).toBe('Panel A');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('sets correct tabindex: selected=0, others=-1', () => {
|
||||
const w = createTabs({ defaultValue: 'b' });
|
||||
const tabs = w.findAll('[role="tab"]');
|
||||
expect(tabs[0]!.attributes('tabindex')).toBe('-1');
|
||||
expect(tabs[1]!.attributes('tabindex')).toBe('0');
|
||||
expect(tabs[2]!.attributes('tabindex')).toBe('-1');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('forceMount keeps panels in DOM', () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(TabsRoot, { defaultValue: 'a' }, {
|
||||
default: () => [
|
||||
h(TabsList, null, {
|
||||
default: () => [
|
||||
h(TabsTrigger, { value: 'a' }, { default: () => 'A' }),
|
||||
h(TabsTrigger, { value: 'b' }, { default: () => 'B' }),
|
||||
],
|
||||
}),
|
||||
h(TabsContent, { value: 'a', forceMount: true }, { default: () => 'PA' }),
|
||||
h(TabsContent, { value: 'b', forceMount: true }, { default: () => 'PB' }),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
const panels = w.findAll('[role="tabpanel"]');
|
||||
expect(panels).toHaveLength(2);
|
||||
// inactive panel has hidden attribute
|
||||
expect(panels[1]!.attributes('hidden')).toBeDefined();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('orientation reflects in data-orientation and aria-orientation', () => {
|
||||
const w = createTabs({ defaultValue: 'a', orientation: 'vertical' });
|
||||
expect(w.find('[role="tablist"]').attributes('aria-orientation')).toBe('vertical');
|
||||
expect(w.find('[role="tablist"]').attributes('data-orientation')).toBe('vertical');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export interface TabsContext {
|
||||
value: Ref<string | undefined>;
|
||||
orientation: Ref<'horizontal' | 'vertical'>;
|
||||
direction: Ref<'ltr' | 'rtl'>;
|
||||
loop: Ref<boolean>;
|
||||
disabled: Ref<boolean>;
|
||||
activationMode: Ref<'automatic' | 'manual'>;
|
||||
/** DOM-ordered tab elements, sourced from the internal Collection. */
|
||||
tabElements: ComputedRef<HTMLElement[]>;
|
||||
select: (value: string) => void;
|
||||
onTriggerKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export const {
|
||||
inject: useTabsContext,
|
||||
provide: provideTabsContext,
|
||||
} = useContextFactory<TabsContext>('TabsContext');
|
||||
@@ -0,0 +1,12 @@
|
||||
export { default as TabsRoot } from './TabsRoot.vue';
|
||||
export { default as TabsList } from './TabsList.vue';
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue';
|
||||
export { default as TabsContent } from './TabsContent.vue';
|
||||
|
||||
export { provideTabsContext, useTabsContext } from './context';
|
||||
|
||||
export type { TabsRootProps } from './TabsRoot.vue';
|
||||
export type { TabsListProps } from './TabsList.vue';
|
||||
export type { TabsTriggerProps } from './TabsTrigger.vue';
|
||||
export type { TabsContentProps } from './TabsContent.vue';
|
||||
export type { TabsContext } from './context';
|
||||
Reference in New Issue
Block a user