feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PopperAnchorProps } from '../../overlays/popper';
/**
* The element the popup is positioned against, typically wrapping the Input and Trigger.
* Acts as the Popper anchor and the boundary used for the blur-to-close heuristic.
*/
export interface ComboboxAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { PopperAnchor } from '../../overlays/popper';
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 IS the single anchor element: the consumer's class (e.g. a
flex row of Input + Trigger) and the slotted children must live on the
SAME element. A nested Primitive split them — the class landed here while
the children sat in an unstyled inner div, stacking them vertically. -->
<PopperAnchor
:ref="forwardRef"
:as="props.as ?? 'div'"
:reference="props.reference"
>
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { PopperArrowProps } from '../../overlays/popper';
/**
* An optional arrow that visually points from the popup back to the anchor. Renders only
* while the combobox is open. Place inside ComboboxContent.
*/
export type ComboboxArrowProps = PopperArrowProps;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { PopperArrow } from '../../overlays/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,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that clears the current search term and refocuses the input. Typically shown
* as an "x" inside the field while the user is typing.
*/
export interface ComboboxCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/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);
if (rootCtx.resetModelValueOnClear.value) rootCtx.clearModelValue();
}
</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,38 @@
<script lang="ts">
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
/**
* The popup listbox that holds the options. Mounts only while open (via Presence) and
* positions itself relative to the anchor. Place the Viewport, Items, and Empty inside it.
*/
export interface ComboboxContentProps extends ComboboxContentImplProps {
/** Keep the content mounted even while closed (for external animation libraries). @default false */
forceMount?: boolean;
}
export type ComboboxContentEmits = ComboboxContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../../utilities/presence';
import ComboboxContentImpl from './ComboboxContentImpl.vue';
import { useComboboxRootContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<ComboboxContentProps>();
const emit = defineEmits<ComboboxContentEmits>();
const rootCtx = useComboboxRootContext();
</script>
<template>
<Presence :present="rootCtx.open.value" :force-mount="forceMount">
<ComboboxContentImpl
v-bind="contentProps"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
>
<slot />
</ComboboxContentImpl>
</Presence>
</template>
@@ -0,0 +1,187 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
import type { FocusScopeEmits } from '../../utilities/focus-scope';
import type { PopperContentProps } from '../../overlays/popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal implementation of the content popup: wires up focus scoping, dismiss-on-outside,
* Popper positioning, and the screen-reader result announcer. Use ComboboxContent instead.
*/
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
/** Position strategy. @default 'popper' */
position?: 'inline' | 'popper';
/** Block outside pointer events. @default false */
disableOutsidePointerEvents?: boolean;
/** Lock body scroll while the content is open (reuses the shared body-scroll-lock). @default false */
bodyLock?: boolean;
/** Hide the content (`display: none`) when no items match the filter. @default false */
hideWhenEmpty?: boolean;
}
export interface ComboboxContentImplEmits {
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: FocusScopeEmits['unmountAutoFocus'];
interactOutside: DismissableLayerEmits['interactOutside'];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, shallowRef, toRef, watch, watchPostEffect } from 'vue';
import { useBodyScrollLock, useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { PopperContent } from '../../overlays/popper';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useHideOthers } from '../../internal/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));
const isEmpty = computed(() => rootCtx.filterState.value.count === 0);
// Body scroll lock is reference-counted and opt-in: acquire only while `bodyLock`
// is set, releasing as soon as it flips off or the content unmounts.
let releaseScrollLock: (() => void) | undefined;
watch(
() => props.bodyLock ?? false,
(locked) => {
if (locked && !releaseScrollLock) {
releaseScrollLock = useBodyScrollLock();
}
else if (!locked && releaseScrollLock) {
releaseScrollLock();
releaseScrollLock = undefined;
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
releaseScrollLock?.();
releaseScrollLock = undefined;
});
provideComboboxContentContext({
viewportElement,
onViewportChange: (el) => { viewportElement.value = el; },
position: toRef(() => props.position ?? 'popper'),
});
function handleEscape(event: KeyboardEvent) {
rootCtx.onOpenChange(false);
emit('escapeKeyDown', event);
}
// Interactions within the anchor (input, trigger, cancel button, padding) must not
// dismiss the popup — e.g. the root focuses the input right after opening, which
// fires a focus-outside from the content layer's perspective.
function handleInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
const target = event.target as Element | null;
const parent = rootCtx.parentElement.value;
const input = rootCtx.inputElement.value;
const trigger = rootCtx.triggerElement.value;
if (target && (parent?.contains(target) || input?.contains(target) || trigger?.contains(target))) {
event.preventDefault();
}
emit('interactOutside', event);
}
function handlePointerDownOutside(event: PointerEvent | MouseEvent) {
emit('pointerDownOutside', event);
}
function handleFocusOutside(event: FocusEvent) {
emit('focusOutside', event);
}
function handleCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
}
const dataEmpty = computed(() => (isEmpty.value ? '' : undefined));
const hiddenStyle = computed(() =>
(props.hideWhenEmpty && isEmpty.value) ? ({ display: 'none' } as const) : undefined,
);
</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"
@interact-outside="handleInteractOutside"
@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-empty="dataEmpty"
:style="hiddenStyle"
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-empty="dataEmpty"
:style="hiddenStyle"
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,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Fallback content shown when the current search term matches no items. Renders only when
* the filtered count is zero, unless `always` is set.
*/
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 '../../internal/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,50 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Groups related items under a shared ComboboxLabel. Hides itself automatically when none
* of its items survive the current filter.
*/
export interface ComboboxGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideComboboxGroupContext, useComboboxRootContext } from './context';
const { as = 'div' } = defineProps<ComboboxGroupProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const id = useId(undefined, 'combobox-group');
const labelId = ref<string | undefined>(undefined);
const isVisible = computed(() => rootCtx.filterState.value.groups.has(id.value));
onMounted(() => rootCtx.onGroupRegister(id.value));
onBeforeUnmount(() => rootCtx.onGroupUnregister(id.value));
provideComboboxGroupContext({
id,
labelId,
registerLabel: (labelElId) => { labelId.value = labelElId; },
unregisterLabel: () => { labelId.value = undefined; },
});
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:as="as"
role="group"
:aria-labelledby="labelId"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,254 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The text field users type into to filter options. Owns the search term, ARIA combobox
* semantics, and keyboard navigation (arrows, Home/End, Enter to select, Escape to close).
*/
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. Combined (OR) with the Root-level `openOnFocus`. */
openOnFocus?: boolean;
/** Open the combobox when the input is clicked. Combined (OR) with the Root-level `openOnClick`. */
openOnClick?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useComboboxRootContext } from './context';
import { INPUT_OPEN_KEYS } from './utils';
const props = defineProps<ComboboxInputProps>();
const {
as = 'input',
disabled = false,
autoFocus = false,
} = props;
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
// Opens when EITHER the per-input prop or the Root-level default opts in. (Boolean
// props coerce an absent value to `false`, so a nullish-merge can't distinguish
// "unset" from "false" — OR composition is the correct, back-compatible semantic.)
const shouldOpenOnFocus = computed(() => props.openOnFocus || rootCtx.openOnFocus.value);
const shouldOpenOnClick = computed(() => props.openOnClick || rootCtx.openOnClick.value);
// IME composition guard: while composing, Enter confirms the candidate and must
// not commit a combobox selection.
let isComposing = false;
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(event: KeyboardEvent) {
const id = rootCtx.selectedValueId.value;
if (id) {
const info = rootCtx.allItems.value.get(id);
// Route through the highlighted item's cancelable `select` flow so keyboard
// Enter fires the same `select` event (and honours preventDefault) as a click.
if (info) return info.select(event);
}
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 && INPUT_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':
// While composing an IME candidate, Enter confirms the candidate — it must
// not select a combobox item.
if (isComposing) break;
if (commitHighlighted(event)) 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 (shouldOpenOnFocus.value && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleClick() {
if (shouldOpenOnClick.value && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
}
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"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,169 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './utils';
/**
* A single selectable option in the list. Registers itself for filtering and keyboard
* navigation, toggles selection on click, and highlights on pointer move.
*/
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;
}
/**
* Detail of the cancelable `select` event. Call `preventDefault()` on the event
* (or read `defaultPrevented`) to block the default selection/auto-close — the
* interception point for create-on-enter and custom selection flows.
*/
export interface ComboboxItemSelectEvent<T extends AcceptableValue = AcceptableValue> extends CustomEvent {
detail: { value: T; originalEvent: PointerEvent | KeyboardEvent | MouseEvent };
}
export interface ComboboxItemEmits<T extends AcceptableValue = AcceptableValue> {
/** Cancelable. Fires before selection commits; `preventDefault()` blocks it. */
select: [event: ComboboxItemSelectEvent<T>];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { ComboboxGroupContext } from './context';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxItemProps<T>>();
const emit = defineEmits<ComboboxItemEmits<T>>();
if (props.value === ('' as unknown as T)) {
throw new Error(
'[ComboboxItem] `value` must not be an empty string — the empty string is reserved for the cleared selection state.',
);
}
const rootCtx = useComboboxRootContext();
let groupCtx: ComboboxGroupContext | null = null;
try {
groupCtx = useComboboxGroupContext();
}
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));
// defineExpose must run BEFORE useForwardExpose: the composable absorbs a prior
// expose() into the forwarded object, while a later one would trigger Vue's
// "expose() should be called only once" warning and clobber the forwarded API.
defineExpose({ id, isVisible, isHighlighted });
const { forwardRef, currentElement } = useForwardExpose();
function syncRegistration() {
rootCtx.onItemRegister(id.value, {
value: props.value,
textValue: textValue.value,
disabled: isDisabled.value,
select: trySelect,
});
}
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);
}
});
/**
* Runs the selection flow guarded by a cancelable `select` event. Consumers can
* call `preventDefault()` on the event to block selection (e.g. create-on-enter).
* Returns `true` when selection committed.
*/
function trySelect(originalEvent: PointerEvent | KeyboardEvent | MouseEvent): boolean {
if (isDisabled.value) return false;
const selectEvent = new CustomEvent('combobox-item-select', {
bubbles: false,
cancelable: true,
detail: { value: props.value, originalEvent },
}) as ComboboxItemSelectEvent<T>;
emit('select', selectEvent);
if (selectEvent.defaultPrevented) return false;
rootCtx.onValueChange(props.value);
if (rootCtx.resetSearchTermOnSelect.value && !rootCtx.multiple.value) {
rootCtx.onSearchTermChange('');
rootCtx.onUserInputtedChange(false);
}
return true;
}
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
trySelect(event);
}
function handlePointerMove() {
if (isDisabled.value || !rootCtx.highlightOnHover.value) return;
if (rootCtx.selectedValueId.value !== id.value) {
rootCtx.onSelectedValueChange(props.value, id.value);
}
}
provideComboboxItemContext({
id,
value: props.value,
textValue,
isSelected,
isDisabled,
});
</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,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Marks the selected state of its parent ComboboxItem, e.g. a checkmark. Renders only when
* that item is selected.
*/
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/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,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An accessible label for a ComboboxGroup. Its id is referenced by the group's
* `aria-labelledby`, so place it as a direct child of ComboboxGroup.
*/
export interface ComboboxLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { useComboboxGroupContext } from './context';
const { as = 'div' } = defineProps<ComboboxLabelProps>();
const { forwardRef } = useForwardExpose();
const groupCtx = useComboboxGroupContext();
const id = useId(undefined, 'combobox-group-label');
groupCtx.registerLabel(id.value);
onBeforeUnmount(() => groupCtx.unregisterLabel());
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PortalProps } from '../../utilities/teleport';
/**
* Teleports the ComboboxContent into another part of the DOM (defaults to `body`) to escape
* overflow/stacking-context clipping. Wrap ComboboxContent with it.
*/
export interface ComboboxPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../../utilities/teleport';
const { to, defer, disabled } = defineProps<ComboboxPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,433 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
/**
* An autocomplete / typeahead input that filters a list of options as the user types.
* Combine a text input with a popup listbox, supporting single or multiple selection,
* custom filtering, and full keyboard navigation. Reach for it when users must pick from
* a large or searchable set of options; for a small fixed list a plain Select is simpler.
* Wraps everything in a Popper and provides shared state to every other Combobox part.
*/
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
/** Controlled selected value. Use `v-model`. */
modelValue?: T | T[];
/** Uncontrolled initial value. */
defaultValue?: T | T[];
/** 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;
/** Clear the model value (to `undefined`, or `[]` when `multiple`) when the search is cleared via ComboboxCancel. @default false */
resetModelValueOnClear?: boolean;
/** Open the combobox when the input is focused. Root-level default for every ComboboxInput; a per-input prop still overrides it. @default false */
openOnFocus?: boolean;
/** Open the combobox when the input is clicked. Root-level default for every ComboboxInput; a per-input prop still overrides it. @default false */
openOnClick?: boolean;
/** Highlight items on pointer hover. Set `false` to opt out of pointermove highlighting. @default true */
highlightOnHover?: 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];
/** Fired whenever the highlighted (active-descendant) item changes; payload is `undefined` when nothing is highlighted. */
highlight: [payload: { value: T; ref: HTMLElement | undefined } | undefined];
}
</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 '../../utilities/config-provider';
import { PopperRoot } from '../../overlays/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,
resetModelValueOnClear = false,
openOnFocus = false,
openOnClick = false,
highlightOnHover = true,
ignoreFilter = false,
filterFunction,
displayValue,
by,
} = defineProps<ComboboxRootProps<T>>();
const emit = defineEmits<ComboboxRootEmits<T>>();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
/** Controlled open state. Use `v-model:open`. */
const open = defineModel<boolean>('open', { default: false });
if (defaultOpen && !open.value) open.value = true;
/** Controlled selected value. Use `v-model`. `undefined` from the parent means "no selection". */
const value = defineModel<T | T[] | undefined>('modelValue');
if (modelValue === undefined && defaultValue !== undefined) {
value.value = multiple
? (Array.isArray(defaultValue) ? defaultValue.slice() : [defaultValue]) as T[]
: (Array.isArray(defaultValue) ? defaultValue[0] : defaultValue) as T;
}
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;
}
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) {
// When the open was initiated by typing, ComboboxInput already set
// searchTerm/isUserInputted — resetting here would wipe the first keystroke.
if (!isUserInputted.value) 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 clearModelValue() {
commitValue(multiple ? ([] as T[]) : undefined);
}
// `selectedValueId` is the single source of truth for the active-descendant, so
// emitting `highlight` off its change covers keyboard, hover, and programmatic
// highlight paths without instrumenting each call site.
watch(selectedValueId, (id) => {
if (id === undefined) {
emit('highlight', undefined);
return;
}
const value = selectedValue.value;
if (value === undefined) {
emit('highlight', undefined);
return;
}
const root = contentElement.value ?? parentElement.value;
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`) ?? undefined;
emit('highlight', { value, ref: el });
});
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),
resetModelValueOnClear: toRef(() => resetModelValueOnClear),
openOnFocus: toRef(() => openOnFocus),
openOnClick: toRef(() => openOnClick),
highlightOnHover: toRef(() => highlightOnHover),
ignoreFilter: ignoreFilterRef,
filterFunction: filterRef,
displayValue: displayValue as ((v: unknown) => string) | undefined,
clearModelValue,
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,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A purely visual divider between items or groups inside the popup. Decorative and hidden
* from assistive technology.
*/
export interface ComboboxSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/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,57 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button, usually a chevron next to the input, that toggles the popup open and closed.
* Optional: typing in the Input also opens the list.
*/
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 '../../internal/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,49 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The scrollable region inside ComboboxContent that holds the items. Provides the overflow
* container that keeps the highlighted item scrolled into view.
*/
export interface ComboboxViewportProps extends PrimitiveProps {
/**
* CSP `nonce` applied to the injected scrollbar-hiding `<style>` tag. Falls
* back to the `ConfigProvider` nonce when omitted.
*/
nonce?: string;
}
</script>
<script setup lang="ts">
import { toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useNonce } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { useComboboxContentContext } from './context';
const props = defineProps<ComboboxViewportProps>();
const { as = 'div' } = props;
const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useComboboxContentContext();
const nonce = useNonce(toRef(() => props.nonce));
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>
<Primitive as="style" :nonce="nonce">
[data-primitives-combobox-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
[data-primitives-combobox-viewport]::-webkit-scrollbar{display:none;}
</Primitive>
</template>
@@ -0,0 +1,433 @@
import type { Ref } from 'vue';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxAnchor,
ComboboxCancel,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
accentInsensitiveContains,
} from '../index';
interface MountOptions {
defaultOpen?: boolean;
multiple?: boolean;
highlightOnHover?: boolean;
resetModelValueOnClear?: boolean;
openOnFocus?: boolean;
openOnClick?: boolean;
model?: Ref<unknown>;
onSelect?: (e: CustomEvent) => void;
onHighlight?: (p: unknown) => void;
items?: Array<{ value: string; textValue: string }>;
}
const DEFAULT_ITEMS = [
{ value: 'apple', textValue: 'Apple' },
{ value: 'banana', textValue: 'Banana' },
{ value: 'cherry', textValue: 'Cherry' },
];
function mountCombobox(options: MountOptions = {}) {
const items = options.items ?? DEFAULT_ITEMS;
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, {
defaultOpen: options.defaultOpen ?? false,
multiple: options.multiple ?? false,
...(options.highlightOnHover !== undefined ? { highlightOnHover: options.highlightOnHover } : {}),
...(options.resetModelValueOnClear !== undefined ? { resetModelValueOnClear: options.resetModelValueOnClear } : {}),
...(options.openOnFocus !== undefined ? { openOnFocus: options.openOnFocus } : {}),
...(options.openOnClick !== undefined ? { openOnClick: options.openOnClick } : {}),
...(options.onHighlight ? { onHighlight: options.onHighlight } : {}),
...(options.model
? {
modelValue: options.model.value,
'onUpdate:modelValue': (v: unknown) => { options.model!.value = v; },
}
: {}),
}, {
default: () => [
h(ComboboxAnchor, { id: 'anchor' }, {
default: () => [
h(ComboboxInput, { id: 'input' }),
h(ComboboxCancel, { id: 'cancel' }, { default: () => 'x' }),
h(ComboboxTrigger, { id: 'trigger' }, { default: () => 'v' }),
],
}),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, {}, {
default: () => h(ComboboxViewport, {}, {
default: () => items.map(it => h(ComboboxItem, {
key: it.value,
value: it.value,
textValue: it.textValue,
...(options.onSelect ? { onSelect: options.onSelect } : {}),
}, { default: () => it.textValue })),
}),
}),
}),
],
}),
});
return mount(Harness, { attachTo: document.body });
}
function getInput(): HTMLInputElement {
return document.querySelector<HTMLInputElement>('#input')!;
}
function getItem(text: string): HTMLElement {
return Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.find(el => el.textContent?.includes(text))!;
}
function getContent(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-content]');
}
async function flush(times = 4) {
for (let i = 0; i < times; i++) await nextTick();
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('Combobox — cancelable item select event', () => {
it('fires select on click and commits when not prevented', async () => {
const model = ref<unknown>(undefined);
const onSelect = vi.fn();
const w = mountCombobox({ defaultOpen: true, model, onSelect });
await flush();
await userEvent.click(getItem('Banana'));
await flush();
expect(onSelect).toHaveBeenCalledTimes(1);
const event = onSelect.mock.calls[0]![0] as CustomEvent;
expect(event.detail.value).toBe('banana');
expect(model.value).toBe('banana');
w.unmount();
});
it('blocks selection when the consumer calls preventDefault', async () => {
const model = ref<unknown>(undefined);
const onSelect = (e: CustomEvent) => e.preventDefault();
const w = mountCombobox({ defaultOpen: true, model, onSelect });
await flush();
await userEvent.click(getItem('Banana'));
await flush();
expect(model.value).toBeUndefined();
// List stays open because selection was blocked (single-select would close on commit).
expect(getContent()).toBeTruthy();
w.unmount();
});
it('fires the same cancelable select on keyboard Enter', async () => {
const model = ref<unknown>(undefined);
const onSelect = vi.fn((e: CustomEvent) => e.preventDefault());
const w = mountCombobox({ model, onSelect });
await flush();
const input = getInput();
await userEvent.click(input);
await userEvent.keyboard('{ArrowDown}');
await flush();
await userEvent.keyboard('{Enter}');
await flush();
expect(onSelect).toHaveBeenCalled();
// preventDefault blocked the commit.
expect(model.value).toBeUndefined();
w.unmount();
});
});
describe('Combobox — empty-string item value guard', () => {
it('throws when an item has an empty-string value', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => mountCombobox({
defaultOpen: true,
items: [{ value: '', textValue: 'Empty' }],
})).toThrow(/empty string/i);
spy.mockRestore();
});
});
describe('Combobox — resetModelValueOnClear', () => {
it('clears the model value when Cancel is clicked and the prop is set', async () => {
const model = ref<unknown>('banana');
const w = mountCombobox({ defaultOpen: true, model, resetModelValueOnClear: true });
await flush();
await userEvent.click(document.querySelector('#cancel')!);
await flush();
expect(model.value).toBeUndefined();
w.unmount();
});
it('leaves the model value intact by default', async () => {
const model = ref<unknown>('banana');
const w = mountCombobox({ defaultOpen: true, model });
await flush();
await userEvent.click(document.querySelector('#cancel')!);
await flush();
expect(model.value).toBe('banana');
w.unmount();
});
it('resets to an empty array in multiple mode', async () => {
const model = ref<unknown>(['banana']);
const w = mountCombobox({ defaultOpen: true, multiple: true, model, resetModelValueOnClear: true });
await flush();
await userEvent.click(document.querySelector('#cancel')!);
await flush();
expect(model.value).toEqual([]);
w.unmount();
});
});
describe('Combobox — highlightOnHover opt-out', () => {
it('does not highlight on pointermove when highlightOnHover is false', async () => {
const w = mountCombobox({ defaultOpen: true, highlightOnHover: false });
await flush();
const cherry = getItem('Cherry');
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
await flush();
expect(cherry.getAttribute('data-highlighted')).toBeNull();
w.unmount();
});
it('highlights on pointermove by default', async () => {
const w = mountCombobox({ defaultOpen: true });
await flush();
const cherry = getItem('Cherry');
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
await flush();
expect(cherry.getAttribute('data-highlighted')).toBe('');
w.unmount();
});
});
describe('Combobox — highlight emit', () => {
it('emits highlight with the active item when navigation moves', async () => {
const onHighlight = vi.fn();
const w = mountCombobox({ onHighlight });
await flush();
const input = getInput();
await userEvent.click(input);
await userEvent.keyboard('{ArrowDown}');
await flush();
const lastPayload = onHighlight.mock.calls.at(-1)?.[0];
expect(lastPayload).toBeTruthy();
expect(lastPayload.value).toBe('apple');
w.unmount();
});
it('emits highlight undefined when the list closes', async () => {
const onHighlight = vi.fn();
const w = mountCombobox({ defaultOpen: true, onHighlight });
await flush();
const cherry = getItem('Cherry');
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
await flush();
onHighlight.mockClear();
await userEvent.keyboard('{Escape}');
await flush();
expect(onHighlight).toHaveBeenCalledWith(undefined);
w.unmount();
});
});
describe('Combobox — accent-insensitive default filter', () => {
it('matches accented text against an unaccented search term', () => {
expect(accentInsensitiveContains('Café', 'cafe')).toBe(true);
expect(accentInsensitiveContains('Crème brûlée', 'creme')).toBe(true);
expect(accentInsensitiveContains('Apple', 'xyz')).toBe(false);
});
it('filters list items ignoring diacritics', async () => {
const w = mountCombobox({
items: [
{ value: 'cafe', textValue: 'Café' },
{ value: 'tea', textValue: 'Tea' },
],
});
await flush();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'cafe');
await flush();
const visible = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.filter(el => el.style.display !== 'none')
.map(el => el.textContent?.trim());
expect(visible).toEqual(['Café']);
w.unmount();
});
});
describe('Combobox — IME composition guard', () => {
it('does not select an item when Enter fires during composition', async () => {
const model = ref<unknown>(undefined);
const w = mountCombobox({ model });
await flush();
const input = getInput();
await userEvent.click(input);
await userEvent.keyboard('{ArrowDown}');
await flush();
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flush();
expect(model.value).toBeUndefined();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flush();
expect(model.value).toBe('apple');
w.unmount();
});
});
describe('Combobox — Root-level openOnFocus / openOnClick', () => {
it('opens on input focus when Root openOnFocus is set', async () => {
const w = mountCombobox({ openOnFocus: true });
await flush();
getInput().focus();
await flush();
expect(getContent()).toBeTruthy();
w.unmount();
});
it('opens on input click when Root openOnClick is set', async () => {
const w = mountCombobox({ openOnClick: true });
await flush();
await userEvent.click(getInput());
await flush();
expect(getContent()).toBeTruthy();
w.unmount();
});
});
describe('Combobox — group label relationship', () => {
function mountWithGroup(withLabel: boolean) {
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, { defaultOpen: true }, {
default: () => [
h(ComboboxAnchor, {}, { default: () => h(ComboboxInput, { id: 'input' }) }),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, {}, {
default: () => h(ComboboxViewport, {}, {
default: () => h(ComboboxGroup, { id: 'group' }, {
default: () => [
...(withLabel ? [h(ComboboxLabel, { class: 'group-label' }, { default: () => 'Fruits' })] : []),
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
],
}),
}),
}),
}),
],
}),
});
return mount(Harness, { attachTo: document.body });
}
it('points aria-labelledby at the rendered label id', async () => {
const w = mountWithGroup(true);
await flush();
const group = document.querySelector('#group')!;
const label = document.querySelector('.group-label')!;
const labelledby = group.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
expect(label.id).toBeTruthy();
expect(labelledby).toBe(label.id);
w.unmount();
});
it('omits aria-labelledby when no label is rendered (no dangling id)', async () => {
const w = mountWithGroup(false);
await flush();
const group = document.querySelector('#group')!;
expect(group.getAttribute('aria-labelledby')).toBeNull();
w.unmount();
});
});
describe('Combobox — hideWhenEmpty + data-empty', () => {
function mountHideWhenEmpty() {
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, { defaultOpen: true }, {
default: () => [
h(ComboboxAnchor, {}, { default: () => h(ComboboxInput, { id: 'input' }) }),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, { hideWhenEmpty: true }, {
default: () => h(ComboboxViewport, {}, {
default: () => DEFAULT_ITEMS.map(it => h(ComboboxItem, {
key: it.value,
value: it.value,
textValue: it.textValue,
}, { default: () => it.textValue })),
}),
}),
}),
],
}),
});
return mount(Harness, { attachTo: document.body });
}
it('marks the content data-empty and hides it when no items match', async () => {
const w = mountHideWhenEmpty();
await flush();
const content = getContent()!;
expect(content.getAttribute('data-empty')).toBeNull();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'zzz');
await flush();
expect(content.getAttribute('data-empty')).toBe('');
expect(content.style.display).toBe('none');
w.unmount();
});
});
@@ -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();
});
});
@@ -0,0 +1,228 @@
import type { Ref } from 'vue';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxAnchor,
ComboboxCancel,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '../index';
interface MountOptions {
defaultOpen?: boolean;
model?: Ref<string | undefined>;
}
function mountCombobox(options: MountOptions = {}) {
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, {
defaultOpen: options.defaultOpen ?? false,
...(options.model
? {
modelValue: options.model.value,
'onUpdate:modelValue': (v: unknown) => { options.model!.value = v as string | undefined; },
}
: {}),
}, {
default: () => [
h(ComboboxAnchor, { id: 'anchor' }, {
default: () => [
h(ComboboxInput, { id: 'input' }),
h(ComboboxCancel, { id: 'cancel' }, { default: () => 'x' }),
h(ComboboxTrigger, { id: 'trigger' }, { default: () => '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 mount(Harness, { attachTo: document.body });
}
function getListbox(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-content]');
}
function getInput(): HTMLInputElement {
return document.querySelector<HTMLInputElement>('#input')!;
}
function visibleItemTexts(): string[] {
return Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.filter(el => el.style.display !== 'none')
.map(el => el.textContent?.trim() ?? '');
}
async function flush(times = 3) {
for (let i = 0; i < times; i++) await nextTick();
}
describe('Combobox — open / dismiss / filtering', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('stays open and focuses the input after clicking the trigger', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
// The popup must survive the input auto-focus (focus lands in the anchor,
// outside the content layer) instead of dismissing itself immediately.
expect(getListbox()).toBeTruthy();
expect(document.activeElement).toBe(getInput());
w.unmount();
});
it('keeps the popup open and clears the search when the cancel button is clicked', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'ban');
await flush();
expect(getListbox()).toBeTruthy();
expect(visibleItemTexts()).toEqual(['Banana']);
await userEvent.click(document.querySelector('#cancel')!);
await flush();
expect(getListbox()).toBeTruthy();
expect(input.value).toBe('');
expect(visibleItemTexts()).toEqual(['Apple', 'Banana', 'Cherry']);
w.unmount();
});
it('closes on outside pointerdown', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
expect(getListbox()).toBeTruthy();
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
await flush();
expect(getListbox()).toBeNull();
w.unmount();
});
it('preserves the first keystroke when typing opens the combobox', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'b');
await flush();
expect(getListbox()).toBeTruthy();
expect(input.value).toBe('b');
expect(visibleItemTexts()).toEqual(['Banana']);
w.unmount();
});
it('lets Space type into a closed input instead of swallowing it', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, ' ');
await flush();
expect(input.value).toBe(' ');
// Typing (any printable character) still opens the list.
expect(getListbox()).toBeTruthy();
w.unmount();
});
it('does not open on caret keys (Home/End/PageDown) while closed', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(getInput());
await userEvent.keyboard('{Home}{End}{PageDown}');
await flush();
expect(getListbox()).toBeNull();
w.unmount();
});
it('opens on ArrowDown while closed', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(getInput());
await userEvent.keyboard('{ArrowDown}');
await flush();
expect(getListbox()).toBeTruthy();
w.unmount();
});
it('clears the selection when the parent clears v-model', async () => {
const model = ref<string | undefined>(undefined);
const w = mountCombobox({ model });
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
const banana = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.find(el => el.textContent?.includes('Banana'))!;
await userEvent.click(banana);
await flush();
// Let the close-path setTimeout(1) reset isUserInputted before asserting.
await new Promise(resolve => setTimeout(resolve, 10));
expect(model.value).toBe('banana');
expect(getInput().value).toBe('banana');
model.value = undefined;
await flush();
expect(getInput().value).toBe('');
await userEvent.click(document.querySelector('#trigger')!);
await flush();
const bananaReopened = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.find(el => el.textContent?.includes('Banana'))!;
expect(bananaReopened.getAttribute('aria-selected')).toBe('false');
w.unmount();
});
it('does not warn "expose() should be called only once" when mounting items', async () => {
const warn = vi.spyOn(console, 'warn');
const w = mountCombobox({ defaultOpen: true });
await flush();
const exposeWarnings = warn.mock.calls.filter(args =>
args.some(arg => typeof arg === 'string' && arg.includes('expose() should be called only once')),
);
expect(exposeWarnings).toEqual([]);
w.unmount();
warn.mockRestore();
});
});
@@ -0,0 +1,139 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue, ComboboxFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface ComboboxItemInfo<T = AcceptableValue> {
value: T;
textValue: string;
disabled: boolean;
/**
* Runs the item's cancelable `select` flow (fires the `select` event, commits
* unless prevented). Lets keyboard Enter on the Input go through the same
* interception point as a click. Returns whether selection committed.
*/
select: (originalEvent: KeyboardEvent | PointerEvent | MouseEvent) => 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>;
resetModelValueOnClear: Ref<boolean>;
openOnFocus: Ref<boolean>;
openOnClick: Ref<boolean>;
highlightOnHover: Ref<boolean>;
ignoreFilter: Ref<boolean>;
filterFunction: Ref<ComboboxFilterFunction | undefined>;
displayValue?: (value: T | T[] | undefined) => string;
/** Clears the current selection (resets the model). */
clearModelValue: () => void;
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>;
/**
* The id of the rendered `ComboboxLabel`, or `undefined` until one mounts.
* The group only points `aria-labelledby` at it once it exists, avoiding a
* dangling reference when no label is rendered.
*/
labelId: Ref<string | undefined>;
registerLabel: (id: string) => void;
unregisterLabel: () => void;
}
export interface ComboboxItemContext<T = AcceptableValue> {
id: Ref<string>;
value: T;
textValue: Ref<string>;
isSelected: Ref<boolean>;
isDisabled: Ref<boolean>;
}
// `any` (not `AcceptableValue`): the generic `ComboboxRoot` provides a
// `ComboboxRootContext<T>` whose contravariant callbacks ((value: T) => void)
// are not assignable to the concrete `AcceptableValue` instantiation. `any` is
// the type-erasure boundary that lets the generic root provide and arbitrary
// `ComboboxItem<T>` consumers inject without per-component casts.
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');
// `any` for the same generic type-erasure reason as the root context above:
// `ComboboxItem<T>` provides `ComboboxItemContext<T>`, injected generically.
export const {
inject: useComboboxItemContext,
provide: provideComboboxItemContext,
} = useContextFactory<ComboboxItemContext<any>>('ComboboxItem');
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ComboboxAnchor,
ComboboxCancel,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '@robonen/primitives';
interface Framework {
value: string;
label: string;
group: 'JavaScript' | 'Native';
}
const frameworks: Framework[] = [
{ value: 'vue', label: 'Vue', group: 'JavaScript' },
{ value: 'react', label: 'React', group: 'JavaScript' },
{ value: 'svelte', label: 'Svelte', group: 'JavaScript' },
{ value: 'solid', label: 'Solid', group: 'JavaScript' },
{ value: 'angular', label: 'Angular', group: 'JavaScript' },
{ value: 'swiftui', label: 'SwiftUI', group: 'Native' },
{ value: 'compose', label: 'Jetpack Compose', group: 'Native' },
{ value: 'flutter', label: 'Flutter', group: 'Native' },
];
const selected = ref<string>();
function labelFor(value: string | undefined) {
return frameworks.find(f => f.value === value)?.label ?? '';
}
const groups = ['JavaScript', 'Native'] as const;
</script>
<template>
<div class="flex w-full max-w-xs flex-col gap-3">
<ComboboxRoot
v-model="selected"
:display-value="labelFor"
class="relative"
>
<ComboboxAnchor
class="flex items-center gap-1 rounded-lg border border-border bg-bg-inset px-2 py-1.5 focus-within:border-accent focus-within:ring-2 focus-within:ring-ring"
>
<ComboboxInput
placeholder="Search a framework..."
open-on-click
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-fg outline-none placeholder:text-fg-subtle"
/>
<ComboboxCancel
class="grid size-5 place-items-center rounded text-fg-subtle hover:bg-bg-subtle hover:text-fg"
>
<span aria-hidden="true" class="text-xs"></span>
</ComboboxCancel>
<ComboboxTrigger
class="grid size-5 place-items-center rounded text-fg-muted hover:bg-bg-subtle hover:text-fg data-[state=open]:rotate-180"
>
<span aria-hidden="true" class="text-xs"></span>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
:side-offset="6"
class="z-50 w-(--popper-anchor-width) overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-lg"
>
<ComboboxViewport class="max-h-60 p-1">
<ComboboxEmpty class="px-3 py-6 text-center text-sm text-fg-subtle">
No frameworks found.
</ComboboxEmpty>
<ComboboxGroup
v-for="group in groups"
:key="group"
class="mb-1 last:mb-0"
>
<ComboboxLabel
class="demo-label px-2 py-1"
>
{{ group }}
</ComboboxLabel>
<ComboboxItem
v-for="framework in frameworks.filter(f => f.group === group)"
:key="framework.value"
:value="framework.value"
:text-value="framework.label"
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-fg outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-fg data-[disabled]:opacity-50"
>
<span>{{ framework.label }}</span>
<ComboboxItemIndicator>
<span aria-hidden="true"></span>
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxGroup>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<p class="text-sm text-fg-muted">
Selected:
<span class="font-medium text-fg">{{ selected ? labelFor(selected) : 'none' }}</span>
</p>
</div>
</template>
@@ -0,0 +1,52 @@
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 { accentInsensitiveContains } from './utils';
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 { ComboboxItemEmits, ComboboxItemProps, ComboboxItemSelectEvent } 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';
@@ -0,0 +1,54 @@
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
// The input is a text field: Space must type a space and Home/End/Page* must move
// the caret, so only the arrow keys open a closed list (typing opens it via input).
export const INPUT_OPEN_KEYS = ['ArrowDown', 'ArrowUp'];
export const SELECTION_KEYS = ['Enter', ' '];
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
// `valueComparator` is listbox's `includes` under a combobox-specific name —
// shared from ../utils/compare-values to avoid duplicating the comparison logic.
export { includes as valueComparator } from '../../internal/utils/compare-values';
export interface ComboboxFilterItem {
id: string;
textValue: string;
}
export type ComboboxFilterFunction = (
items: ComboboxFilterItem[],
searchTerm: string,
) => ComboboxFilterItem[];
/**
* Substring match that ignores case AND diacritics via Unicode canonical
* decomposition (`café` matches `cafe`), using only native `String.normalize`
* — no `Intl`/locale dependency, deterministic across runtimes.
*/
function foldAccents(value: string): string {
// NFD splits accented characters into base + combining marks; \p{M} (Unicode
// Mark) strips those marks, leaving the bare letters.
return value.normalize('NFD').replaceAll(/\p{M}/gu, '').toLowerCase();
}
/**
* Accent/diacritic-insensitive substring match. Reused by {@link defaultFilter}
* and exposed so a custom `filterFunction` can opt into the same folding.
*/
export function accentInsensitiveContains(haystack: string, needle: string): boolean {
return foldAccents(haystack).includes(foldAccents(needle));
}
export const defaultFilter: ComboboxFilterFunction = (items, searchTerm) => {
if (!searchTerm) return items;
const out: ComboboxFilterItem[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i]!;
if (accentInsensitiveContains(it.textValue, searchTerm)) out.push(it);
}
return out;
};