@zag-js/listbox
Version:
Core logic for the listbox widget implemented as a state machine
411 lines (410 loc) • 14.5 kB
JavaScript
// 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
};