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,66 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
export interface ToggleGroupItemProps extends PrimitiveProps {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useCollectionInjector } from '../collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useToggleGroupContext } from './context';
|
||||
|
||||
const { value, disabled = false, as = 'button' } = defineProps<ToggleGroupItemProps>();
|
||||
|
||||
const ctx = useToggleGroupContext();
|
||||
const { CollectionItem } = useCollectionInjector();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
const isDisabled = computed(() => ctx.disabled.value || disabled);
|
||||
const isPressed = computed(() => ctx.isPressed(value));
|
||||
|
||||
// Roving focus: only one enabled item is the tabstop (first pressed, else first enabled).
|
||||
const isTabStop = computed(() => {
|
||||
if (!ctx.rovingFocus.value || isDisabled.value) return !ctx.rovingFocus.value && !isDisabled.value;
|
||||
const enabled = ctx.items.value.filter(x => !x.hasAttribute('data-disabled'));
|
||||
if (enabled.length === 0) return false;
|
||||
const firstPressed = enabled.find(n => n.getAttribute('aria-pressed') === 'true' || n.getAttribute('aria-checked') === 'true');
|
||||
const target = firstPressed ?? enabled[0];
|
||||
return currentElement.value === target;
|
||||
});
|
||||
|
||||
function onClick(): void {
|
||||
if (isDisabled.value) return;
|
||||
ctx.toggle(value);
|
||||
}
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!currentElement.value) return;
|
||||
ctx.onItemKeyDown(event, currentElement.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionItem>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:ref="forwardRef"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
:role="ctx.type.value === 'single' ? 'radio' : undefined"
|
||||
:aria-pressed="ctx.type.value === 'multiple' ? isPressed : undefined"
|
||||
:aria-checked="ctx.type.value === 'single' ? isPressed : undefined"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:data-state="isPressed ? 'on' : 'off'"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:tabindex="isDisabled ? -1 : (ctx.rovingFocus.value ? (isTabStop ? 0 : -1) : 0)"
|
||||
:disabled="isDisabled || undefined"
|
||||
@click="onClick"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot :pressed="isPressed" />
|
||||
</Primitive>
|
||||
</CollectionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { RovingDirection } from '../utils/roving-focus';
|
||||
import type { ToggleGroupType } from './context';
|
||||
|
||||
export interface ToggleGroupRootProps extends PrimitiveProps {
|
||||
type?: ToggleGroupType;
|
||||
modelValue?: string | string[];
|
||||
defaultValue?: string | string[];
|
||||
disabled?: boolean;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
dir?: RovingDirection;
|
||||
loop?: boolean;
|
||||
rovingFocus?: boolean;
|
||||
}
|
||||
|
||||
export interface ToggleGroupRootEmits {
|
||||
'update:modelValue': [value: string | string[] | undefined];
|
||||
valueChange: [value: string | string[]];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef, watch } from 'vue';
|
||||
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
|
||||
import { useCollectionProvider } from '../collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideToggleGroupContext } from './context';
|
||||
|
||||
const {
|
||||
type = 'single',
|
||||
disabled = false,
|
||||
orientation = 'horizontal',
|
||||
dir = 'ltr',
|
||||
loop = true,
|
||||
rovingFocus = true,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
as = 'div',
|
||||
} = defineProps<ToggleGroupRootProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const emit = defineEmits<ToggleGroupRootEmits>();
|
||||
|
||||
function normalize(v: string | string[] | undefined): string[] {
|
||||
if (v === undefined) return [];
|
||||
if (Array.isArray(v)) return v.slice();
|
||||
return [v];
|
||||
}
|
||||
|
||||
const localValue = ref<string[]>(
|
||||
normalize(modelValue).length > 0 ? normalize(modelValue) : normalize(defaultValue),
|
||||
);
|
||||
|
||||
watch(() => modelValue, (v) => {
|
||||
if (v === undefined) return;
|
||||
const n = normalize(v);
|
||||
if (n.length === localValue.value.length && n.every((x, i) => x === localValue.value[i])) return;
|
||||
localValue.value = n;
|
||||
});
|
||||
|
||||
function emitValue(next: string[]): void {
|
||||
localValue.value = next;
|
||||
if (type === 'single') {
|
||||
const v = next[0];
|
||||
emit('update:modelValue', v);
|
||||
emit('valueChange', v ?? '');
|
||||
}
|
||||
else {
|
||||
emit('update:modelValue', next);
|
||||
emit('valueChange', next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(v: string): void {
|
||||
if (disabled) return;
|
||||
if (type === 'single') {
|
||||
if (localValue.value[0] === v) emitValue([]);
|
||||
else emitValue([v]);
|
||||
}
|
||||
else if (localValue.value.includes(v)) {
|
||||
emitValue(localValue.value.filter(x => x !== v));
|
||||
}
|
||||
else {
|
||||
emitValue([...localValue.value, v]);
|
||||
}
|
||||
}
|
||||
|
||||
function isPressed(v: string): boolean {
|
||||
return localValue.value.includes(v);
|
||||
}
|
||||
|
||||
// DOM-order items via Collection primitive — survives v-for reorders.
|
||||
const { getItems, CollectionSlot } = useCollectionProvider();
|
||||
const items = computed(() => getItems(true).map(i => i.ref));
|
||||
|
||||
function onItemKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
||||
if (!rovingFocus) return;
|
||||
const action = rovingKeyToAction(event, { orientation, dir, loop });
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
const enabled = items.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);
|
||||
enabled[nextIdx]!.focus();
|
||||
}
|
||||
|
||||
provideToggleGroupContext({
|
||||
type: toRef(() => type),
|
||||
value: localValue,
|
||||
toggle,
|
||||
isPressed,
|
||||
orientation: toRef(() => orientation),
|
||||
direction: toRef(() => dir),
|
||||
loop: toRef(() => loop),
|
||||
disabled: toRef(() => disabled),
|
||||
rovingFocus: toRef(() => rovingFocus),
|
||||
items,
|
||||
onItemKeyDown,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionSlot>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:role="type === 'single' ? 'radiogroup' : 'group'"
|
||||
:aria-orientation="orientation"
|
||||
:aria-disabled="disabled || undefined"
|
||||
:dir="dir"
|
||||
:data-orientation="orientation"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
>
|
||||
<slot :value="localValue" />
|
||||
</Primitive>
|
||||
</CollectionSlot>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { ToggleGroupItem, ToggleGroupRoot } from '../index';
|
||||
|
||||
function mountGroup(opts: Record<string, unknown> = {}) {
|
||||
const model = ref<string | string[] | undefined>(undefined);
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ToggleGroupRoot, {
|
||||
modelValue: model.value,
|
||||
'onUpdate:modelValue': (v: string | string[] | undefined) => { model.value = v; },
|
||||
...opts,
|
||||
}, {
|
||||
default: () => [
|
||||
h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' }),
|
||||
h(ToggleGroupItem, { value: 'b', id: 'b' }, { default: () => 'B' }),
|
||||
h(ToggleGroupItem, { value: 'c', id: 'c', disabled: true }, { default: () => 'C' }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return { wrapper: mount(Harness, { attachTo: document.body }), model };
|
||||
}
|
||||
|
||||
function press(el: Element, key: string): void {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
describe('ToggleGroup (single)', () => {
|
||||
it('role="radiogroup" and items role="radio"', () => {
|
||||
const { wrapper } = mountGroup();
|
||||
expect(wrapper.element.getAttribute('role')).toBe('radiogroup');
|
||||
expect(document.querySelectorAll('[role="radio"]')).toHaveLength(3);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('click selects; clicking selected deselects', async () => {
|
||||
const { wrapper, model } = mountGroup();
|
||||
await nextTick();
|
||||
const a = document.querySelector<HTMLButtonElement>('#a')!;
|
||||
a.click();
|
||||
await nextTick();
|
||||
expect(model.value).toBe('a');
|
||||
expect(a.getAttribute('aria-checked')).toBe('true');
|
||||
a.click();
|
||||
await nextTick();
|
||||
expect(model.value).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('selecting another replaces', async () => {
|
||||
const { wrapper, model } = mountGroup({ defaultValue: 'a' });
|
||||
await nextTick();
|
||||
const b = document.querySelector<HTMLButtonElement>('#b')!;
|
||||
b.click();
|
||||
await nextTick();
|
||||
expect(model.value).toBe('b');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ArrowRight cycles focus (roving)', async () => {
|
||||
const { wrapper } = mountGroup();
|
||||
await nextTick();
|
||||
const a = document.querySelector<HTMLButtonElement>('#a')!;
|
||||
const b = document.querySelector<HTMLButtonElement>('#b')!;
|
||||
a.focus();
|
||||
press(a, 'ArrowRight');
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(b);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('disabled item aria-disabled=true, not toggleable', async () => {
|
||||
const { wrapper, model } = mountGroup();
|
||||
await nextTick();
|
||||
const c = document.querySelector<HTMLButtonElement>('#c')!;
|
||||
expect(c.getAttribute('aria-disabled')).toBe('true');
|
||||
c.click();
|
||||
await nextTick();
|
||||
expect(model.value).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToggleGroup (multiple)', () => {
|
||||
it('role="group" and items with aria-pressed', async () => {
|
||||
const { wrapper, model } = mountGroup({ type: 'multiple' });
|
||||
await nextTick();
|
||||
expect(wrapper.element.getAttribute('role')).toBe('group');
|
||||
const a = document.querySelector<HTMLButtonElement>('#a')!;
|
||||
const b = document.querySelector<HTMLButtonElement>('#b')!;
|
||||
a.click();
|
||||
await nextTick();
|
||||
expect(a.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(model.value).toEqual(['a']);
|
||||
b.click();
|
||||
await nextTick();
|
||||
expect(model.value).toEqual(['a', 'b']);
|
||||
// Toggle off a:
|
||||
a.click();
|
||||
await nextTick();
|
||||
expect(model.value).toEqual(['b']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { RovingDirection, RovingOrientation } from '../utils/roving-focus';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export type ToggleGroupType = 'single' | 'multiple';
|
||||
|
||||
export interface ToggleGroupContext {
|
||||
type: Ref<ToggleGroupType>;
|
||||
value: Ref<string[]>;
|
||||
toggle: (v: string) => void;
|
||||
isPressed: (v: string) => boolean;
|
||||
orientation: Ref<RovingOrientation>;
|
||||
direction: Ref<RovingDirection>;
|
||||
loop: Ref<boolean>;
|
||||
disabled: Ref<boolean>;
|
||||
rovingFocus: Ref<boolean>;
|
||||
/** DOM-ordered items, sourced from the internal Collection. */
|
||||
items: ComputedRef<HTMLElement[]>;
|
||||
onItemKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
|
||||
}
|
||||
|
||||
const ctx = useContextFactory<ToggleGroupContext>('ToggleGroupContext');
|
||||
|
||||
export const provideToggleGroupContext = ctx.provide;
|
||||
export const useToggleGroupContext = ctx.inject;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ToggleGroupItem } from './ToggleGroupItem.vue';
|
||||
export { default as ToggleGroupRoot } from './ToggleGroupRoot.vue';
|
||||
export type { ToggleGroupType } from './context';
|
||||
export type { ToggleGroupItemProps } from './ToggleGroupItem.vue';
|
||||
export type { ToggleGroupRootEmits, ToggleGroupRootProps } from './ToggleGroupRoot.vue';
|
||||
Reference in New Issue
Block a user