Files
tools/vue/primitives/src/combobox/ComboboxRoot.vue
T
robonen 626fbc70d8 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.
2026-06-07 16:29:56 +07:00

401 lines
11 KiB
Vue

<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>