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,72 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ListboxContentProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref } from 'vue';
import { useCollectionInjector } from '../collection';
import { useForwardExpose } from '@robonen/vue';
import { useListboxRootContext } from './context';
// Module-scoped to avoid re-allocating on every keydown.
const NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']);
const { as = 'div' } = defineProps<ListboxContentProps>();
const ctx = useListboxRootContext();
const { forwardRef } = useForwardExpose();
const { CollectionSlot } = useCollectionInjector();
const isClickFocus = ref(false);
function onMouseDown(event: MouseEvent): void {
if (event.button !== 0) return;
isClickFocus.value = true;
setTimeout(() => {
isClickFocus.value = false;
}, 10);
}
function onFocus(event: FocusEvent): void {
if (isClickFocus.value) return;
ctx.onEnter(event);
}
function onKeyDown(event: KeyboardEvent): void {
const { key } = event;
const o = ctx.orientation.value;
if ((o === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight'))
|| (o === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown'))) return;
if (key === 'Enter') return ctx.onKeydownEnter(event);
if (NAV_KEYS.has(key)) {
event.preventDefault();
if (ctx.focusable.value) ctx.onKeydownNavigation(event);
return;
}
ctx.onKeydownTypeAhead(event);
}
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
role="listbox"
:tabindex="ctx.focusable.value ? (ctx.highlightedElement.value ? '-1' : '0') : '-1'"
:aria-orientation="ctx.orientation.value"
:aria-multiselectable="ctx.multiple.value ? true : undefined"
:data-orientation="ctx.orientation.value"
@mousedown="onMouseDown"
@focus="onFocus"
@keydown="onKeyDown"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ListboxFilterProps extends PrimitiveProps {
/** Controlled input value. */
modelValue?: string;
/** Focus on mount. */
autoFocus?: boolean;
/** Disable input. */
disabled?: boolean;
}
export interface ListboxFilterEmits {
'update:modelValue': [value: string];
}
</script>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, watchSyncEffect } from 'vue';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { useListboxRootContext } from './context';
// Module-scoped nav key set: avoids per-keydown array allocation.
const NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'Home', 'End']);
const {
as = 'input',
modelValue,
autoFocus = false,
disabled = false,
} = defineProps<ListboxFilterProps>();
const emit = defineEmits<ListboxFilterEmits>();
const ctx = useListboxRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const localValue = ref<string>(modelValue ?? '');
watch(() => modelValue, (v) => {
if (v === undefined || v === localValue.value) return;
localValue.value = v;
});
const isDisabled = computed(() => disabled || ctx.disabled.value);
const activedescendant = ref<string | undefined>();
watchSyncEffect(() => {
activedescendant.value = ctx.highlightedElement.value?.id;
});
onMounted(() => {
ctx.focusable.value = false;
if (!autoFocus) return;
setTimeout(() => {
(currentElement.value as HTMLInputElement | undefined)?.focus();
}, 1);
});
onUnmounted(() => {
ctx.focusable.value = true;
});
function onInput(event: Event): void {
const v = (event.target as HTMLInputElement).value;
localValue.value = v;
emit('update:modelValue', v);
ctx.highlightFirstItem();
}
function onKeyDown(event: KeyboardEvent): void {
const { key } = event;
if (key === 'Enter') return ctx.onKeydownEnter(event);
if (NAV_KEYS.has(key)) {
event.preventDefault();
ctx.onKeydownNavigation(event);
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
:value="localValue"
:disabled="isDisabled || undefined"
:aria-disabled="isDisabled ? true : undefined"
:aria-activedescendant="activedescendant"
:data-disabled="isDisabled ? '' : undefined"
@input="onInput"
@keydown="onKeyDown"
>
<slot :model-value="localValue" />
</Primitive>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ListboxGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { provideListboxGroupContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { as = 'div' } = defineProps<ListboxGroupProps>();
const { forwardRef } = useForwardExpose();
const id = useId(undefined, 'listbox-group').value;
provideListboxGroupContext({ id });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="group"
:aria-labelledby="id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ListboxGroupLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { useListboxGroupContext } from './context';
const { as = 'div' } = defineProps<ListboxGroupLabelProps>();
const group = useListboxGroupContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="group.id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,85 @@
<script lang="ts" generic="T extends ListboxValue = ListboxValue">
import type { ListboxValue } from './utils';
import type { PrimitiveProps } from '../primitive';
export interface ListboxItemProps<U extends ListboxValue = ListboxValue> extends PrimitiveProps {
/** The value of the item. */
value: U;
/** Disable this item. */
disabled?: boolean;
}
export interface ListboxItemEmits<U extends ListboxValue = ListboxValue> {
/** Fired before the value change commits. Call `event.preventDefault()` to cancel. */
select: [event: CustomEvent<{ originalEvent: Event; value: U }>];
}
</script>
<script setup lang="ts" generic="T extends ListboxValue = ListboxValue">
import { provideListboxItemContext, useListboxRootContext } from './context';
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useCollectionInjector } from '../collection';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { as = 'div', value, disabled = false } = defineProps<ListboxItemProps<T>>();
const emit = defineEmits<ListboxItemEmits<T>>();
const ctx = useListboxRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
const id = useId(undefined, 'listbox-item').value;
const isHighlighted = computed(() => currentElement.value === ctx.highlightedElement.value);
const isSelected = computed(() => ctx.isSelected(value));
const isDisabled = computed(() => disabled || ctx.disabled.value);
function handleSelect(originalEvent: Event): void {
if (isDisabled.value) return;
const detail = { originalEvent, value };
const custom = new CustomEvent('listbox.select', { bubbles: true, cancelable: true, detail });
emit('select', custom);
if (custom.defaultPrevented) return;
ctx.onValueChange(value);
if (currentElement.value) ctx.changeHighlight(currentElement.value);
}
function onClick(event: MouseEvent): void {
handleSelect(event);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key !== ' ') return;
event.preventDefault();
handleSelect(event);
}
function onPointerMove(): void {
if (!ctx.highlightOnHover.value) return;
if (!currentElement.value || ctx.highlightedElement.value === currentElement.value) return;
ctx.changeHighlight(currentElement.value, false, false);
}
provideListboxItemContext({ isSelected });
</script>
<template>
<CollectionItem :value="value">
<Primitive
:ref="forwardRef"
:as="as"
:id="id"
role="option"
:tabindex="ctx.focusable.value ? (isHighlighted ? '0' : '-1') : '-1'"
:aria-selected="isSelected"
:data-state="isSelected ? 'checked' : 'unchecked'"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:disabled="isDisabled || undefined"
@click="onClick"
@keydown="onKeyDown"
@pointermove="onPointerMove"
>
<slot :is-selected="isSelected" :is-highlighted="isHighlighted" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ListboxItemIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { useListboxItemContext } from './context';
const { as = 'span' } = defineProps<ListboxItemIndicatorProps>();
const item = useListboxItemContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
v-if="item.isSelected.value"
:ref="forwardRef"
:as="as"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts" generic="T extends ListboxValue = ListboxValue">
import type { ListboxDirection, ListboxOrientation, ListboxSelectionBehavior } from './context';
import type { ListboxValue } from './utils';
import type { PrimitiveProps } from '../primitive';
export interface ListboxRootProps<U extends ListboxValue = ListboxValue> extends PrimitiveProps {
/** Controlled value. Use `v-model`. */
modelValue?: U | U[];
/** Uncontrolled initial value. */
defaultValue?: U | U[];
/** Allow multiple selection. */
multiple?: boolean;
/** Navigation orientation. @default 'vertical' */
orientation?: ListboxOrientation;
/** Reading direction. Falls back to `ConfigProvider`. */
dir?: ListboxDirection;
/** Disable the whole listbox. */
disabled?: boolean;
/** How selection behaves in `multiple` mode. @default 'toggle' */
selectionBehavior?: ListboxSelectionBehavior;
/** Highlight items on hover. */
highlightOnHover?: boolean;
/** Compare objects by key or custom comparator. */
by?: string | ((a: U, b: U) => boolean);
}
export interface ListboxRootEmits<U extends ListboxValue = ListboxValue> {
'update:modelValue': [value: U | U[] | undefined];
highlight: [payload: { ref: HTMLElement; value: U } | undefined];
entryFocus: [event: CustomEvent];
leave: [event: Event];
}
</script>
<script setup lang="ts" generic="T extends ListboxValue = ListboxValue">
import { compare, includes } from './utils';
import { computed, nextTick, ref, shallowRef, toRef, watch } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
import { Primitive } from '../primitive';
import type { Ref } from 'vue';
import { provideListboxRootContext } from './context';
import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
import { useForwardExpose } from '@robonen/vue';
const {
as = 'div',
modelValue,
defaultValue,
multiple = false,
orientation = 'vertical',
dir,
disabled = false,
selectionBehavior = 'toggle',
highlightOnHover = false,
by,
} = defineProps<ListboxRootProps<T>>();
const emit = defineEmits<ListboxRootEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
const initial = (modelValue ?? defaultValue) as T | T[] | undefined;
// shallowRef: value is always replaced on commit, never mutated in place.
const localValue = shallowRef<T | T[] | undefined>(
multiple
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
: (Array.isArray(initial) ? initial[0] : initial),
) as Ref<T | T[] | undefined>;
watch(() => modelValue, (v) => {
if (v === undefined) return;
const cur = localValue.value;
if (Array.isArray(v)) {
if (Array.isArray(cur) && v.length === cur.length) {
let equal = true;
for (let i = 0; i < v.length; i++) {
if (v[i] !== cur[i]) {
equal = false;
break;
}
}
if (equal) return;
}
localValue.value = v.slice();
}
else if (v !== cur) {
localValue.value = v as any;
}
});
const highlightedElement = shallowRef<HTMLElement>();
const previousElement = shallowRef<HTMLElement>();
const focusable = ref(true);
const isUserAction = ref(false);
const { getItems } = useCollectionProvider();
// Inlined to avoid two intermediate array allocations (.map + .filter) and two closures
// on every call. Called in type-ahead / navigation / enter, which are hot paths.
function enabledEls(): HTMLElement[] {
const items = getItems(true);
const out: HTMLElement[] = [];
for (let i = 0; i < items.length; i++) {
const el = items[i]!.ref;
if (el.dataset.disabled !== '') out.push(el);
}
return out;
}
function isSelected(value: T): boolean {
return includes(localValue.value, value, by);
}
function commit(next: T | T[] | undefined): void {
localValue.value = next;
emit('update:modelValue', next);
}
function onValueChange(val: T): void {
isUserAction.value = true;
if (multiple) {
const cur = Array.isArray(localValue.value) ? [...(localValue.value as T[])] : [];
if (selectionBehavior === 'toggle') {
const idx = cur.findIndex(i => compare(i, val, by));
if (idx === -1) cur.push(val);
else cur.splice(idx, 1);
commit(cur);
}
else {
commit([val]);
}
}
else if (selectionBehavior === 'toggle') {
commit(compare(localValue.value as T | undefined, val, by) ? undefined : val);
}
else {
commit(val);
}
// Reset after the commit-driven watcher flush rather than via a real timer
// (avoids allocating a timer handle and the 1ms latency).
nextTick(() => {
isUserAction.value = false;
});
}
function changeHighlight(el: HTMLElement | undefined, scrollIntoView = true, focus?: boolean): void {
if (!el) return;
highlightedElement.value = el;
if (focus ?? focusable.value) el.focus({ preventScroll: !scrollIntoView });
if (scrollIntoView) el.scrollIntoView({ block: 'nearest' });
const hit = getItems(true).find(i => i.ref === el);
emit('highlight', hit as any);
}
function onKeydownEnter(event: KeyboardEvent): void {
const el = highlightedElement.value;
if (!el || !el.isConnected) return;
event.preventDefault();
event.stopPropagation();
el.click();
}
function onKeydownNavigation(event: KeyboardEvent): void {
const intent = rovingKeyToAction(event, { orientation, dir: direction.value, loop: false });
if (!intent) return;
const els = enabledEls();
if (els.length === 0) return;
if (intent.absolute === 'home') return changeHighlight(els[0]);
if (intent.absolute === 'end') return changeHighlight(els[els.length - 1]);
const current = highlightedElement.value;
const idx = current ? els.indexOf(current) : -1;
if (idx === -1) {
changeHighlight(intent.delta < 0 ? els[els.length - 1] : els[0]);
return;
}
const next = resolveNextIndex(idx, intent.delta, els.length, false);
changeHighlight(els[next]);
}
function onKeydownTypeAhead(event: KeyboardEvent): void {
if (!focusable.value) return;
if (event.altKey || event.ctrlKey || event.metaKey) {
if (event.key.toLowerCase() === 'a' && multiple) {
const all = getItems(true).map(i => i.value) as T[];
commit(all);
event.preventDefault();
const last = enabledEls().at(-1);
if (last) changeHighlight(last);
}
return;
}
if (event.key.length !== 1) return;
const letter = event.key.toLowerCase();
const els = enabledEls();
const len = els.length;
if (len === 0) return;
const start = highlightedElement.value ? els.indexOf(highlightedElement.value) + 1 : 0;
for (let i = 0; i < len; i++) {
const el = els[(start + i) % len]!;
const text = el.textContent;
if (text && text.trim().toLowerCase().startsWith(letter)) {
changeHighlight(el);
return;
}
}
}
function highlightFirstItem(): void {
nextTick(() => {
const el = enabledEls()[0];
if (el) changeHighlight(el);
});
}
function onLeave(event: Event): void {
const el = highlightedElement.value;
if (el?.isConnected) previousElement.value = el;
highlightedElement.value = undefined;
emit('leave', event);
}
function onEnter(event: Event): void {
const entryFocusEvent = new CustomEvent('listbox.entryFocus', { bubbles: false, cancelable: true });
(event.currentTarget as HTMLElement | null)?.dispatchEvent(entryFocusEvent);
emit('entryFocus', entryFocusEvent);
if (entryFocusEvent.defaultPrevented) return;
if (previousElement.value?.isConnected) {
changeHighlight(previousElement.value);
return;
}
const els = enabledEls();
for (let i = 0; i < els.length; i++) {
if (els[i]!.dataset.state === 'checked') return changeHighlight(els[i]);
}
changeHighlight(els[0]);
}
async function onFocusOut(event: FocusEvent): Promise<void> {
const target = (event.relatedTarget || event.target) as HTMLElement | null;
await nextTick();
if (highlightedElement.value && currentElement.value && !currentElement.value.contains(target)) {
onLeave(event);
}
}
// localValue is always replaced on commit — no need for deep traversal.
watch(localValue, () => {
if (isUserAction.value) return;
nextTick(() => {
const els = enabledEls();
for (let i = 0; i < els.length; i++) {
if (els[i]!.dataset.state === 'checked') {
changeHighlight(els[i], true, false);
return;
}
}
});
}, { immediate: true });
provideListboxRootContext({
modelValue: localValue,
multiple: toRef(() => multiple),
orientation: toRef(() => orientation),
direction,
disabled: toRef(() => disabled),
highlightOnHover: toRef(() => highlightOnHover),
selectionBehavior: toRef(() => selectionBehavior),
highlightedElement,
focusable,
by,
onValueChange,
isSelected,
changeHighlight,
onKeydownNavigation,
onKeydownEnter,
onKeydownTypeAhead,
highlightFirstItem,
onEnter,
onLeave,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:data-disabled="disabled ? '' : undefined"
@pointerleave="onLeave"
@focusout="onFocusOut"
>
<slot :model-value="localValue" />
</Primitive>
</template>
@@ -0,0 +1,232 @@
import {
ListboxContent,
ListboxFilter,
ListboxGroup,
ListboxGroupLabel,
ListboxItem,
ListboxItemIndicator,
ListboxRoot,
} from '../index';
import type { ListboxValue } from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createListbox(
rootProps: Record<string, unknown> = {},
options: string[] = ['Apple', 'Banana', 'Cherry'],
) {
return mount(
defineComponent({
setup() {
const value = ref(rootProps.modelValue ?? rootProps.defaultValue);
return () => h(
ListboxRoot,
{
modelValue: value.value as ListboxValue | ListboxValue[] | undefined,
'onUpdate:modelValue': (v: unknown) => (value.value = v),
...rootProps,
},
{
default: () => h(ListboxContent, null, {
default: () => options.map(opt =>
h(ListboxItem, { key: opt, value: opt }, {
default: () => [
opt,
h(ListboxItemIndicator, null, { default: () => '✓' }),
],
}),
),
}),
},
);
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Listbox', () => {
it('renders role=listbox with options', () => {
const w = createListbox();
const list = w.find('[role="listbox"]');
expect(list.exists()).toBe(true);
expect(w.findAll('[role="option"]')).toHaveLength(3);
w.unmount();
});
it('click selects an item (single)', async () => {
const w = createListbox();
const items = w.findAll('[role="option"]');
await items[1]!.trigger('click');
await nextTick();
expect(items[1]!.attributes('aria-selected')).toBe('true');
expect(items[1]!.attributes('data-state')).toBe('checked');
w.unmount();
});
it('toggle deselects on second click in single mode', async () => {
const w = createListbox();
const items = w.findAll('[role="option"]');
await items[0]!.trigger('click');
await items[0]!.trigger('click');
await nextTick();
expect(items[0]!.attributes('data-state')).toBe('unchecked');
w.unmount();
});
it('multiple selection accumulates toggles', async () => {
const w = createListbox({ multiple: true });
const items = w.findAll('[role="option"]');
await items[0]!.trigger('click');
await items[2]!.trigger('click');
await nextTick();
expect(items[0]!.attributes('data-state')).toBe('checked');
expect(items[2]!.attributes('data-state')).toBe('checked');
expect(items[1]!.attributes('data-state')).toBe('unchecked');
w.unmount();
});
it('multiple selectionBehavior=replace keeps a single value in the array', async () => {
const w = createListbox({ multiple: true, selectionBehavior: 'replace' });
const items = w.findAll('[role="option"]');
await items[0]!.trigger('click');
await items[1]!.trigger('click');
await nextTick();
expect(items[0]!.attributes('data-state')).toBe('unchecked');
expect(items[1]!.attributes('data-state')).toBe('checked');
w.unmount();
});
it('ArrowDown on listbox highlights next enabled item', async () => {
const w = createListbox();
const list = w.find('[role="listbox"]').element as HTMLElement;
list.focus();
// entry focus highlights first item
await nextTick();
press(list, 'ArrowDown');
await nextTick();
const items = w.findAll('[role="option"]');
expect(items[1]!.attributes('data-highlighted')).toBe('');
w.unmount();
});
it('Home/End jump to first/last item', async () => {
const w = createListbox();
const list = w.find('[role="listbox"]').element as HTMLElement;
list.focus();
await nextTick();
press(list, 'End');
await nextTick();
const items = w.findAll('[role="option"]');
expect(items[2]!.attributes('data-highlighted')).toBe('');
press(list, 'Home');
await nextTick();
expect(items[0]!.attributes('data-highlighted')).toBe('');
w.unmount();
});
it('Enter activates the highlighted item', async () => {
const w = createListbox();
const list = w.find('[role="listbox"]').element as HTMLElement;
list.focus();
await nextTick();
press(list, 'End');
await nextTick();
press(list, 'Enter');
await nextTick();
const items = w.findAll('[role="option"]');
expect(items[2]!.attributes('data-state')).toBe('checked');
w.unmount();
});
it('typeahead jumps to first matching item by letter', async () => {
const w = createListbox();
const list = w.find('[role="listbox"]').element as HTMLElement;
list.focus();
await nextTick();
press(list, 'c');
await nextTick();
const items = w.findAll('[role="option"]');
expect(items[2]!.attributes('data-highlighted')).toBe('');
w.unmount();
});
it('ItemIndicator renders only for selected items', async () => {
const w = createListbox({ defaultValue: 'Banana' });
await nextTick();
const items = w.findAll('[role="option"]');
expect(items[0]!.text()).not.toContain('✓');
expect(items[1]!.text()).toContain('✓');
w.unmount();
});
it('disabled root prevents selection', async () => {
const w = createListbox({ disabled: true });
const items = w.findAll('[role="option"]');
await items[0]!.trigger('click');
await nextTick();
expect(items[0]!.attributes('data-state')).toBe('unchecked');
w.unmount();
});
it('group exposes role=group with aria-labelledby', () => {
const w = mount(
defineComponent({
setup: () => () =>
h(ListboxRoot, null, {
default: () => h(ListboxContent, null, {
default: () => h(ListboxGroup, null, {
default: () => [
h(ListboxGroupLabel, null, { default: () => 'Fruits' }),
h(ListboxItem, { value: 'x' }, { default: () => 'x' }),
],
}),
}),
}),
}),
{ attachTo: document.body },
);
const g = w.find('[role="group"]');
expect(g.exists()).toBe(true);
const labelledBy = g.attributes('aria-labelledby')!;
expect(w.find(`#${labelledBy}`).text()).toBe('Fruits');
w.unmount();
});
it('filter disables intrinsic focusability and mirrors highlight via aria-activedescendant', async () => {
const w = mount(
defineComponent({
setup() {
const q = ref('');
return () => h(ListboxRoot, null, {
default: () => [
h(ListboxFilter, {
modelValue: q.value,
'onUpdate:modelValue': (v: string) => (q.value = v),
}),
h(ListboxContent, null, {
default: () => ['a', 'b'].map(v =>
h(ListboxItem, { key: v, value: v }, { default: () => v }),
),
}),
],
});
},
}),
{ attachTo: document.body },
);
const input = w.find('input').element as HTMLInputElement;
input.focus();
press(input, 'ArrowDown');
await nextTick();
const firstItem = w.findAll('[role="option"]')[0]!;
expect(input.getAttribute('aria-activedescendant')).toBe(firstItem.attributes('id'));
w.unmount();
});
});
+53
View File
@@ -0,0 +1,53 @@
import type { Ref, ShallowRef } from 'vue';
import type { ListboxValue } from './utils';
import { useContextFactory } from '@robonen/vue';
export type ListboxOrientation = 'vertical' | 'horizontal';
export type ListboxDirection = 'ltr' | 'rtl';
export type ListboxSelectionBehavior = 'toggle' | 'replace';
export interface ListboxRootContext<T extends ListboxValue = ListboxValue> {
modelValue: Ref<T | T[] | undefined>;
multiple: Ref<boolean>;
orientation: Ref<ListboxOrientation>;
direction: Ref<ListboxDirection>;
disabled: Ref<boolean>;
highlightOnHover: Ref<boolean>;
selectionBehavior: Ref<ListboxSelectionBehavior>;
highlightedElement: ShallowRef<HTMLElement | undefined>;
focusable: Ref<boolean>;
by?: string | ((a: T, b: T) => boolean);
onValueChange: (value: T) => void;
isSelected: (value: T) => boolean;
changeHighlight: (el: HTMLElement | undefined, scrollIntoView?: boolean, focus?: boolean) => void;
onKeydownNavigation: (event: KeyboardEvent) => void;
onKeydownEnter: (event: KeyboardEvent) => void;
onKeydownTypeAhead: (event: KeyboardEvent) => void;
highlightFirstItem: () => void;
onEnter: (event: Event) => void;
onLeave: (event: Event) => void;
}
export interface ListboxItemContext {
isSelected: Ref<boolean>;
}
export interface ListboxGroupContext {
id: string;
}
export const {
inject: useListboxRootContext,
provide: provideListboxRootContext,
} = useContextFactory<ListboxRootContext<any>>('listbox');
export const {
inject: useListboxItemContext,
provide: provideListboxItemContext,
} = useContextFactory<ListboxItemContext>('listbox-item');
export const {
inject: useListboxGroupContext,
provide: provideListboxGroupContext,
} = useContextFactory<ListboxGroupContext>('listbox-group');
+32
View File
@@ -0,0 +1,32 @@
export { default as ListboxRoot } from './ListboxRoot.vue';
export { default as ListboxContent } from './ListboxContent.vue';
export { default as ListboxItem } from './ListboxItem.vue';
export { default as ListboxItemIndicator } from './ListboxItemIndicator.vue';
export { default as ListboxGroup } from './ListboxGroup.vue';
export { default as ListboxGroupLabel } from './ListboxGroupLabel.vue';
export { default as ListboxFilter } from './ListboxFilter.vue';
export {
provideListboxRootContext,
useListboxRootContext,
provideListboxItemContext,
useListboxItemContext,
provideListboxGroupContext,
useListboxGroupContext,
type ListboxRootContext,
type ListboxItemContext,
type ListboxGroupContext,
type ListboxOrientation,
type ListboxDirection,
type ListboxSelectionBehavior,
} from './context';
export { compare as compareListboxValues, includes as includesListboxValue, type ListboxValue } from './utils';
export type { ListboxRootProps, ListboxRootEmits } from './ListboxRoot.vue';
export type { ListboxContentProps } from './ListboxContent.vue';
export type { ListboxItemProps, ListboxItemEmits } from './ListboxItem.vue';
export type { ListboxItemIndicatorProps } from './ListboxItemIndicator.vue';
export type { ListboxGroupProps } from './ListboxGroup.vue';
export type { ListboxGroupLabelProps } from './ListboxGroupLabel.vue';
export type { ListboxFilterProps, ListboxFilterEmits } from './ListboxFilter.vue';
+27
View File
@@ -0,0 +1,27 @@
export type ListboxValue = string | number | boolean | Record<string, unknown>;
export function compare<T>(
a: T | undefined,
b: T | undefined,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (a === undefined || b === undefined) return false;
if (by === undefined) return a === b;
if (typeof by === 'function') return by(a as T, b as T);
// string key lookup
return (a as any)?.[by] === (b as any)?.[by];
}
export function includes<T>(
value: T | T[] | undefined,
current: T,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (value === undefined) return false;
if (!Array.isArray(value)) return compare(value, current, by);
// manual loop avoids the per-call closure allocation of .some()
for (const v of value) {
if (compare(v, current, by)) return true;
}
return false;
}