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:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
+38
View File
@@ -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>
+28
View File
@@ -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>
+111
View File
@@ -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>
+59
View File
@@ -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();
});
});
+20
View File
@@ -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');
+12
View File
@@ -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';