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,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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user