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,33 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface ComboboxAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxAnchorProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
watchPostEffect(() => rootCtx.onParentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onParentChange(undefined));
</script>
<template>
<PopperAnchor :reference="props.reference">
<Primitive
:ref="forwardRef"
:as="props.as ?? 'div'"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export type ComboboxArrowProps = PopperArrowProps;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { PopperArrow } from '../popper';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxArrowProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
</script>
<template>
<PopperArrow
v-if="rootCtx.open.value"
:ref="forwardRef"
v-bind="props"
/>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'button' } = defineProps<ComboboxCancelProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
function handleClick() {
rootCtx.onSearchTermChange('');
const input = rootCtx.inputElement.value;
if (input) {
input.value = '';
input.focus();
}
rootCtx.onUserInputtedChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-label="Clear"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type ComboboxContentProps = ComboboxContentImplProps;
export type ComboboxContentEmits = ComboboxContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import ComboboxContentImpl from './ComboboxContentImpl.vue';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentProps>();
const emit = defineEmits<ComboboxContentEmits>();
const rootCtx = useComboboxRootContext();
</script>
<template>
<Presence :present="rootCtx.open.value">
<ComboboxContentImpl
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
>
<slot />
</ComboboxContentImpl>
</Presence>
</template>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
/** Position strategy. @default 'popper' */
position?: 'inline' | 'popper';
/** Block outside pointer events. @default false */
disableOutsidePointerEvents?: boolean;
}
export interface ComboboxContentImplEmits {
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: FocusScopeEmits['unmountAutoFocus'];
}
</script>
<script setup lang="ts">
import { onBeforeUnmount, shallowRef, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { Primitive } from '../primitive';
import { VisuallyHidden } from '../visually-hidden';
import { useHideOthers } from '../utils/useHideOthers';
import { provideComboboxContentContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentImplProps>();
const emit = defineEmits<ComboboxContentImplEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const viewportElement = shallowRef<HTMLElement | undefined>(undefined);
watchPostEffect(() => rootCtx.onContentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onContentChange(undefined));
useHideOthers(toRef(() => rootCtx.parentElement.value));
provideComboboxContentContext({
viewportElement,
onViewportChange: (el) => { viewportElement.value = el; },
position: toRef(() => props.position ?? 'popper'),
});
function handleEscape(event: KeyboardEvent) {
rootCtx.onOpenChange(false);
emit('escapeKeyDown', event);
}
function handlePointerDownOutside(event: any) {
const target = event.target as Element | null;
const input = rootCtx.inputElement.value;
const trigger = rootCtx.triggerElement.value;
if (target && (input?.contains(target) || trigger?.contains(target))) {
event.preventDefault();
return;
}
emit('pointerDownOutside', event);
if (!event.defaultPrevented) rootCtx.onOpenChange(false);
}
function handleFocusOutside(event: any) {
emit('focusOutside', event);
}
function handleCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
}
</script>
<template>
<FocusScope
as="template"
:loop="false"
:trapped="false"
@mount-auto-focus.prevent
@unmount-auto-focus="handleCloseAutoFocus"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
@escape-key-down="handleEscape"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@dismiss="rootCtx.onOpenChange(false)"
>
<PopperContent
v-if="(props.position ?? 'popper') === 'popper'"
:ref="forwardRef"
:as="props.as ?? 'div'"
:side="props.side ?? 'bottom'"
:side-offset="props.sideOffset ?? 4"
:align="props.align ?? 'start'"
:align-offset="props.alignOffset"
:avoid-collisions="props.avoidCollisions"
:collision-boundary="props.collisionBoundary"
:collision-padding="props.collisionPadding"
:arrow-padding="props.arrowPadding"
:sticky="props.sticky"
:hide-when-detached="props.hideWhenDetached"
:update-position-strategy="props.updatePositionStrategy"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</PopperContent>
<Primitive
v-else
:ref="forwardRef"
:as="props.as ?? 'div'"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</Primitive>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxEmptyProps extends PrimitiveProps {
/** Render even when items exist but none are filtered out. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'div', always = false } = defineProps<ComboboxEmptyProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const shouldRender = computed(() => {
if (always) return true;
return rootCtx.filterState.value.count === 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxGroupContext, useComboboxRootContext } from './context';
const { as = 'div' } = defineProps<ComboboxGroupProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const id = useId(undefined, 'combobox-group');
const isVisible = computed(() => rootCtx.filterState.value.groups.has(id.value));
onMounted(() => rootCtx.onGroupRegister(id.value));
onBeforeUnmount(() => rootCtx.onGroupUnregister(id.value));
provideComboboxGroupContext({ id });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:as="as"
role="group"
:aria-labelledby="id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,221 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxInputProps extends PrimitiveProps {
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
/** Open the combobox when the input is focused. */
openOnFocus?: boolean;
/** Open the combobox when the input is clicked. */
openOnClick?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { OPEN_KEYS } from './utils';
const {
as = 'input',
disabled = false,
autoFocus = false,
openOnFocus = false,
openOnClick = false,
} = defineProps<ComboboxInputProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
const activeDescendant = computed(() => rootCtx.selectedValueId.value);
function displayString(value: unknown): string {
if (rootCtx.displayValue) return rootCtx.displayValue(value);
if (value === undefined || value === null) return '';
if (Array.isArray(value)) return '';
if (typeof value === 'object') return '';
return String(value);
}
function syncDisplayValue() {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
const next = displayString(rootCtx.modelValue.value);
if (input.value !== next) input.value = next;
}
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
rootCtx.onInputChange(el);
if (el) {
el.value = rootCtx.searchTerm.value || displayString(rootCtx.modelValue.value);
}
if (autoFocus) setTimeout(() => el?.focus(), 1);
});
onBeforeUnmount(() => rootCtx.onInputChange(undefined));
watch(() => rootCtx.modelValue.value, () => {
if (rootCtx.isUserInputted.value) return;
if (!rootCtx.resetSearchTermOnSelect.value && rootCtx.searchTerm.value) return;
rootCtx.onSearchTermChange('');
syncDisplayValue();
}, { deep: true });
watch(() => rootCtx.searchTerm.value, (v) => {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
if (!v && !rootCtx.isUserInputted.value) {
syncDisplayValue();
return;
}
if (input.value !== v) input.value = v;
});
watch(() => rootCtx.filterState.value, (newState, oldState) => {
if (oldState && oldState.count === 0 && newState.count > 0) {
rootCtx.highlightFirstItem();
}
});
function moveHighlight(delta: number) {
const els = rootCtx.getVisibleItemElements();
if (els.length === 0) return;
const curId = rootCtx.selectedValueId.value;
let idx = -1;
if (curId) {
for (let i = 0; i < els.length; i++) {
if (els[i]!.id === curId) {
idx = i;
break;
}
}
}
let nextIdx: number;
if (idx === -1) nextIdx = delta > 0 ? 0 : els.length - 1;
else nextIdx = (idx + delta + els.length) % els.length;
rootCtx.highlightItemById(els[nextIdx]!.id);
}
function commitHighlighted() {
const value = rootCtx.selectedValue.value;
if (value === undefined) return false;
rootCtx.onValueChange(value);
return true;
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const next = target.value;
rootCtx.onUserInputtedChange(true);
rootCtx.onSearchTermChange(next);
if (!rootCtx.open.value) {
rootCtx.onOpenChange(true);
nextTick(() => rootCtx.highlightFirstItem());
}
else {
nextTick(() => rootCtx.highlightFirstItem());
}
}
function handleKeyDown(event: KeyboardEvent) {
if (isDisabled.value) return;
const { key } = event;
if (!rootCtx.open.value && OPEN_KEYS.includes(key)) {
event.preventDefault();
rootCtx.onOpenChange(true);
return;
}
if (!rootCtx.open.value) return;
switch (key) {
case 'ArrowDown':
event.preventDefault();
moveHighlight(1);
break;
case 'ArrowUp':
event.preventDefault();
moveHighlight(-1);
break;
case 'Home': {
event.preventDefault();
const first = rootCtx.getVisibleItemElements()[0];
if (first) rootCtx.highlightItemById(first.id);
break;
}
case 'End': {
event.preventDefault();
const list = rootCtx.getVisibleItemElements();
const last = list[list.length - 1];
if (last) rootCtx.highlightItemById(last.id);
break;
}
case 'Enter':
if (commitHighlighted()) event.preventDefault();
break;
case 'Escape':
event.preventDefault();
rootCtx.onOpenChange(false);
if (rootCtx.resetSearchTermOnBlur.value) rootCtx.onSearchTermChange('');
break;
case 'Tab':
rootCtx.onOpenChange(false);
break;
}
}
function handleFocus() {
if (openOnFocus && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleClick() {
if (openOnClick && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleBlur(event: FocusEvent) {
if (!rootCtx.open.value) return;
const nextFocus = event.relatedTarget as Element | null;
if (!nextFocus) return;
const parent = rootCtx.parentElement.value;
const content = rootCtx.contentElement.value;
if (parent?.contains(nextFocus) || content?.contains(nextFocus)) return;
rootCtx.onOpenChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="rootCtx.open.value"
:aria-controls="rootCtx.contentId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="isDisabled || undefined"
:aria-required="rootCtx.required.value || undefined"
:disabled="isDisabled || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
@input="handleInput"
@keydown="handleKeyDown"
@focus="handleFocus"
@click="handleClick"
@blur="handleBlur"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AcceptableValue } from './utils';
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Item value. Selected/registered identity. */
value: T;
/** Optional explicit text for filter + typeahead. */
textValue?: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxItemProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
let groupCtx: { id: { value: string } } | null = null;
try {
groupCtx = useComboboxGroupContext() as any;
}
catch {
groupCtx = null;
}
const id = useId(undefined, 'combobox-item');
const textValue = ref(props.textValue ?? '');
const isDisabled = computed(() => rootCtx.disabled.value || !!props.disabled);
const isSelected = computed(() => rootCtx.isSelected(props.value));
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
const isVisible = computed(() => rootCtx.filterState.value.items.has(id.value));
function syncRegistration() {
rootCtx.onItemRegister(id.value, {
value: props.value,
textValue: textValue.value,
disabled: isDisabled.value,
});
}
onMounted(() => {
const el = currentElement.value as HTMLElement | undefined;
if (el && !props.textValue) {
textValue.value = el.textContent?.trim() ?? '';
}
syncRegistration();
if (groupCtx) rootCtx.onGroupItemRegister(groupCtx.id.value, id.value);
});
watch(() => [props.value, props.textValue, isDisabled.value], () => {
if (props.textValue) textValue.value = props.textValue;
syncRegistration();
});
onBeforeUnmount(() => {
rootCtx.onItemUnregister(id.value);
if (groupCtx) rootCtx.onGroupItemUnregister(groupCtx.id.value, id.value);
if (rootCtx.selectedValueId.value === id.value) {
rootCtx.onSelectedValueChange(undefined, undefined);
}
});
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onValueChange(props.value);
if (rootCtx.resetSearchTermOnSelect.value && !rootCtx.multiple.value) {
rootCtx.onSearchTermChange('');
rootCtx.onUserInputtedChange(false);
}
}
function handlePointerMove() {
if (isDisabled.value) return;
if (rootCtx.selectedValueId.value !== id.value) {
rootCtx.onSelectedValueChange(props.value, id.value);
}
}
provideComboboxItemContext({
id,
value: props.value,
textValue,
isSelected,
isDisabled,
});
defineExpose({ id, isVisible, isHighlighted });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="id"
:as="props.as ?? 'div'"
role="option"
:aria-selected="isSelected"
:aria-disabled="isDisabled || undefined"
:data-state="isSelected ? 'checked' : 'unchecked'"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:tabindex="-1"
data-primitives-combobox-item
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :selected="isSelected" :highlighted="isHighlighted" />
</Primitive>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxItemContext } from './context';
const { as = 'span' } = defineProps<ComboboxItemIndicatorProps>();
const { forwardRef } = useForwardExpose();
const itemCtx = useComboboxItemContext();
</script>
<template>
<Primitive
v-if="itemCtx.isSelected.value"
:ref="forwardRef"
:as="as"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxGroupContext } from './context';
const { as = 'div' } = defineProps<ComboboxLabelProps>();
const { forwardRef } = useForwardExpose();
const groupCtx = useComboboxGroupContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="groupCtx.id.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PortalProps } from '../teleport';
export interface ComboboxPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../teleport';
const { to, defer, disabled } = defineProps<ComboboxPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,400 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
/** Controlled selected value. Use `v-model`. */
modelValue?: T | T[];
/** Uncontrolled initial value. */
defaultValue?: T | T[];
/** Controlled open state. Use `v-model:open`. */
open?: boolean;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Allow selecting multiple values. */
multiple?: boolean;
/** Reading direction. Falls back to `ConfigProvider`. */
dir?: Direction;
/** Disable the whole combobox. */
disabled?: boolean;
/** Mark as required for native form validation. */
required?: boolean;
/** Native input name for form submission. */
name?: string;
/** Reset the search term when the input is blurred. @default true */
resetSearchTermOnBlur?: boolean;
/** Reset the search term when a value is selected (single mode). @default true */
resetSearchTermOnSelect?: boolean;
/** Skip the built-in filter; render every item regardless of search term. */
ignoreFilter?: boolean;
/** Custom filter implementation. Overrides the default substring match. */
filterFunction?: ComboboxFilterFunction;
/** Map the current model value to the input's display value. */
displayValue?: (value: T | T[] | undefined) => string;
/** Compare values by key, or via a custom comparator. */
by?: string | ((a: T, b: T) => boolean);
}
export interface ComboboxRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T | T[] | undefined];
'update:open': [open: boolean];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { ShallowRef } from 'vue';
import type { ComboboxFilterState, ComboboxItemInfo } from './context';
import { computed, nextTick, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { useConfig, useId } from '../config-provider';
import { PopperRoot } from '../popper';
import { provideComboboxRootContext } from './context';
import { defaultFilter, valueComparator } from './utils';
defineOptions({ inheritAttrs: false });
const {
modelValue,
defaultValue,
defaultOpen = false,
multiple = false,
dir,
disabled = false,
required = false,
name,
resetSearchTermOnBlur = true,
resetSearchTermOnSelect = true,
ignoreFilter = false,
filterFunction,
displayValue,
by,
} = defineProps<ComboboxRootProps<T>>();
const emit = defineEmits<ComboboxRootEmits<T>>();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const initial = (modelValue ?? defaultValue) as T | T[] | undefined;
const localValue = shallowRef<T | T[] | undefined>(
multiple
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
: (Array.isArray(initial) ? initial[0] : initial),
);
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const searchTerm = ref('');
const isUserInputted = ref(false);
const contentId = useId(undefined, 'combobox-content');
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
const inputElement = shallowRef<HTMLInputElement | undefined>(undefined);
const contentElement = shallowRef<HTMLElement | undefined>(undefined);
const parentElement = shallowRef<HTMLElement | undefined>(undefined);
const selectedValue = shallowRef<T | undefined>(undefined) as ShallowRef<T | undefined>;
const selectedValueId = ref<string | undefined>(undefined);
const allItems = shallowRef(new Map<string, ComboboxItemInfo<T>>());
const allGroups = shallowRef(new Map<string, Set<string>>());
function onItemRegister(id: string, info: ComboboxItemInfo<T>) {
allItems.value.set(id, info);
triggerRef(allItems);
}
function onItemUnregister(id: string) {
allItems.value.delete(id);
triggerRef(allItems);
}
function onGroupRegister(groupId: string) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set());
triggerRef(allGroups);
}
}
function onGroupUnregister(groupId: string) {
allGroups.value.delete(groupId);
triggerRef(allGroups);
}
function onGroupItemRegister(groupId: string, itemId: string) {
let set = allGroups.value.get(groupId);
if (!set) {
set = new Set();
allGroups.value.set(groupId, set);
}
set.add(itemId);
triggerRef(allGroups);
}
function onGroupItemUnregister(groupId: string, itemId: string) {
const set = allGroups.value.get(groupId);
if (set) {
set.delete(itemId);
triggerRef(allGroups);
}
}
const filterRef = toRef(() => filterFunction);
const ignoreFilterRef = toRef(() => ignoreFilter);
const filterState = computed<ComboboxFilterState>(() => {
const items = allItems.value;
const groups = allGroups.value;
if (!searchTerm.value || ignoreFilterRef.value || !isUserInputted.value) {
return {
count: items.size,
items: new Set(items.keys()),
groups: new Set(groups.keys()),
};
}
const candidates: ComboboxFilterItem[] = [];
for (const [id, info] of items) candidates.push({ id, textValue: info.textValue });
const fn = filterRef.value ?? defaultFilter;
const filtered = fn(candidates, searchTerm.value);
const visibleItems = new Set<string>();
for (let i = 0; i < filtered.length; i++) visibleItems.add(filtered[i]!.id);
const visibleGroups = new Set<string>();
for (const [groupId, set] of groups) {
for (const itemId of set) {
if (visibleItems.has(itemId)) {
visibleGroups.add(groupId);
break;
}
}
}
return {
count: visibleItems.size,
items: visibleItems,
groups: visibleGroups,
};
});
function isSelected(v: T): boolean {
return valueComparator(value.value as T | T[] | undefined, v, by);
}
function commitValue(next: T | T[] | undefined) {
value.value = next;
emit('update:modelValue', next);
}
function onValueChange(v: T) {
if (multiple) {
const cur = Array.isArray(value.value) ? [...(value.value as T[])] : [];
const idx = cur.findIndex(i => valueComparator(i, v, by));
if (idx === -1) cur.push(v);
else cur.splice(idx, 1);
commitValue(cur);
inputElement.value?.focus();
}
else {
commitValue(v);
open.value = false;
}
}
function onOpenChange(next: boolean) {
open.value = next;
if (next) {
isUserInputted.value = false;
searchTerm.value = '';
nextTick(() => {
inputElement.value?.focus();
highlightSelectedOrFirst();
});
}
else {
setTimeout(() => {
if (resetSearchTermOnBlur) searchTerm.value = '';
isUserInputted.value = false;
}, 1);
}
}
function onSelectedValueChange(v: T | undefined, id?: string) {
selectedValue.value = v;
selectedValueId.value = id;
}
function getVisibleItemElements(): HTMLElement[] {
const root = contentElement.value ?? parentElement.value;
if (!root) return [];
const all = Array.from(root.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'));
const visible: HTMLElement[] = [];
const filterIds = filterState.value.items;
for (let i = 0; i < all.length; i++) {
const el = all[i]!;
if (el.dataset['disabled'] === '') continue;
const id = el.id;
if (!id || filterIds.has(id)) visible.push(el);
}
return visible;
}
function readValueFromElement(el: HTMLElement): T | undefined {
const id = el.id;
if (!id) return undefined;
return allItems.value.get(id)?.value;
}
function highlightItemById(id: string | undefined) {
if (!id) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
const info = allItems.value.get(id);
if (!info) return;
selectedValue.value = info.value;
selectedValueId.value = id;
const root = contentElement.value ?? parentElement.value;
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function highlightFirstItem() {
const els = getVisibleItemElements();
if (els.length === 0) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
highlightItemById(els[0]!.id);
}
function highlightSelectedOrFirst() {
const cur = value.value;
if (cur !== undefined && !Array.isArray(cur)) {
for (const [id, info] of allItems.value) {
if (valueComparator(cur, info.value, by) && !info.disabled) {
highlightItemById(id);
return;
}
}
}
highlightFirstItem();
}
watch(open, (isOpen) => {
if (!isOpen) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
}
});
function onSearchTermChange(v: string) {
searchTerm.value = v;
}
function onUserInputtedChange(v: boolean) {
isUserInputted.value = v;
}
provideComboboxRootContext({
modelValue: value,
onValueChange,
multiple: toRef(() => multiple),
open,
onOpenChange,
disabled: toRef(() => disabled),
dir: direction,
name: toRef(() => name),
required: toRef(() => required),
by,
isSelected,
searchTerm,
onSearchTermChange,
resetSearchTermOnBlur: toRef(() => resetSearchTermOnBlur),
resetSearchTermOnSelect: toRef(() => resetSearchTermOnSelect),
ignoreFilter: ignoreFilterRef,
filterFunction: filterRef,
displayValue: displayValue as ((v: unknown) => string) | undefined,
isUserInputted,
onUserInputtedChange,
contentId,
triggerElement,
onTriggerChange: (el) => { triggerElement.value = el; },
inputElement,
onInputChange: (el) => { inputElement.value = el; },
contentElement,
onContentChange: (el) => { contentElement.value = el; },
parentElement,
onParentChange: (el) => { parentElement.value = el; },
selectedValue,
selectedValueId,
onSelectedValueChange,
allItems,
onItemRegister,
onItemUnregister,
allGroups,
onGroupRegister,
onGroupUnregister,
onGroupItemRegister,
onGroupItemUnregister,
filterState,
getVisibleItemElements,
highlightItemById,
highlightFirstItem,
});
defineExpose({
filterState,
highlightFirstItem,
highlightItemById,
// Avoid unused warnings — surfaced for advanced consumers
readValueFromElement,
});
</script>
<template>
<PopperRoot>
<slot :open="open" :model-value="value" />
<input
v-if="name"
type="hidden"
:name="name"
:value="Array.isArray(value) ? JSON.stringify(value) : (value ?? '')"
:required="required"
:disabled="disabled"
aria-hidden="true"
style="display: none"
tabindex="-1"
/>
</PopperRoot>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<ComboboxSeparatorProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="separator"
aria-orientation="horizontal"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxTriggerProps extends PrimitiveProps {
/** Disable the trigger independently from the root. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { getOpenState } from './utils';
const { as = 'button', disabled = false } = defineProps<ComboboxTriggerProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
watchPostEffect(() => rootCtx.onTriggerChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onTriggerChange(undefined));
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onOpenChange(!rootCtx.open.value);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-haspopup="listbox"
aria-label="Show options"
:aria-controls="rootCtx.contentId.value"
:aria-expanded="rootCtx.open.value"
:aria-disabled="isDisabled || undefined"
:disabled="isDisabled || undefined"
:data-state="getOpenState(rootCtx.open.value)"
:data-disabled="isDisabled ? '' : undefined"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxViewportProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxContentContext } from './context';
const { as = 'div' } = defineProps<ComboboxViewportProps>();
const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useComboboxContentContext();
watchPostEffect(() => contentCtx.onViewportChange(currentElement.value));
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-combobox-viewport
style="position: relative; flex: 1 1 0%; overflow: hidden auto"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,81 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '../index';
function mountCombobox() {
const search = ref('');
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, { defaultOpen: true, multiple: false }, {
default: () => [
h(ComboboxTrigger, { id: 'trigger' }, {
default: () => h(ComboboxInput, {
id: 'input',
'onUpdate:searchTerm': (v: string) => { search.value = v; },
}),
}),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, {}, {
default: () => h(ComboboxViewport, {}, {
default: () => [
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
h(ComboboxItem, { value: 'banana', textValue: 'Banana' }, { default: () => 'Banana' }),
h(ComboboxItem, { value: 'cherry', textValue: 'Cherry' }, { default: () => 'Cherry' }),
],
}),
}),
}),
],
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), search };
}
function getLiveRegion(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-announce]');
}
describe('Combobox — filtered-results live region', () => {
it('announces "N results available." reflecting the unfiltered count on open', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const live = getLiveRegion();
expect(live).toBeTruthy();
expect(live!.getAttribute('role')).toBe('status');
expect(live!.getAttribute('aria-live')).toBe('polite');
expect(live!.textContent?.trim()).toBe('3 results available.');
wrapper.unmount();
});
it('updates the count as the search term filters items', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const input = document.querySelector<HTMLInputElement>('#input')!;
await userEvent.click(input);
await userEvent.type(input, 'app');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('1 result available.');
await userEvent.clear(input);
await userEvent.type(input, 'zz');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('0 results available.');
wrapper.unmount();
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface ComboboxItemInfo<T = AcceptableValue> {
value: T;
textValue: string;
disabled: boolean;
}
export interface ComboboxFilterState {
count: number;
items: Set<string>;
groups: Set<string>;
}
export interface ComboboxRootContext<T = AcceptableValue> {
modelValue: Ref<T | T[] | undefined>;
onValueChange: (value: T) => void;
multiple: Ref<boolean>;
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
disabled: Ref<boolean>;
dir: Ref<Direction>;
name: Ref<string | undefined>;
required: Ref<boolean>;
by?: string | ((a: T, b: T) => boolean);
isSelected: (value: T) => boolean;
searchTerm: Ref<string>;
onSearchTermChange: (value: string) => void;
resetSearchTermOnBlur: Ref<boolean>;
resetSearchTermOnSelect: Ref<boolean>;
ignoreFilter: Ref<boolean>;
filterFunction: Ref<ComboboxFilterFunction | undefined>;
displayValue?: (value: T | T[] | undefined) => string;
isUserInputted: Ref<boolean>;
onUserInputtedChange: (value: boolean) => void;
contentId: Ref<string>;
triggerElement: ShallowRef<HTMLElement | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
inputElement: ShallowRef<HTMLInputElement | undefined>;
onInputChange: (el: HTMLInputElement | undefined) => void;
contentElement: ShallowRef<HTMLElement | undefined>;
onContentChange: (el: HTMLElement | undefined) => void;
parentElement: ShallowRef<HTMLElement | undefined>;
onParentChange: (el: HTMLElement | undefined) => void;
selectedValue: ShallowRef<T | undefined>;
selectedValueId: Ref<string | undefined>;
onSelectedValueChange: (value: T | undefined, id?: string) => void;
allItems: ShallowRef<Map<string, ComboboxItemInfo<T>>>;
onItemRegister: (id: string, info: ComboboxItemInfo<T>) => void;
onItemUnregister: (id: string) => void;
allGroups: ShallowRef<Map<string, Set<string>>>;
onGroupRegister: (groupId: string) => void;
onGroupUnregister: (groupId: string) => void;
onGroupItemRegister: (groupId: string, itemId: string) => void;
onGroupItemUnregister: (groupId: string, itemId: string) => void;
filterState: ComputedRef<ComboboxFilterState>;
/** Returns visible, enabled item elements in DOM order. */
getVisibleItemElements: () => HTMLElement[];
/** Highlights an item element by its id. */
highlightItemById: (id: string | undefined) => void;
/** Highlights the first visible item. */
highlightFirstItem: () => void;
}
export interface ComboboxContentContext {
viewportElement: ShallowRef<HTMLElement | undefined>;
onViewportChange: (el: HTMLElement | undefined) => void;
position: Ref<'inline' | 'popper'>;
}
export interface ComboboxGroupContext {
id: Ref<string>;
}
export interface ComboboxItemContext<T = AcceptableValue> {
id: Ref<string>;
value: T;
textValue: Ref<string>;
isSelected: Ref<boolean>;
isDisabled: Ref<boolean>;
}
export const {
inject: useComboboxRootContext,
provide: provideComboboxRootContext,
} = useContextFactory<ComboboxRootContext<any>>('ComboboxRoot');
export const {
inject: useComboboxContentContext,
provide: provideComboboxContentContext,
} = useContextFactory<ComboboxContentContext>('ComboboxContent');
export const {
inject: useComboboxGroupContext,
provide: provideComboboxGroupContext,
} = useContextFactory<ComboboxGroupContext>('ComboboxGroup');
export const {
inject: useComboboxItemContext,
provide: provideComboboxItemContext,
} = useContextFactory<ComboboxItemContext<any>>('ComboboxItem');
+51
View File
@@ -0,0 +1,51 @@
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxArrow } from './ComboboxArrow.vue';
export { default as ComboboxCancel } from './ComboboxCancel.vue';
export { default as ComboboxContent } from './ComboboxContent.vue';
export { default as ComboboxContentImpl } from './ComboboxContentImpl.vue';
export { default as ComboboxEmpty } from './ComboboxEmpty.vue';
export { default as ComboboxGroup } from './ComboboxGroup.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxItemIndicator } from './ComboboxItemIndicator.vue';
export { default as ComboboxLabel } from './ComboboxLabel.vue';
export { default as ComboboxPortal } from './ComboboxPortal.vue';
export { default as ComboboxRoot } from './ComboboxRoot.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export {
useComboboxContentContext,
useComboboxGroupContext,
useComboboxItemContext,
useComboboxRootContext,
} from './context';
export type {
ComboboxContentContext,
ComboboxFilterState,
ComboboxGroupContext,
ComboboxItemContext,
ComboboxItemInfo,
ComboboxRootContext,
} from './context';
export type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export type { ComboboxAnchorProps } from './ComboboxAnchor.vue';
export type { ComboboxArrowProps } from './ComboboxArrow.vue';
export type { ComboboxCancelProps } from './ComboboxCancel.vue';
export type { ComboboxContentEmits, ComboboxContentProps } from './ComboboxContent.vue';
export type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type { ComboboxEmptyProps } from './ComboboxEmpty.vue';
export type { ComboboxGroupProps } from './ComboboxGroup.vue';
export type { ComboboxInputProps } from './ComboboxInput.vue';
export type { ComboboxItemIndicatorProps } from './ComboboxItemIndicator.vue';
export type { ComboboxItemProps } from './ComboboxItem.vue';
export type { ComboboxLabelProps } from './ComboboxLabel.vue';
export type { ComboboxPortalProps } from './ComboboxPortal.vue';
export type { ComboboxRootEmits, ComboboxRootProps } from './ComboboxRoot.vue';
export type { ComboboxSeparatorProps } from './ComboboxSeparator.vue';
export type { ComboboxTriggerProps } from './ComboboxTrigger.vue';
export type { ComboboxViewportProps } from './ComboboxViewport.vue';
+57
View File
@@ -0,0 +1,57 @@
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
export const SELECTION_KEYS = ['Enter', ' '];
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
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);
return (a as any)?.[by] === (b as any)?.[by];
}
export function valueComparator<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);
for (const v of value) {
if (compare(v, current, by)) return true;
}
return false;
}
export interface ComboboxFilterItem {
id: string;
textValue: string;
}
export type ComboboxFilterFunction = (
items: ComboboxFilterItem[],
searchTerm: string,
) => ComboboxFilterItem[];
export const defaultFilter: ComboboxFilterFunction = (items, searchTerm) => {
const term = searchTerm.toLowerCase();
if (!term) return items;
const out: ComboboxFilterItem[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i]!;
if (it.textValue.toLowerCase().includes(term)) out.push(it);
}
return out;
};