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
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionContentProps extends PrimitiveProps {
/** Keep content mounted even when closed. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<AccordionContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
</script>
<template>
<Presence :present="forceMount || item.open.value">
<Primitive
:ref="forwardRef"
:as="as"
role="region"
:id="item.contentId.value"
:aria-labelledby="item.triggerId.value"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:hidden="!item.open.value || undefined"
>
<slot :open="item.open.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionItemProps extends PrimitiveProps {
/** Unique value for this item. */
value: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { provideAccordionItemContext, useAccordionContext } from './context';
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { value, disabled = false, as = 'div' } = defineProps<AccordionItemProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const isOpen = computed(() => ctx.isOpen(value));
const isDisabled = computed(() => ctx.disabled.value || disabled);
const triggerId = useId(undefined, 'accordion-trigger');
const contentId = useId(undefined, 'accordion-content');
provideAccordionItemContext({
value,
open: isOpen,
disabled: isDisabled,
triggerId,
contentId,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="isOpen ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot :open="isOpen" />
</Primitive>
</template>
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { RovingDirection } from '../utils/roving-focus';
export interface AccordionRootProps extends PrimitiveProps {
/** Current open value(s) for controlled mode. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
/** 'single' allows one panel; 'multiple' allows many. @default 'single' */
type?: 'single' | 'multiple';
/** Allow closing all panels in single mode. @default false */
collapsible?: boolean;
/** Disable all items. */
disabled?: boolean;
/** Orientation of the accordion. @default 'vertical' */
orientation?: 'horizontal' | 'vertical';
/** Writing direction. @default 'ltr' */
dir?: RovingDirection;
/** Wrap keyboard navigation. @default true */
loop?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
import { Primitive } from '../primitive';
import { provideAccordionContext } from './context';
import { toArray } from '@robonen/stdlib';
import { useCollectionProvider } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const {
type = 'single',
collapsible = false,
disabled = false,
orientation = 'vertical',
dir = 'ltr',
loop = true,
modelValue,
defaultValue,
as = 'div',
} = defineProps<AccordionRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
const openSet = shallowRef<Set<string>>(
new Set(toArray(modelValue ?? defaultValue)),
);
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
if (arr.length !== set.size) return false;
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
return true;
}
watch(() => modelValue, (v) => {
if (v === undefined) return;
const arr = toArray(v);
if (setEqualsArray(openSet.value, arr)) return;
openSet.value = new Set(arr);
});
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
const present = cur.has(value);
if (type === 'single') {
if (!present) return new Set([value]);
return collapsible ? new Set() : cur;
}
const next = new Set(cur);
if (present) next.delete(value);
else next.add(value);
return next;
}
function toEmitValue(set: Set<string>): string | string[] | undefined {
return type === 'single' ? set.values().next().value : [...set];
}
function commit(next: Set<string>): void {
openSet.value = next;
emit('update:modelValue', toEmitValue(next));
}
function isOpen(value: string): boolean {
return openSet.value.has(value);
}
function toggle(value: string): void {
if (disabled) return;
const cur = openSet.value;
const next = nextOpenSet(cur, value);
if (next !== cur) commit(next);
}
const { getItems, CollectionSlot } = useCollectionProvider();
const triggerElements = computed(() => getItems(true).map(i => i.ref));
function resolveFocusIndex(action: RovingAction, current: number, count: number): number {
if (action.absolute === 'home') return 0;
if (action.absolute === 'end') return count - 1;
return resolveNextIndex(current === -1 ? 0 : current, action.delta, count, loop);
}
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
const action = rovingKeyToAction(event, { orientation, dir, loop });
if (!action) return;
event.preventDefault();
const enabled = triggerElements.value.filter(x => !x.hasAttribute('data-disabled'));
if (enabled.length === 0) return;
enabled[resolveFocusIndex(action, enabled.indexOf(el), enabled.length)]!.focus();
}
provideAccordionContext({
disabled: toRef(() => disabled),
orientation: toRef(() => orientation),
direction: toRef(() => dir),
loop: toRef(() => loop),
collapsible: toRef(() => collapsible),
triggerElements,
isOpen,
toggle,
onTriggerKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:data-orientation="orientation"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionTriggerProps extends PrimitiveProps {
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Primitive } from '../primitive';
import { useCollectionInjector } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<AccordionTriggerProps>();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
function onClick(): void {
if (item.disabled.value) return;
ctx.toggle(item.value);
}
function onKeyDown(event: KeyboardEvent): void {
if (!currentElement.value) return;
ctx.onTriggerKeyDown(event, currentElement.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:as="as"
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
:id="item.triggerId.value"
:aria-expanded="item.open.value"
:aria-controls="item.contentId.value"
:aria-disabled="item.disabled.value || undefined"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:disabled="item.disabled.value || undefined"
@click="onClick"
@keydown="onKeyDown"
>
<slot :open="item.open.value" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,242 @@
import { AccordionContent, AccordionItem, AccordionRoot, AccordionTrigger } from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createAccordion(rootProps: Record<string, unknown> = {}, itemCount = 3) {
return mount(
defineComponent({
setup() {
return () => h(AccordionRoot, { ...rootProps }, {
default: () => Array.from({ length: itemCount }, (_, i) => {
const val = String.fromCodePoint(97 + i); // 'a', 'b', 'c'
return h(AccordionItem, { value: val, key: val, disabled: i === 2 ? true : undefined }, {
default: () => [
h(AccordionTrigger, null, { default: () => `Trigger ${val.toUpperCase()}` }),
h(AccordionContent, null, { default: () => `Content ${val.toUpperCase()}` }),
],
});
}),
});
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Accordion', () => {
it('renders items with correct structure', () => {
const w = createAccordion();
const triggers = w.findAll('button');
expect(triggers).toHaveLength(3);
triggers.forEach((t) => {
expect(t.attributes('aria-expanded')).toBeDefined();
expect(t.attributes('aria-controls')).toBeDefined();
});
w.unmount();
});
it('all panels closed by default (single, non-collapsible)', () => {
const w = createAccordion();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(0);
w.unmount();
});
it('defaultValue opens a panel', () => {
const w = createAccordion({ defaultValue: 'a' });
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content A');
w.unmount();
});
it('click toggles panel open/closed (single, collapsible)', async () => {
const w = createAccordion({ collapsible: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
// clicking again closes it (collapsible)
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('single mode: opening one closes previous', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('single mode: cannot close when not collapsible', async () => {
const w = createAccordion({ defaultValue: 'a', collapsible: false });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// should stay open
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
w.unmount();
});
it('multiple mode: multiple panels open', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(2);
w.unmount();
});
it('multiple mode: toggle individual items', async () => {
const w = createAccordion({ type: 'multiple', defaultValue: ['a', 'b'] });
expect(w.findAll('[role="region"]')).toHaveLength(2);
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// 'a' closed, 'b' still open
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('v-model works (single)', async () => {
const value = ref<string | undefined>('a');
const w = mount(
defineComponent({
setup() {
return () => h(AccordionRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: string | string[] | undefined) => { value.value = v as string | undefined; },
collapsible: true,
}, {
default: () => [
h(AccordionItem, { value: 'a' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'A' }),
h(AccordionContent, null, { default: () => 'PA' }),
],
}),
h(AccordionItem, { value: 'b' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'B' }),
h(AccordionContent, null, { default: () => 'PB' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
expect(w.find('[role="region"]').text()).toBe('PA');
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
expect(value.value).toBe('b');
w.unmount();
});
it('keyboard navigation (vertical, ArrowDown/ArrowUp)', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'ArrowUp');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('Home/End keys move focus', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'End');
await nextTick();
// End goes to last enabled trigger (B, since C is disabled)
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'Home');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('disabled item cannot be toggled', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[2]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('disabled root blocks all interaction', async () => {
const w = createAccordion({ disabled: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('data-state and aria-expanded reflect open state', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
expect(triggers[0]!.attributes('aria-expanded')).toBe('true');
expect(triggers[0]!.attributes('data-state')).toBe('open');
expect(triggers[1]!.attributes('aria-expanded')).toBe('false');
expect(triggers[1]!.attributes('data-state')).toBe('closed');
w.unmount();
});
it('content has role=region with aria-labelledby', () => {
const w = createAccordion({ defaultValue: 'a' });
const region = w.find('[role="region"]');
expect(region.attributes('aria-labelledby')).toBeDefined();
const trigger = w.findAll('button')[0]!;
expect(region.attributes('aria-labelledby')).toBe(trigger.attributes('id'));
w.unmount();
});
it('orientation reflects in data-orientation', () => {
const w = createAccordion({ orientation: 'horizontal' });
expect(w.find('[data-orientation="horizontal"]').exists()).toBe(true);
w.unmount();
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface AccordionContext {
disabled: Ref<boolean>;
orientation: Ref<'horizontal' | 'vertical'>;
direction: Ref<'ltr' | 'rtl'>;
loop: Ref<boolean>;
collapsible: Ref<boolean>;
/** DOM-ordered trigger elements, sourced from the internal Collection. */
triggerElements: ComputedRef<HTMLElement[]>;
isOpen: (value: string) => boolean;
toggle: (value: string) => void;
onTriggerKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
export const {
inject: useAccordionContext,
provide: provideAccordionContext,
} = useContextFactory<AccordionContext>('AccordionContext');
export interface AccordionItemContext {
value: string;
open: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
}
export const {
inject: useAccordionItemContext,
provide: provideAccordionItemContext,
} = useContextFactory<AccordionItemContext>('AccordionItemContext');
+12
View File
@@ -0,0 +1,12 @@
export { default as AccordionRoot } from './AccordionRoot.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { provideAccordionContext, useAccordionContext, provideAccordionItemContext, useAccordionItemContext } from './context';
export type { AccordionRootProps } from './AccordionRoot.vue';
export type { AccordionItemProps } from './AccordionItem.vue';
export type { AccordionTriggerProps } from './AccordionTrigger.vue';
export type { AccordionContentProps } from './AccordionContent.vue';
export type { AccordionContext, AccordionItemContext } from './context';