@zag-js/combobox
Version:
Core logic for the combobox widget implemented as a state machine
1,463 lines (1,460 loc) • 52.5 kB
JavaScript
import { createAnatomy } from '@zag-js/anatomy';
import { ListCollection } from '@zag-js/collection';
import { raf, setCaretToEnd, nextTick, scrollIntoView, observeAttributes, clickIfLink, query, dataAttr, ariaAttr, isDownloadingEvent, isOpeningInNewTab, isContextMenuEvent, isLeftClick, getEventKey, isComposingEvent, isAnchorElement } from '@zag-js/dom-query';
import { getPlacement, getPlacementStyles } from '@zag-js/popper';
import { match, remove, addOrRemove, isBoolean, isEqual, createSplitProps, ensure } from '@zag-js/utils';
import { ariaHidden } from '@zag-js/aria-hidden';
import { setup } from '@zag-js/core';
import { trackDismissableElement } from '@zag-js/dismissable';
import { createProps } from '@zag-js/types';
// src/combobox.anatomy.ts
var anatomy = createAnatomy("combobox").parts(
"root",
"clearTrigger",
"content",
"control",
"input",
"item",
"itemGroup",
"itemGroupLabel",
"itemIndicator",
"itemText",
"label",
"list",
"positioner",
"trigger"
);
var parts = anatomy.build();
var collection = (options) => {
return new ListCollection(options);
};
collection.empty = () => {
return new ListCollection({ items: [] });
};
var getRootId = (ctx) => ctx.ids?.root ?? `combobox:${ctx.id}`;
var getLabelId = (ctx) => ctx.ids?.label ?? `combobox:${ctx.id}:label`;
var getControlId = (ctx) => ctx.ids?.control ?? `combobox:${ctx.id}:control`;
var getInputId = (ctx) => ctx.ids?.input ?? `combobox:${ctx.id}:input`;
var getContentId = (ctx) => ctx.ids?.content ?? `combobox:${ctx.id}:content`;
var getPositionerId = (ctx) => ctx.ids?.positioner ?? `combobox:${ctx.id}:popper`;
var getTriggerId = (ctx) => ctx.ids?.trigger ?? `combobox:${ctx.id}:toggle-btn`;
var getClearTriggerId = (ctx) => ctx.ids?.clearTrigger ?? `combobox:${ctx.id}:clear-btn`;
var getItemGroupId = (ctx, id) => ctx.ids?.itemGroup?.(id) ?? `combobox:${ctx.id}:optgroup:${id}`;
var getItemGroupLabelId = (ctx, id) => ctx.ids?.itemGroupLabel?.(id) ?? `combobox:${ctx.id}:optgroup-label:${id}`;
var getItemId = (ctx, id) => ctx.ids?.item?.(id) ?? `combobox:${ctx.id}:option:${id}`;
var getContentEl = (ctx) => ctx.getById(getContentId(ctx));
var getInputEl = (ctx) => ctx.getById(getInputId(ctx));
var getPositionerEl = (ctx) => ctx.getById(getPositionerId(ctx));
var getControlEl = (ctx) => ctx.getById(getControlId(ctx));
var getTriggerEl = (ctx) => ctx.getById(getTriggerId(ctx));
var getClearTriggerEl = (ctx) => ctx.getById(getClearTriggerId(ctx));
var getItemEl = (ctx, value) => {
if (value == null) return;
const selector = `[role=option][data-value="${CSS.escape(value)}"]`;
return query(getContentEl(ctx), selector);
};
var focusInputEl = (ctx) => {
const inputEl = getInputEl(ctx);
if (ctx.isActiveElement(inputEl)) return;
inputEl?.focus({ preventScroll: true });
};
var focusTriggerEl = (ctx) => {
const triggerEl = getTriggerEl(ctx);
if (ctx.isActiveElement(triggerEl)) return;
triggerEl?.focus({ preventScroll: true });
};
// src/combobox.connect.ts
function connect(service, normalize) {
const { context, prop, state, send, scope, computed, event } = service;
const translations = prop("translations");
const collection2 = prop("collection");
const disabled = prop("disabled");
const interactive = computed("isInteractive");
const invalid = prop("invalid");
const readOnly = prop("readOnly");
const open = state.hasTag("open");
const focused = state.hasTag("focused");
const composite = prop("composite");
const highlightedValue = context.get("highlightedValue");
const popperStyles = getPlacementStyles({
...prop("positioning"),
placement: context.get("currentPlacement")
});
function getItemState(props2) {
const disabled2 = collection2.getItemDisabled(props2.item);
const value = collection2.getItemValue(props2.item);
ensure(value, () => `[zag-js] No value found for item ${JSON.stringify(props2.item)}`);
return {
value,
disabled: Boolean(disabled2 || disabled2),
highlighted: highlightedValue === value,
selected: context.get("value").includes(value)
};
}
return {
focused,
open,
inputValue: context.get("inputValue"),
highlightedValue,
highlightedItem: context.get("highlightedItem"),
value: context.get("value"),
valueAsString: computed("valueAsString"),
hasSelectedItems: computed("hasSelectedItems"),
selectedItems: context.get("selectedItems"),
collection: prop("collection"),
multiple: !!prop("multiple"),
disabled: !!disabled,
syncSelectedItems() {
send({ type: "SELECTED_ITEMS.SYNC" });
},
reposition(options = {}) {
send({ type: "POSITIONING.SET", options });
},
setHighlightValue(value) {
send({ type: "HIGHLIGHTED_VALUE.SET", value });
},
clearHighlightValue() {
send({ type: "HIGHLIGHTED_VALUE.CLEAR" });
},
selectValue(value) {
send({ type: "ITEM.SELECT", value });
},
setValue(value) {
send({ type: "VALUE.SET", value });
},
setInputValue(value, reason = "script") {
send({ type: "INPUT_VALUE.SET", value, src: reason });
},
clearValue(value) {
if (value != null) {
send({ type: "ITEM.CLEAR", value });
} else {
send({ type: "VALUE.CLEAR" });
}
},
focus() {
getInputEl(scope)?.focus();
},
setOpen(nextOpen, reason = "script") {
const open2 = state.hasTag("open");
if (open2 === nextOpen) return;
send({ type: nextOpen ? "OPEN" : "CLOSE", src: reason });
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
dir: prop("dir"),
id: getRootId(scope),
"data-invalid": dataAttr(invalid),
"data-readonly": dataAttr(readOnly)
});
},
getLabelProps() {
return normalize.label({
...parts.label.attrs,
dir: prop("dir"),
htmlFor: getInputId(scope),
id: getLabelId(scope),
"data-readonly": dataAttr(readOnly),
"data-disabled": dataAttr(disabled),
"data-invalid": dataAttr(invalid),
"data-focus": dataAttr(focused),
onClick(event2) {
if (composite) return;
event2.preventDefault();
getTriggerEl(scope)?.focus({ preventScroll: true });
}
});
},
getControlProps() {
return normalize.element({
...parts.control.attrs,
dir: prop("dir"),
id: getControlId(scope),
"data-state": open ? "open" : "closed",
"data-focus": dataAttr(focused),
"data-disabled": dataAttr(disabled),
"data-invalid": dataAttr(invalid)
});
},
getPositionerProps() {
return normalize.element({
...parts.positioner.attrs,
dir: prop("dir"),
id: getPositionerId(scope),
style: popperStyles.floating
});
},
getInputProps() {
return normalize.input({
...parts.input.attrs,
dir: prop("dir"),
"aria-invalid": ariaAttr(invalid),
"data-invalid": dataAttr(invalid),
"data-autofocus": dataAttr(prop("autoFocus")),
name: prop("name"),
form: prop("form"),
disabled,
required: prop("required"),
autoComplete: "off",
autoCorrect: "off",
autoCapitalize: "none",
spellCheck: "false",
readOnly,
placeholder: prop("placeholder"),
id: getInputId(scope),
type: "text",
role: "combobox",
defaultValue: context.get("inputValue"),
"aria-autocomplete": computed("autoComplete") ? "both" : "list",
"aria-controls": getContentId(scope),
"aria-expanded": open,
"data-state": open ? "open" : "closed",
"aria-activedescendant": highlightedValue ? getItemId(scope, highlightedValue) : void 0,
onClick(event2) {
if (event2.defaultPrevented) return;
if (!prop("openOnClick")) return;
if (!interactive) return;
send({ type: "INPUT.CLICK", src: "input-click" });
},
onFocus() {
if (disabled) return;
send({ type: "INPUT.FOCUS" });
},
onBlur() {
if (disabled) return;
send({ type: "INPUT.BLUR" });
},
onChange(event2) {
send({ type: "INPUT.CHANGE", value: event2.currentTarget.value, src: "input-change" });
},
onKeyDown(event2) {
if (event2.defaultPrevented) return;
if (!interactive) return;
if (event2.ctrlKey || event2.shiftKey || isComposingEvent(event2)) return;
const openOnKeyPress = prop("openOnKeyPress");
const isModifierKey = event2.ctrlKey || event2.metaKey || event2.shiftKey;
const keypress = true;
const keymap = {
ArrowDown(event3) {
if (!openOnKeyPress && !open) return;
send({ type: event3.altKey ? "OPEN" : "INPUT.ARROW_DOWN", keypress, src: "arrow-key" });
event3.preventDefault();
},
ArrowUp() {
if (!openOnKeyPress && !open) return;
send({ type: event2.altKey ? "CLOSE" : "INPUT.ARROW_UP", keypress, src: "arrow-key" });
event2.preventDefault();
},
Home(event3) {
if (isModifierKey) return;
send({ type: "INPUT.HOME", keypress });
if (open) {
event3.preventDefault();
}
},
End(event3) {
if (isModifierKey) return;
send({ type: "INPUT.END", keypress });
if (open) {
event3.preventDefault();
}
},
Enter(event3) {
send({ type: "INPUT.ENTER", keypress, src: "item-select" });
const submittable = computed("isCustomValue") && prop("allowCustomValue");
if (open && !submittable) {
event3.preventDefault();
}
if (highlightedValue == null) return;
const itemEl = getItemEl(scope, highlightedValue);
if (isAnchorElement(itemEl)) {
prop("navigate")?.({ value: highlightedValue, node: itemEl, href: itemEl.href });
}
},
Escape() {
send({ type: "INPUT.ESCAPE", keypress, src: "escape-key" });
event2.preventDefault();
}
};
const key = getEventKey(event2, { dir: prop("dir") });
const exec = keymap[key];
exec?.(event2);
}
});
},
getTriggerProps(props2 = {}) {
return normalize.button({
...parts.trigger.attrs,
dir: prop("dir"),
id: getTriggerId(scope),
"aria-haspopup": composite ? "listbox" : "dialog",
type: "button",
tabIndex: props2.focusable ? void 0 : -1,
"aria-label": translations.triggerLabel,
"aria-expanded": open,
"data-state": open ? "open" : "closed",
"aria-controls": open ? getContentId(scope) : void 0,
disabled,
"data-invalid": dataAttr(invalid),
"data-focusable": dataAttr(props2.focusable),
"data-readonly": dataAttr(readOnly),
"data-disabled": dataAttr(disabled),
onFocus() {
if (!props2.focusable) return;
send({ type: "INPUT.FOCUS", src: "trigger" });
},
onClick(event2) {
if (event2.defaultPrevented) return;
if (!interactive) return;
if (!isLeftClick(event2)) return;
send({ type: "TRIGGER.CLICK", src: "trigger-click" });
},
onPointerDown(event2) {
if (!interactive) return;
if (event2.pointerType === "touch") return;
if (!isLeftClick(event2)) return;
event2.preventDefault();
queueMicrotask(() => {
getInputEl(scope)?.focus({ preventScroll: true });
});
},
onKeyDown(event2) {
if (event2.defaultPrevented) return;
if (composite) return;
const keyMap = {
ArrowDown() {
send({ type: "INPUT.ARROW_DOWN", src: "arrow-key" });
},
ArrowUp() {
send({ type: "INPUT.ARROW_UP", src: "arrow-key" });
}
};
const key = getEventKey(event2, { dir: prop("dir") });
const exec = keyMap[key];
if (exec) {
exec(event2);
event2.preventDefault();
}
}
});
},
getContentProps() {
return normalize.element({
...parts.content.attrs,
dir: prop("dir"),
id: getContentId(scope),
role: !composite ? "dialog" : "listbox",
tabIndex: -1,
hidden: !open,
"data-state": open ? "open" : "closed",
"data-placement": context.get("currentPlacement"),
"aria-labelledby": getLabelId(scope),
"aria-multiselectable": prop("multiple") && composite ? true : void 0,
"data-empty": dataAttr(collection2.size === 0),
onPointerDown(event2) {
if (!isLeftClick(event2)) return;
event2.preventDefault();
}
});
},
getListProps() {
return normalize.element({
...parts.list.attrs,
role: !composite ? "listbox" : void 0,
"data-empty": dataAttr(collection2.size === 0),
"aria-labelledby": getLabelId(scope),
"aria-multiselectable": prop("multiple") && !composite ? true : void 0
});
},
getClearTriggerProps() {
return normalize.button({
...parts.clearTrigger.attrs,
dir: prop("dir"),
id: getClearTriggerId(scope),
type: "button",
tabIndex: -1,
disabled,
"data-invalid": dataAttr(invalid),
"aria-label": translations.clearTriggerLabel,
"aria-controls": getInputId(scope),
hidden: !context.get("value").length,
onPointerDown(event2) {
if (!isLeftClick(event2)) return;
event2.preventDefault();
},
onClick(event2) {
if (event2.defaultPrevented) return;
if (!interactive) return;
send({ type: "VALUE.CLEAR", src: "clear-trigger" });
}
});
},
getItemState,
getItemProps(props2) {
const itemState = getItemState(props2);
const value = itemState.value;
return normalize.element({
...parts.item.attrs,
dir: prop("dir"),
id: getItemId(scope, value),
role: "option",
tabIndex: -1,
"data-highlighted": dataAttr(itemState.highlighted),
"data-state": itemState.selected ? "checked" : "unchecked",
"aria-selected": ariaAttr(itemState.highlighted),
"aria-disabled": ariaAttr(itemState.disabled),
"data-disabled": dataAttr(itemState.disabled),
"data-value": itemState.value,
onPointerMove() {
if (itemState.disabled) return;
if (itemState.highlighted) return;
send({ type: "ITEM.POINTER_MOVE", value });
},
onPointerLeave() {
if (props2.persistFocus) return;
if (itemState.disabled) return;
const prev = event.previous();
const mouseMoved = prev?.type.includes("POINTER");
if (!mouseMoved) return;
send({ type: "ITEM.POINTER_LEAVE", value });
},
onClick(event2) {
if (isDownloadingEvent(event2)) return;
if (isOpeningInNewTab(event2)) return;
if (isContextMenuEvent(event2)) return;
if (itemState.disabled) return;
send({ type: "ITEM.CLICK", src: "item-select", value });
}
});
},
getItemTextProps(props2) {
const itemState = getItemState(props2);
return normalize.element({
...parts.itemText.attrs,
dir: prop("dir"),
"data-state": itemState.selected ? "checked" : "unchecked",
"data-disabled": dataAttr(itemState.disabled),
"data-highlighted": dataAttr(itemState.highlighted)
});
},
getItemIndicatorProps(props2) {
const itemState = getItemState(props2);
return normalize.element({
"aria-hidden": true,
...parts.itemIndicator.attrs,
dir: prop("dir"),
"data-state": itemState.selected ? "checked" : "unchecked",
hidden: !itemState.selected
});
},
getItemGroupProps(props2) {
const { id } = props2;
return normalize.element({
...parts.itemGroup.attrs,
dir: prop("dir"),
id: getItemGroupId(scope, id),
"aria-labelledby": getItemGroupLabelId(scope, id),
"data-empty": dataAttr(collection2.size === 0),
role: "group"
});
},
getItemGroupLabelProps(props2) {
const { htmlFor } = props2;
return normalize.element({
...parts.itemGroupLabel.attrs,
dir: prop("dir"),
id: getItemGroupLabelId(scope, htmlFor),
role: "presentation"
});
}
};
}
var { guards, createMachine, choose } = setup();
var { and, not } = guards;
var machine = createMachine({
props({ props: props2 }) {
return {
loopFocus: true,
openOnClick: false,
defaultValue: [],
closeOnSelect: !props2.multiple,
allowCustomValue: false,
inputBehavior: "none",
selectionBehavior: props2.multiple ? "clear" : "replace",
openOnKeyPress: true,
openOnChange: true,
composite: true,
navigate({ node }) {
clickIfLink(node);
},
collection: collection.empty(),
...props2,
positioning: {
placement: "bottom",
sameWidth: true,
...props2.positioning
},
translations: {
triggerLabel: "Toggle suggestions",
clearTriggerLabel: "Clear value",
...props2.translations
}
};
},
initialState({ prop }) {
const open = prop("open") || prop("defaultOpen");
return open ? "suggesting" : "idle";
},
context({ prop, bindable, getContext, getEvent }) {
return {
currentPlacement: bindable(() => ({
defaultValue: void 0
})),
value: bindable(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
isEqual,
hash(value) {
return value.join(",");
},
onChange(value) {
const context = getContext();
const prevSelectedItems = context.get("selectedItems");
const collection2 = prop("collection");
const nextItems = value.map((v) => {
const item = prevSelectedItems.find((item2) => collection2.getItemValue(item2) === v);
return item || collection2.find(v);
});
context.set("selectedItems", nextItems);
prop("onValueChange")?.({ value, items: nextItems });
}
})),
highlightedValue: bindable(() => ({
defaultValue: prop("defaultHighlightedValue") || null,
value: prop("highlightedValue"),
onChange(value) {
const item = prop("collection").find(value);
prop("onHighlightChange")?.({ highlightedValue: value, highlightedItem: item });
}
})),
inputValue: bindable(() => {
let inputValue = prop("inputValue") || prop("defaultInputValue") || "";
const value = prop("defaultValue") || prop("value") || [];
if (!inputValue.trim() && !prop("multiple")) {
const valueAsString = prop("collection").stringifyMany(value);
inputValue = match(prop("selectionBehavior"), {
preserve: inputValue || valueAsString,
replace: valueAsString,
clear: ""
});
}
return {
defaultValue: inputValue,
value: prop("inputValue"),
onChange(value2) {
const event = getEvent();
const reason = (event.previousEvent || event).src;
prop("onInputValueChange")?.({ inputValue: value2, reason });
}
};
}),
highlightedItem: bindable(() => {
const highlightedValue = prop("highlightedValue");
const highlightedItem = prop("collection").find(highlightedValue);
return { defaultValue: highlightedItem };
}),
selectedItems: bindable(() => {
const value = prop("value") || prop("defaultValue") || [];
const selectedItems = prop("collection").findMany(value);
return { defaultValue: selectedItems };
})
};
},
computed: {
isInputValueEmpty: ({ context }) => context.get("inputValue").length === 0,
isInteractive: ({ prop }) => !(prop("readOnly") || prop("disabled")),
autoComplete: ({ prop }) => prop("inputBehavior") === "autocomplete",
autoHighlight: ({ prop }) => prop("inputBehavior") === "autohighlight",
hasSelectedItems: ({ context }) => context.get("value").length > 0,
valueAsString: ({ context, prop }) => prop("collection").stringifyItems(context.get("selectedItems")),
isCustomValue: ({ context, computed }) => context.get("inputValue") !== computed("valueAsString")
},
watch({ context, prop, track, action, send }) {
track([() => context.hash("value")], () => {
action(["syncSelectedItems"]);
});
track([() => context.get("inputValue")], () => {
action(["syncInputValue"]);
});
track([() => context.get("highlightedValue")], () => {
action(["syncHighlightedItem", "autofillInputValue"]);
});
track([() => prop("open")], () => {
action(["toggleVisibility"]);
});
track([() => prop("collection").toString()], () => {
send({ type: "CHILDREN_CHANGE" });
});
},
on: {
"SELECTED_ITEMS.SYNC": {
actions: ["syncSelectedItems"]
},
"HIGHLIGHTED_VALUE.SET": {
actions: ["setHighlightedValue"]
},
"HIGHLIGHTED_VALUE.CLEAR": {
actions: ["clearHighlightedValue"]
},
"ITEM.SELECT": {
actions: ["selectItem"]
},
"ITEM.CLEAR": {
actions: ["clearItem"]
},
"VALUE.SET": {
actions: ["setValue"]
},
"INPUT_VALUE.SET": {
actions: ["setInputValue"]
},
"POSITIONING.SET": {
actions: ["reposition"]
}
},
entry: choose([
{
guard: "autoFocus",
actions: ["setInitialFocus"]
}
]),
states: {
idle: {
tags: ["idle", "closed"],
entry: ["scrollContentToTop", "clearHighlightedValue"],
on: {
"CONTROLLED.OPEN": {
target: "interacting"
},
"TRIGGER.CLICK": [
{
guard: "isOpenControlled",
actions: ["setInitialFocus", "highlightFirstSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["setInitialFocus", "highlightFirstSelectedItem", "invokeOnOpen"]
}
],
"INPUT.CLICK": [
{
guard: "isOpenControlled",
actions: ["highlightFirstSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["highlightFirstSelectedItem", "invokeOnOpen"]
}
],
"INPUT.FOCUS": {
target: "focused"
},
OPEN: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "interacting",
actions: ["invokeOnOpen"]
}
],
"VALUE.CLEAR": {
target: "focused",
actions: ["clearInputValue", "clearSelectedItems", "setInitialFocus"]
}
}
},
focused: {
tags: ["focused", "closed"],
entry: ["scrollContentToTop", "clearHighlightedValue"],
on: {
"CONTROLLED.OPEN": [
{
guard: "isChangeEvent",
target: "suggesting"
},
{
target: "interacting"
}
],
"INPUT.CHANGE": [
{
guard: and("isOpenControlled", "openOnChange"),
actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"]
},
{
guard: "openOnChange",
target: "suggesting",
actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"]
},
{
actions: ["setInputValue"]
}
],
"LAYER.INTERACT_OUTSIDE": {
target: "idle"
},
"INPUT.ESCAPE": {
guard: and("isCustomValue", not("allowCustomValue")),
actions: ["revertInputValue"]
},
"INPUT.BLUR": {
target: "idle"
},
"INPUT.CLICK": [
{
guard: "isOpenControlled",
actions: ["highlightFirstSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["highlightFirstSelectedItem", "invokeOnOpen"]
}
],
"TRIGGER.CLICK": [
{
guard: "isOpenControlled",
actions: ["setInitialFocus", "highlightFirstSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["setInitialFocus", "highlightFirstSelectedItem", "invokeOnOpen"]
}
],
"INPUT.ARROW_DOWN": [
// == group 1 ==
{
guard: and("isOpenControlled", "autoComplete"),
actions: ["invokeOnOpen"]
},
{
guard: "autoComplete",
target: "interacting",
actions: ["invokeOnOpen"]
},
// == group 2 ==
{
guard: "isOpenControlled",
actions: ["highlightFirstOrSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["highlightFirstOrSelectedItem", "invokeOnOpen"]
}
],
"INPUT.ARROW_UP": [
// == group 1 ==
{
guard: "autoComplete",
target: "interacting",
actions: ["invokeOnOpen"]
},
{
guard: "autoComplete",
target: "interacting",
actions: ["invokeOnOpen"]
},
// == group 2 ==
{
target: "interacting",
actions: ["highlightLastOrSelectedItem", "invokeOnOpen"]
},
{
target: "interacting",
actions: ["highlightLastOrSelectedItem", "invokeOnOpen"]
}
],
OPEN: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "interacting",
actions: ["invokeOnOpen"]
}
],
"VALUE.CLEAR": {
actions: ["clearInputValue", "clearSelectedItems"]
}
}
},
interacting: {
tags: ["open", "focused"],
entry: ["setInitialFocus"],
effects: ["scrollToHighlightedItem", "trackDismissableLayer", "trackPlacement", "hideOtherElements"],
on: {
"CONTROLLED.CLOSE": [
{
guard: "restoreFocus",
target: "focused",
actions: ["setFinalFocus"]
},
{
target: "idle"
}
],
CHILDREN_CHANGE: [
{
guard: "isHighlightedItemRemoved",
actions: ["clearHighlightedValue"]
},
{
actions: ["scrollToHighlightedItem"]
}
],
"INPUT.HOME": {
actions: ["highlightFirstItem"]
},
"INPUT.END": {
actions: ["highlightLastItem"]
},
"INPUT.ARROW_DOWN": [
{
guard: and("autoComplete", "isLastItemHighlighted"),
actions: ["clearHighlightedValue", "scrollContentToTop"]
},
{
actions: ["highlightNextItem"]
}
],
"INPUT.ARROW_UP": [
{
guard: and("autoComplete", "isFirstItemHighlighted"),
actions: ["clearHighlightedValue"]
},
{
actions: ["highlightPrevItem"]
}
],
"INPUT.ENTER": [
// == group 1 ==
{
guard: and("isOpenControlled", "isCustomValue", not("hasHighlightedItem"), not("allowCustomValue")),
actions: ["revertInputValue", "invokeOnClose"]
},
{
guard: and("isCustomValue", not("hasHighlightedItem"), not("allowCustomValue")),
target: "focused",
actions: ["revertInputValue", "invokeOnClose"]
},
// == group 2 ==
{
guard: and("isOpenControlled", "closeOnSelect"),
actions: ["selectHighlightedItem", "invokeOnClose"]
},
{
guard: "closeOnSelect",
target: "focused",
actions: ["selectHighlightedItem", "invokeOnClose", "setFinalFocus"]
},
{
actions: ["selectHighlightedItem"]
}
],
"INPUT.CHANGE": [
{
guard: "autoComplete",
target: "suggesting",
actions: ["setInputValue"]
},
{
target: "suggesting",
actions: ["clearHighlightedValue", "setInputValue"]
}
],
"ITEM.POINTER_MOVE": {
actions: ["setHighlightedValue"]
},
"ITEM.POINTER_LEAVE": {
actions: ["clearHighlightedValue"]
},
"ITEM.CLICK": [
{
guard: and("isOpenControlled", "closeOnSelect"),
actions: ["selectItem", "invokeOnClose"]
},
{
guard: "closeOnSelect",
target: "focused",
actions: ["selectItem", "invokeOnClose", "setFinalFocus"]
},
{
actions: ["selectItem"]
}
],
"LAYER.ESCAPE": [
{
guard: and("isOpenControlled", "autoComplete"),
actions: ["syncInputValue", "invokeOnClose"]
},
{
guard: "autoComplete",
target: "focused",
actions: ["syncInputValue", "invokeOnClose"]
},
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose", "setFinalFocus"]
}
],
"TRIGGER.CLICK": [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose"]
}
],
"LAYER.INTERACT_OUTSIDE": [
// == group 1 ==
{
guard: and("isOpenControlled", "isCustomValue", not("allowCustomValue")),
actions: ["revertInputValue", "invokeOnClose"]
},
{
guard: and("isCustomValue", not("allowCustomValue")),
target: "idle",
actions: ["revertInputValue", "invokeOnClose"]
},
// == group 2 ==
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "idle",
actions: ["invokeOnClose"]
}
],
CLOSE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose", "setFinalFocus"]
}
],
"VALUE.CLEAR": [
{
guard: "isOpenControlled",
actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"]
},
{
target: "focused",
actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose", "setFinalFocus"]
}
]
}
},
suggesting: {
tags: ["open", "focused"],
effects: ["trackDismissableLayer", "scrollToHighlightedItem", "trackPlacement", "hideOtherElements"],
entry: ["setInitialFocus"],
on: {
"CONTROLLED.CLOSE": [
{
guard: "restoreFocus",
target: "focused",
actions: ["setFinalFocus"]
},
{
target: "idle"
}
],
CHILDREN_CHANGE: [
{
guard: "autoHighlight",
actions: ["highlightFirstItem"]
},
{
guard: "isHighlightedItemRemoved",
actions: ["clearHighlightedValue"]
}
],
"INPUT.ARROW_DOWN": {
target: "interacting",
actions: ["highlightNextItem"]
},
"INPUT.ARROW_UP": {
target: "interacting",
actions: ["highlightPrevItem"]
},
"INPUT.HOME": {
target: "interacting",
actions: ["highlightFirstItem"]
},
"INPUT.END": {
target: "interacting",
actions: ["highlightLastItem"]
},
"INPUT.ENTER": [
// == group 1 ==
{
guard: and("isOpenControlled", "isCustomValue", not("hasHighlightedItem"), not("allowCustomValue")),
actions: ["revertInputValue", "invokeOnClose"]
},
{
guard: and("isCustomValue", not("hasHighlightedItem"), not("allowCustomValue")),
target: "focused",
actions: ["revertInputValue", "invokeOnClose"]
},
// == group 2 ==
{
guard: and("isOpenControlled", "closeOnSelect"),
actions: ["selectHighlightedItem", "invokeOnClose"]
},
{
guard: "closeOnSelect",
target: "focused",
actions: ["selectHighlightedItem", "invokeOnClose", "setFinalFocus"]
},
{
actions: ["selectHighlightedItem"]
}
],
"INPUT.CHANGE": {
actions: ["setInputValue"]
},
"LAYER.ESCAPE": [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose"]
}
],
"ITEM.POINTER_MOVE": {
target: "interacting",
actions: ["setHighlightedValue"]
},
"ITEM.POINTER_LEAVE": {
actions: ["clearHighlightedValue"]
},
"LAYER.INTERACT_OUTSIDE": [
// == group 1 ==
{
guard: and("isOpenControlled", "isCustomValue", not("allowCustomValue")),
actions: ["revertInputValue", "invokeOnClose"]
},
{
guard: and("isCustomValue", not("allowCustomValue")),
target: "idle",
actions: ["revertInputValue", "invokeOnClose"]
},
// == group 2 ==
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "idle",
actions: ["invokeOnClose"]
}
],
"TRIGGER.CLICK": [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose"]
}
],
"ITEM.CLICK": [
{
guard: and("isOpenControlled", "closeOnSelect"),
actions: ["selectItem", "invokeOnClose"]
},
{
guard: "closeOnSelect",
target: "focused",
actions: ["selectItem", "invokeOnClose", "setFinalFocus"]
},
{
actions: ["selectItem"]
}
],
CLOSE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "focused",
actions: ["invokeOnClose", "setFinalFocus"]
}
],
"VALUE.CLEAR": [
{
guard: "isOpenControlled",
actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"]
},
{
target: "focused",
actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose", "setFinalFocus"]
}
]
}
}
},
implementations: {
guards: {
isInputValueEmpty: ({ computed }) => computed("isInputValueEmpty"),
autoComplete: ({ computed, prop }) => computed("autoComplete") && !prop("multiple"),
autoHighlight: ({ computed }) => computed("autoHighlight"),
isFirstItemHighlighted: ({ prop, context }) => prop("collection").firstValue === context.get("highlightedValue"),
isLastItemHighlighted: ({ prop, context }) => prop("collection").lastValue === context.get("highlightedValue"),
isCustomValue: ({ computed }) => computed("isCustomValue"),
allowCustomValue: ({ prop }) => !!prop("allowCustomValue"),
hasHighlightedItem: ({ context }) => context.get("highlightedValue") != null,
closeOnSelect: ({ prop }) => !!prop("closeOnSelect"),
isOpenControlled: ({ prop }) => prop("open") != null,
openOnChange: ({ prop, context }) => {
const openOnChange = prop("openOnChange");
if (isBoolean(openOnChange)) return openOnChange;
return !!openOnChange?.({ inputValue: context.get("inputValue") });
},
restoreFocus: ({ event }) => event.restoreFocus == null ? true : !!event.restoreFocus,
isChangeEvent: ({ event }) => event.previousEvent?.type === "INPUT.CHANGE",
autoFocus: ({ prop }) => !!prop("autoFocus"),
isHighlightedItemRemoved: ({ prop, context }) => !prop("collection").has(context.get("highlightedValue"))
},
effects: {
trackDismissableLayer({ send, prop, scope }) {
if (prop("disableLayer")) return;
const contentEl = () => getContentEl(scope);
return trackDismissableElement(contentEl, {
defer: true,
exclude: () => [getInputEl(scope), getTriggerEl(scope), getClearTriggerEl(scope)],
onFocusOutside: prop("onFocusOutside"),
onPointerDownOutside: prop("onPointerDownOutside"),
onInteractOutside: prop("onInteractOutside"),
onEscapeKeyDown(event) {
event.preventDefault();
event.stopPropagation();
send({ type: "LAYER.ESCAPE", src: "escape-key" });
},
onDismiss() {
send({ type: "LAYER.INTERACT_OUTSIDE", src: "interact-outside", restoreFocus: false });
}
});
},
hideOtherElements({ scope }) {
return ariaHidden([
getInputEl(scope),
getContentEl(scope),
getTriggerEl(scope),
getClearTriggerEl(scope)
]);
},
trackPlacement({ context, prop, scope }) {
const anchorEl = () => getControlEl(scope) || getTriggerEl(scope);
const positionerEl = () => getPositionerEl(scope);
context.set("currentPlacement", prop("positioning").placement);
return getPlacement(anchorEl, positionerEl, {
...prop("positioning"),
defer: true,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
scrollToHighlightedItem({ context, prop, scope, event }) {
const inputEl = getInputEl(scope);
let cleanups = [];
const exec = (immediate) => {
const pointer = event.current().type.includes("POINTER");
const highlightedValue = context.get("highlightedValue");
if (pointer || !highlightedValue) return;
const itemEl = getItemEl(scope, highlightedValue);
const contentEl = getContentEl(scope);
const scrollToIndexFn = prop("scrollToIndexFn");
if (scrollToIndexFn) {
const highlightedIndex = prop("collection").indexOf(highlightedValue);
scrollToIndexFn({ index: highlightedIndex, immediate });
return;
}
const raf_cleanup = raf(() => {
scrollIntoView(itemEl, { rootEl: contentEl, block: "nearest" });
});
cleanups.push(raf_cleanup);
};
const rafCleanup = raf(() => exec(true));
cleanups.push(rafCleanup);
const observerCleanup = observeAttributes(inputEl, {
attributes: ["aria-activedescendant"],
callback: () => exec(false)
});
cleanups.push(observerCleanup);
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}
},
actions: {
reposition({ context, prop, scope, event }) {
const controlEl = () => getControlEl(scope);
const positionerEl = () => getPositionerEl(scope);
getPlacement(controlEl, positionerEl, {
...prop("positioning"),
...event.options,
defer: true,
listeners: false,
onComplete(data) {
context.set("currentPlacement", data.placement);
}
});
},
setHighlightedValue({ context, event }) {
if (event.value == null) return;
context.set("highlightedValue", event.value);
},
clearHighlightedValue({ context }) {
context.set("highlightedValue", null);
},
selectHighlightedItem(params) {
const { context, prop } = params;
const collection2 = prop("collection");
const highlightedValue = context.get("highlightedValue");
if (!highlightedValue || !collection2.has(highlightedValue)) return;
const nextValue = prop("multiple") ? addOrRemove(context.get("value"), highlightedValue) : [highlightedValue];
prop("onSelect")?.({ value: nextValue, itemValue: highlightedValue });
context.set("value", nextValue);
const inputValue = match(prop("selectionBehavior"), {
preserve: context.get("inputValue"),
replace: collection2.stringifyMany(nextValue),
clear: ""
});
context.set("inputValue", inputValue);
},
scrollToHighlightedItem({ context, prop, scope }) {
nextTick(() => {
const highlightedValue = context.get("highlightedValue");
if (highlightedValue == null) return;
const itemEl = getItemEl(scope, highlightedValue);
const contentEl = getContentEl(scope);
const scrollToIndexFn = prop("scrollToIndexFn");
if (scrollToIndexFn) {
const highlightedIndex = prop("collection").indexOf(highlightedValue);
scrollToIndexFn({ index: highlightedIndex, immediate: true });
return;
}
scrollIntoView(itemEl, { rootEl: contentEl, block: "nearest" });
});
},
selectItem(params) {
const { context, event, flush, prop } = params;
if (event.value == null) return;
flush(() => {
const nextValue = prop("multiple") ? addOrRemove(context.get("value"), event.value) : [event.value];
prop("onSelect")?.({ value: nextValue, itemValue: event.value });
context.set("value", nextValue);
const inputValue = match(prop("selectionBehavior"), {
preserve: context.get("inputValue"),
replace: prop("collection").stringifyMany(nextValue),
clear: ""
});
context.set("inputValue", inputValue);
});
},
clearItem(params) {
const { context, event, flush, prop } = params;
if (event.value == null) return;
flush(() => {
const nextValue = remove(context.get("value"), event.value);
context.set("value", nextValue);
const inputValue = match(prop("selectionBehavior"), {
preserve: context.get("inputValue"),
replace: prop("collection").stringifyMany(nextValue),
clear: ""
});
context.set("inputValue", inputValue);
});
},
setInitialFocus({ scope }) {
raf(() => {
focusInputEl(scope);
});
},
setFinalFocus({ scope }) {
raf(() => {
const triggerEl = getTriggerEl(scope);
if (triggerEl?.dataset.focusable == null) {
focusInputEl(scope);
} else {
focusTriggerEl(scope);
}
});
},
syncInputValue({ context, scope, event }) {
const inputEl = getInputEl(scope);
if (!inputEl) return;
inputEl.value = context.get("inputValue");
queueMicrotask(() => {
if (event.current().type === "INPUT.CHANGE") return;
setCaretToEnd(inputEl);
});
},
setInputValue({ context, event }) {
context.set("inputValue", event.value);
},
clearInputValue({ context }) {
context.set("inputValue", "");
},
revertInputValue({ context, prop, computed }) {
const selectionBehavior = prop("selectionBehavior");
const inputValue = match(selectionBehavior, {
replace: computed("hasSelectedItems") ? computed("valueAsString") : "",
preserve: context.get("inputValue"),
clear: ""
});
context.set("inputValue", inputValue);
},
setValue(params) {
const { context, flush, event, prop } = params;
flush(() => {
context.set("value", event.value);
const inputValue = match(prop("selectionBehavior"), {
preserve: context.get("inputValue"),
replace: prop("collection").stringifyMany(event.value),
clear: ""
});
context.set("inputValue", inputValue);
});
},
clearSelectedItems(params) {
const { context, flush, prop } = params;
flush(() => {
context.set("value", []);
const inputValue = match(prop("selectionBehavior"), {
preserve: context.get("inputValue"),
replace: prop("collection").stringifyMany([]),
clear: ""
});
context.set("inputValue", inputValue);
});
},
scrollContentToTop({ prop, scope }) {
const scrollToIndexFn = prop("scrollToIndexFn");
if (scrollToIndexFn) {
scrollToIndexFn({ index: 0, immediate: true });
} else {
const contentEl = getContentEl(scope);
if (!contentEl) return;
contentEl.scrollTop = 0;
}
},
invokeOnOpen({ prop, event }) {
const reason = getOpenChangeReason(event);
prop("onOpenChange")?.({ open: true, reason });
},
invokeOnClose({ prop, event }) {
const reason = getOpenChangeReason(event);
prop("onOpenChange")?.({ open: false, reason });
},
highlightFirstItem({ context, prop, scope }) {
const exec = getContentEl(scope) ? queueMicrotask : raf;
exec(() => {
const value = prop("collection").firstValue;
if (value) context.set("highlightedValue", value);
});
},
highlightFirstItemIfNeeded({ computed, action }) {
if (!computed("autoHighlight")) return;
action(["highlightFirstItem"]);
},
highlightLastItem({ context, prop, scope }) {
const exec = getContentEl(scope) ? queueMicrotask : raf;
exec(() => {
const value = prop("collection").lastValue;
if (value) context.set("highlightedValue", value);
});
},
highlightNextItem({ context, prop }) {
let value = null;
const highlightedValue = context.get("highlightedValue");
const collection2 = prop("collection");
if (highlightedValue) {
value = collection2.getNextValue(highlightedValue);
if (!value && prop("loopFocus")) value = collection2.firstValue;
} else {
value = collection2.firstValue;
}
if (value) context.set("highlightedValue", value);
},
highlightPrevItem({ context, prop }) {
let value = null;
const highlightedValue = context.get("highlightedValue");
const collection2 = prop("collection");
if (highlightedValue) {
value = collection2.getPreviousValue(highlightedValue);
if (!value && prop("loopFocus")) value = collection2.lastValue;
} else {
value = collection2.lastValue;
}
if (value) context.set("highlightedValue", value);
},
highlightFirstSelectedItem({ context, prop }) {
raf(() => {
const [value] = prop("collection").sort(context.get("value"));
if (value) context.set("highlightedValue", value);
});
},
highlightFirstOrSelectedItem({ context, prop, computed }) {
raf(() => {
let value = null;
if (computed("hasSelectedItems")) {
value = prop("collection").sort(context.get("value"))[0];
} else {
value = prop("collection").firstValue;
}
if (value) context.set("highlightedValue", value);
});
},
highlightLastOrSelectedItem({ context, prop, computed }) {
raf(() => {
const collection2 = prop("collection");
let value = null;
if (computed("hasSelectedItems")) {
value = collection2.sort(context.get("value"))[0];
} else {
value = collection2.lastValue;
}
if (value) context.set("highlightedValue", value);
});
},
autofillInputValue({ context, computed, prop, event, scope }) {
const inputEl = getInputEl(scope);
const collection2 = prop("collection");
if (!computed("autoComplete") || !inputEl || !event.keypress) return;
const valueText = collection2.stringify(context.get("highlightedValue"));
raf(() => {
inputEl.value = valueText || context.get("inputValue");
});
},
syncSelectedItems(params) {
queueMicrotask(() => {