UNPKG

@zag-js/listbox

Version:

Core logic for the listbox widget implemented as a state machine

411 lines (410 loc) 14.5 kB
// src/listbox.machine.ts import { Selection, createSelectedItemMap, deriveSelectionState, isGridCollection, resolveSelectedItems } from "@zag-js/collection"; import { setup } from "@zag-js/core"; import { getByTypeahead, observeAttributes, raf, scrollIntoView } from "@zag-js/dom-query"; import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible"; import { isEqual } from "@zag-js/utils"; import { collection } from "./listbox.collection.mjs"; import * as dom from "./listbox.dom.mjs"; var { guards, createMachine } = setup(); var { or } = guards; var machine = createMachine({ props({ props }) { return { loopFocus: false, composite: true, defaultValue: [], multiple: false, typeahead: true, collection: collection.empty(), orientation: "vertical", selectionMode: "single", ...props }; }, context({ prop, bindable, getContext }) { const initialValue = prop("value") ?? prop("defaultValue") ?? []; const initialSelectedItems = prop("collection").findMany(initialValue); return { value: bindable(() => ({ defaultValue: prop("defaultValue"), value: prop("value"), isEqual, onChange(value) { const context = getContext(); const collection2 = prop("collection"); const selectedItemMap = context.get("selectedItemMap"); const proposed = deriveSelectionState({ values: value, collection: collection2, selectedItemMap }); const effectiveValue = prop("value") ?? value; const effective = effectiveValue === value ? proposed : deriveSelectionState({ values: effectiveValue, collection: collection2, selectedItemMap: proposed.nextSelectedItemMap }); context.set("selectedItemMap", effective.nextSelectedItemMap); return prop("onValueChange")?.({ value, items: proposed.selectedItems }); } })), highlightedValue: bindable(() => ({ defaultValue: prop("defaultHighlightedValue") || null, value: prop("highlightedValue"), sync: true, onChange(value) { prop("onHighlightChange")?.({ highlightedValue: value, highlightedItem: prop("collection").find(value), highlightedIndex: prop("collection").indexOf(value) }); } })), highlightedItem: bindable(() => ({ defaultValue: null })), selectedItemMap: bindable(() => { return { defaultValue: createSelectedItemMap({ selectedItems: initialSelectedItems, collection: prop("collection") }) }; }), focused: bindable(() => ({ sync: true, defaultValue: false })) }; }, refs() { return { typeahead: { ...getByTypeahead.defaultOptions }, focusVisible: false, inputState: { autoHighlight: false, focused: false } }; }, computed: { hasSelectedItems: ({ context }) => context.get("value").length > 0, isTypingAhead: ({ refs }) => refs.get("typeahead").keysSoFar !== "", isInteractive: ({ prop }) => !prop("disabled"), selection: ({ context, prop }) => { const selection = new Selection(context.get("value")); selection.selectionMode = prop("selectionMode"); selection.deselectable = !!prop("deselectable"); return selection; }, multiple: ({ prop }) => prop("selectionMode") === "multiple" || prop("selectionMode") === "extended", selectedItems: ({ context, prop }) => resolveSelectedItems({ values: context.get("value"), collection: prop("collection"), selectedItemMap: context.get("selectedItemMap") }), valueAsString: ({ computed, prop }) => prop("collection").stringifyItems(computed("selectedItems")) }, initialState() { return "idle"; }, watch({ context, prop, track, action }) { track([() => context.get("value").toString()], () => { action(["syncSelectedItems"]); }); track([() => context.get("highlightedValue")], () => { action(["syncHighlightedItem"]); }); track([() => prop("collection").toString()], () => { action(["syncHighlightedValue"]); }); }, effects: ["trackFocusVisible"], on: { "HIGHLIGHTED_VALUE.SET": { actions: ["setHighlightedItem"] }, "ITEM.SELECT": { actions: ["selectItem"] }, "ITEM.CLEAR": { actions: ["clearItem"] }, "VALUE.SET": { actions: ["setSelectedItems"] }, "VALUE.CLEAR": { actions: ["clearSelectedItems"] }, "HIGHLIGHT.FIRST": { actions: ["highlightFirstValue"] }, "HIGHLIGHT.LAST": { actions: ["highlightLastValue"] }, "HIGHLIGHT.NEXT": { actions: ["highlightNextValue"] }, "HIGHLIGHT.PREV": { actions: ["highlightPreviousValue"] } }, states: { idle: { effects: ["scrollToHighlightedItem"], on: { "INPUT.FOCUS": { actions: ["setFocused", "setInputState"] }, "CONTENT.FOCUS": [ { guard: or("hasSelectedValue", "hasHighlightedValue"), actions: ["setFocused"] }, { actions: ["setFocused", "setDefaultHighlightedValue"] } ], "CONTENT.BLUR": { actions: ["clearFocused", "clearInputState"] }, "ITEM.CLICK": { actions: ["setHighlightedItem", "selectHighlightedItem"] }, "CONTENT.TYPEAHEAD": { actions: ["setFocused", "highlightMatchingItem"] }, "ITEM.POINTER_MOVE": { actions: ["highlightItem"] }, "ITEM.POINTER_LEAVE": { actions: ["clearHighlightedItem"] }, NAVIGATE: { actions: ["setFocused", "setHighlightedItem", "selectWithKeyboard"] } } } }, implementations: { guards: { hasSelectedValue: ({ context }) => context.get("value").length > 0, hasHighlightedValue: ({ context }) => context.get("highlightedValue") != null }, effects: { trackFocusVisible: ({ scope, refs }) => { return trackFocusVisible({ root: scope.getRootNode?.(), onChange(details) { refs.set("focusVisible", details.isFocusVisible); } }); }, scrollToHighlightedItem({ context, prop, scope }) { const exec = (immediate) => { const highlightedValue = context.get("highlightedValue"); if (highlightedValue == null) return; const modality = getInteractionModality(); if (modality === "pointer") return; const contentEl2 = dom.getContentEl(scope); const scrollToIndexFn = prop("scrollToIndexFn"); if (scrollToIndexFn) { const highlightedIndex = prop("collection").indexOf(highlightedValue); scrollToIndexFn?.({ index: highlightedIndex, immediate, getElement() { return dom.getItemEl(scope, highlightedValue); } }); return; } const itemEl = dom.getItemEl(scope, highlightedValue); scrollIntoView(itemEl, { rootEl: contentEl2, block: "nearest" }); }; raf(() => { setInteractionModality("virtual"); exec(true); }); const contentEl = () => dom.getContentEl(scope); return observeAttributes(contentEl, { defer: true, attributes: ["data-activedescendant"], callback() { exec(false); } }); } }, actions: { selectHighlightedItem({ context, prop, event, computed }) { const value = event.value ?? context.get("highlightedValue"); const collection2 = prop("collection"); if (value == null || !collection2.has(value)) return; const selection = computed("selection"); if (event.shiftKey && computed("multiple") && event.anchorValue) { const next = selection.extendSelection(collection2, event.anchorValue, value); invokeOnSelect(selection, next, prop("onSelect")); context.set("value", Array.from(next)); } else { const next = selection.select(collection2, value, event.metaKey); invokeOnSelect(selection, next, prop("onSelect")); context.set("value", Array.from(next)); } }, selectWithKeyboard({ context, prop, event, computed }) { const selection = computed("selection"); const collection2 = prop("collection"); if (event.shiftKey && computed("multiple") && event.anchorValue) { const next = selection.extendSelection(collection2, event.anchorValue, event.value); invokeOnSelect(selection, next, prop("onSelect")); context.set("value", Array.from(next)); return; } if (prop("selectOnHighlight")) { const next = selection.replaceSelection(collection2, event.value); invokeOnSelect(selection, next, prop("onSelect")); context.set("value", Array.from(next)); } }, highlightItem({ context, event }) { context.set("highlightedValue", event.value); }, highlightMatchingItem({ context, prop, event, refs }) { const value = prop("collection").search(event.key, { state: refs.get("typeahead"), currentValue: context.get("highlightedValue") }); if (value == null) return; context.set("highlightedValue", value); }, setHighlightedItem({ context, event }) { context.set("highlightedValue", event.value); }, highlightFirstValue({ context, prop }) { context.set("highlightedValue", prop("collection").firstValue ?? null); }, highlightLastValue({ context, prop }) { context.set("highlightedValue", prop("collection").lastValue ?? null); }, highlightNextValue({ context, prop }) { const collection2 = prop("collection"); const highlightedValue = context.get("highlightedValue"); let nextValue = null; if (isGridCollection(collection2) && highlightedValue) { nextValue = collection2.getNextRowValue(highlightedValue); } else if (highlightedValue) { nextValue = collection2.getNextValue(highlightedValue); } if (!nextValue && (prop("loopFocus") || !highlightedValue)) { nextValue = collection2.firstValue; } if (!nextValue) return; context.set("highlightedValue", nextValue); }, highlightPreviousValue({ context, prop }) { const collection2 = prop("collection"); const highlightedValue = context.get("highlightedValue"); let nextValue = null; if (isGridCollection(collection2) && highlightedValue) { nextValue = collection2.getPreviousRowValue(highlightedValue); } else if (highlightedValue) { nextValue = collection2.getPreviousValue(highlightedValue); } if (!nextValue && (prop("loopFocus") || !highlightedValue)) { nextValue = collection2.lastValue; } if (!nextValue) return; context.set("highlightedValue", nextValue); }, clearHighlightedItem({ context }) { context.set("highlightedValue", null); }, selectItem({ context, prop, event, computed }) { const collection2 = prop("collection"); const selection = computed("selection"); const next = selection.select(collection2, event.value); invokeOnSelect(selection, next, prop("onSelect")); context.set("value", Array.from(next)); }, clearItem({ context, event, computed }) { const selection = computed("selection"); const value = selection.deselect(event.value); context.set("value", Array.from(value)); }, setSelectedItems({ context, event }) { context.set("value", event.value); }, clearSelectedItems({ context }) { context.set("value", []); }, syncSelectedItems({ context, prop }) { const next = deriveSelectionState({ values: context.get("value"), collection: prop("collection"), selectedItemMap: context.get("selectedItemMap") }); context.set("selectedItemMap", next.nextSelectedItemMap); }, syncHighlightedItem({ context, prop }) { const collection2 = prop("collection"); const highlightedValue = context.get("highlightedValue"); const highlightedItem = highlightedValue ? collection2.find(highlightedValue) : null; context.set("highlightedItem", highlightedItem); }, syncHighlightedValue({ context, prop, refs }) { const collection2 = prop("collection"); const highlightedValue = context.get("highlightedValue"); const { autoHighlight } = refs.get("inputState"); if (autoHighlight) { queueMicrotask(() => { context.set("highlightedValue", prop("collection").firstValue ?? null); }); return; } if (highlightedValue != null && !collection2.has(highlightedValue)) { queueMicrotask(() => { context.set("highlightedValue", null); }); } }, setFocused({ context }) { context.set("focused", true); }, setDefaultHighlightedValue({ context, prop }) { const collection2 = prop("collection"); const firstValue = collection2.firstValue; if (firstValue != null) { context.set("highlightedValue", firstValue); } }, clearFocused({ context }) { context.set("focused", false); }, setInputState({ refs, event }) { refs.set("inputState", { autoHighlight: !!event.autoHighlight, focused: true }); }, clearInputState({ refs }) { refs.set("inputState", { autoHighlight: false, focused: false }); } } } }); var diff = (a, b) => { const result = new Set(a); for (const item of b) result.delete(item); return result; }; function invokeOnSelect(current, next, onSelect) { const added = diff(next, current); for (const item of added) { onSelect?.({ value: item }); } } export { machine };