UNPKG

@zag-js/tags-input

Version:

Core logic for the tags-input widget implemented as a state machine

695 lines (694 loc) • 24 kB
// src/tags-input.machine.ts import { autoResizeInput } from "@zag-js/auto-resize"; import { createGuards, createMachine } from "@zag-js/core"; import { contains, isCaretAtStart, raf, setElementValue, trackFormControl } from "@zag-js/dom-query"; import { trackInteractOutside } from "@zag-js/interact-outside"; import { createLiveRegion } from "@zag-js/live-region"; import { isEqual, removeAt, uniq, warn } from "@zag-js/utils"; import * as dom from "./tags-input.dom.mjs"; var { and, not, or } = createGuards(); var machine = createMachine({ props({ props }) { return { dir: "ltr", addOnPaste: false, editable: true, validate: () => true, allowDuplicates: false, delimiter: ",", defaultValue: [], defaultInputValue: "", max: Infinity, sanitizeValue: (value) => value.trim(), ...props, translations: { clearTriggerLabel: "Clear all tags", deleteTagTriggerLabel: (value) => `Delete tag ${value}`, tagAdded: (value) => `Added tag ${value}`, tagsPasted: (values) => `Pasted ${values.length} tags`, tagEdited: (value) => `Editing tag ${value}. Press enter to save or escape to cancel.`, tagUpdated: (value) => `Tag update to ${value}`, tagDeleted: (value) => `Tag ${value} deleted`, tagSelected: (value) => `Tag ${value} selected. Press enter to edit, delete or backspace to remove.`, ...props.translations } }; }, initialState({ prop }) { return prop("autoFocus") ? "focused:input" : "idle"; }, refs() { return { liveRegion: null, log: { current: null, prev: null } }; }, context({ bindable, prop }) { return { value: bindable(() => ({ defaultValue: prop("defaultValue"), value: prop("value"), isEqual, hash(value) { return value.join(", "); }, onChange(value) { prop("onValueChange")?.({ value }); } })), inputValue: bindable(() => ({ sync: true, defaultValue: prop("defaultInputValue"), value: prop("inputValue"), onChange(value) { prop("onInputValueChange")?.({ inputValue: value }); } })), fieldsetDisabled: bindable(() => ({ defaultValue: false })), editedTagValue: bindable(() => ({ defaultValue: "" })), editedTagId: bindable(() => ({ defaultValue: null })), editedTagIndex: bindable(() => ({ defaultValue: null, sync: true })), highlightedTagId: bindable(() => ({ defaultValue: null, sync: true, onChange(value) { prop("onHighlightChange")?.({ highlightedValue: value }); } })) }; }, computed: { count: ({ context }) => context.get("value").length, valueAsString: ({ context }) => context.hash("value"), sanitizedInputValue: ({ context, prop }) => prop("sanitizeValue")(context.get("inputValue")), isDisabled: ({ prop }) => !!prop("disabled"), isInteractive: ({ prop }) => !(prop("readOnly") || !!prop("disabled")), isAtMax: ({ context, prop }) => context.get("value").length === prop("max"), isOverflowing: ({ context, prop }) => context.get("value").length > prop("max") }, watch({ track, context, action, computed, refs }) { track([() => computed("valueAsString")], () => { action(["dispatchChangeEvent"]); }); track([() => context.get("editedTagValue")], () => { action(["syncEditedTagInputValue"]); }); track([() => context.get("inputValue")], () => { action(["syncInputValue"]); }); track([() => context.get("highlightedTagId")], () => { action(["logHighlightedTag"]); }); track([() => computed("isOverflowing")], () => { action(["invokeOnInvalid"]); }); track([() => JSON.stringify(refs.get("log"))], () => { action(["announceLog"]); }); }, effects: ["trackLiveRegion", "trackFormControlState"], exit: ["clearLog"], on: { DOUBLE_CLICK_TAG: { // internal: true, guard: "isTagEditable", target: "editing:tag", actions: ["setEditedId"] }, POINTER_DOWN_TAG: { // internal: true, target: "navigating:tag", actions: ["highlightTag", "focusInput"] }, CLICK_DELETE_TAG: { target: "focused:input", actions: ["deleteTag"] }, SET_INPUT_VALUE: { actions: ["setInputValue"] }, SET_VALUE: { actions: ["setValue"] }, CLEAR_TAG: { actions: ["deleteTag"] }, SET_VALUE_AT_INDEX: { actions: ["setValueAtIndex"] }, CLEAR_VALUE: { actions: ["clearTags", "clearInputValue", "focusInput"] }, ADD_TAG: { actions: ["addTag"] }, INSERT_TAG: { // (!isAtMax || allowOverflow) && !inputValueIsEmpty guard: and(or(not("isAtMax"), "allowOverflow"), not("isInputValueEmpty")), actions: ["addTag", "clearInputValue"] }, EXTERNAL_BLUR: [ { guard: "addOnBlur", actions: ["raiseInsertTagEvent"] }, { guard: "clearOnBlur", actions: ["clearInputValue"] } ] }, states: { idle: { on: { FOCUS: { target: "focused:input" }, POINTER_DOWN: { guard: not("hasHighlightedTag"), target: "focused:input" } } }, "focused:input": { tags: ["focused"], entry: ["focusInput", "clearHighlightedId"], effects: ["trackInteractOutside"], on: { TYPE: { actions: ["setInputValue"] }, BLUR: [ { guard: "addOnBlur", target: "idle", actions: ["raiseInsertTagEvent"] }, { guard: "clearOnBlur", target: "idle", actions: ["clearInputValue"] }, { target: "idle" } ], ENTER: { actions: ["raiseInsertTagEvent"] }, DELIMITER_KEY: { actions: ["raiseInsertTagEvent"] }, ARROW_LEFT: { guard: and("hasTags", "isCaretAtStart"), target: "navigating:tag", actions: ["highlightLastTag"] }, BACKSPACE: { target: "navigating:tag", guard: and("hasTags", "isCaretAtStart"), actions: ["highlightLastTag"] }, DELETE: { guard: "hasHighlightedTag", actions: ["deleteHighlightedTag", "highlightTagAtIndex"] }, PASTE: [ { guard: "addOnPaste", actions: ["setInputValue", "addTagFromPaste"] }, { actions: ["setInputValue"] } ] } }, "navigating:tag": { tags: ["focused"], effects: ["trackInteractOutside"], on: { ARROW_RIGHT: [ { guard: and("hasTags", "isCaretAtStart", not("isLastTagHighlighted")), actions: ["highlightNextTag"] }, { target: "focused:input" } ], ARROW_LEFT: [ { guard: not("isCaretAtStart"), target: "focused:input" }, { actions: ["highlightPrevTag"] } ], BLUR: { target: "idle", actions: ["clearHighlightedId"] }, ENTER: { guard: and("isTagEditable", "hasHighlightedTag"), target: "editing:tag", actions: ["setEditedId", "focusEditedTagInput"] }, ARROW_DOWN: { target: "focused:input" }, ESCAPE: { target: "focused:input" }, TYPE: { target: "focused:input", actions: ["setInputValue"] }, BACKSPACE: [ { guard: not("isCaretAtStart"), target: "focused:input" }, { guard: "isFirstTagHighlighted", actions: ["deleteHighlightedTag", "highlightFirstTag"] }, { guard: "hasHighlightedTag", actions: ["deleteHighlightedTag", "highlightPrevTag"] }, { actions: ["highlightLastTag"] } ], DELETE: [ { guard: not("isCaretAtStart"), target: "focused:input" }, { target: "focused:input", actions: ["deleteHighlightedTag", "highlightTagAtIndex"] } ], PASTE: [ { guard: "addOnPaste", target: "focused:input", actions: ["setInputValue", "addTagFromPaste"] }, { target: "focused:input", actions: ["setInputValue"] } ] } }, "editing:tag": { tags: ["editing", "focused"], entry: ["focusEditedTagInput"], effects: ["autoResize"], on: { TAG_INPUT_TYPE: { actions: ["setEditedTagValue"] }, TAG_INPUT_ESCAPE: { target: "navigating:tag", actions: ["clearEditedTagValue", "focusInput", "clearEditedId", "highlightTagAtIndex"] }, TAG_INPUT_BLUR: [ { guard: "isInputRelatedTarget", target: "navigating:tag", actions: ["clearEditedTagValue", "clearHighlightedId", "clearEditedId"] }, { target: "idle", actions: ["clearEditedTagValue", "clearHighlightedId", "clearEditedId", "raiseExternalBlurEvent"] } ], TAG_INPUT_ENTER: [ { guard: "isEditedTagEmpty", target: "navigating:tag", actions: ["deleteHighlightedTag", "focusInput", "clearEditedId", "highlightTagAtIndex"] }, { target: "navigating:tag", actions: ["submitEditedTagValue", "focusInput", "clearEditedId", "highlightTagAtIndex"] } ] } } }, implementations: { guards: { isInputRelatedTarget: ({ scope, event }) => event.relatedTarget === dom.getInputEl(scope), isAtMax: ({ computed }) => computed("isAtMax"), hasHighlightedTag: ({ context }) => context.get("highlightedTagId") != null, isFirstTagHighlighted: ({ context, scope }) => { const value = context.get("value"); const firstItemId = dom.getItemId(scope, { value: value[0], index: 0 }); return firstItemId === context.get("highlightedTagId"); }, isEditedTagEmpty: ({ context, prop }) => prop("sanitizeValue")(context.get("editedTagValue")) === "", isLastTagHighlighted: ({ context, scope }) => { const value = context.get("value"); const lastIndex = value.length - 1; const lastItemId = dom.getItemId(scope, { value: value[lastIndex], index: lastIndex }); return lastItemId === context.get("highlightedTagId"); }, isInputValueEmpty: ({ context, prop }) => prop("sanitizeValue")(context.get("inputValue")).length === 0, hasTags: ({ context }) => context.get("value").length > 0, allowOverflow: ({ prop }) => !!prop("allowOverflow"), autoFocus: ({ prop }) => !!prop("autoFocus"), addOnBlur: ({ prop }) => prop("blurBehavior") === "add", clearOnBlur: ({ prop }) => prop("blurBehavior") === "clear", addOnPaste: ({ prop }) => !!prop("addOnPaste"), isTagEditable: ({ prop }) => !!prop("editable"), isCaretAtStart: ({ scope }) => isCaretAtStart(dom.getInputEl(scope)) }, effects: { trackInteractOutside({ scope, prop, send }) { return trackInteractOutside(dom.getInputEl(scope), { exclude(target) { const itemEls = dom.getItemEls(scope); return itemEls.some((el) => contains(el, target)); }, onFocusOutside: prop("onFocusOutside"), onPointerDownOutside: prop("onPointerDownOutside"), onInteractOutside(event) { prop("onInteractOutside")?.(event); if (event.defaultPrevented) return; send({ type: "BLUR", src: "interact-outside" }); } }); }, trackFormControlState({ context, send, scope }) { return trackFormControl(dom.getHiddenInputEl(scope), { onFieldsetDisabledChange(disabled) { context.set("fieldsetDisabled", disabled); }, onFormReset() { const value = context.initial("value"); send({ type: "SET_VALUE", value, src: "form-reset" }); } }); }, autoResize({ context, prop, scope }) { let fn_cleanup; queueMicrotask(() => { const editedTagValue = context.get("editedTagValue"); const editedTagIndex = context.get("editedTagIndex"); if (!editedTagValue || editedTagIndex == null || !prop("editable")) return; const inputEl = dom.getTagInputEl(scope, { value: editedTagValue, index: editedTagIndex }); fn_cleanup = autoResizeInput(inputEl); }); return () => { fn_cleanup?.(); }; }, trackLiveRegion({ scope, refs }) { const liveRegion = createLiveRegion({ level: "assertive", document: scope.getDoc() }); refs.set("liveRegion", liveRegion); return () => liveRegion.destroy(); } }, actions: { raiseInsertTagEvent({ send }) { send({ type: "INSERT_TAG" }); }, raiseExternalBlurEvent({ send, event }) { send({ type: "EXTERNAL_BLUR", id: event.id }); }, dispatchChangeEvent({ scope, computed }) { queueMicrotask(() => { dom.dispatchInputEvent(scope, computed("valueAsString")); }); }, highlightNextTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId"); if (highlightedTagId == null) return; const next = dom.getNextEl(scope, highlightedTagId); context.set("highlightedTagId", next?.id ?? null); }, highlightFirstTag({ context, scope }) { raf(() => { const first = dom.getFirstEl(scope); context.set("highlightedTagId", first?.id ?? null); }); }, highlightLastTag({ context, scope }) { const last = dom.getLastEl(scope); context.set("highlightedTagId", last?.id ?? null); }, highlightPrevTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId"); if (highlightedTagId == null) return; const prev = dom.getPrevEl(scope, highlightedTagId); context.set("highlightedTagId", prev?.id ?? null); }, highlightTag({ context, event }) { context.set("highlightedTagId", event.id); }, highlightTagAtIndex({ context, scope }) { raf(() => { const idx = context.get("editedTagIndex"); if (idx == null) return; const tagEl = dom.getTagElAtIndex(scope, idx); if (tagEl == null) return; context.set("highlightedTagId", tagEl.id); context.set("editedTagIndex", null); }); }, deleteTag({ context, scope, event, refs }) { const index = dom.getIndexOfId(scope, event.id); const value = context.get("value")[index]; const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "delete", value } }); context.set("value", (prev) => removeAt(prev, index)); }, deleteHighlightedTag({ context, scope, refs }) { const highlightedTagId = context.get("highlightedTagId"); if (highlightedTagId == null) return; const index = dom.getIndexOfId(scope, highlightedTagId); context.set("editedTagIndex", index); const value = context.get("value"); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "delete", value: value[index] } }); context.set("value", (prev) => removeAt(prev, index)); }, setEditedId({ context, event, scope }) { const highlightedTagId = context.get("highlightedTagId"); const editedTagId = event.id ?? highlightedTagId; context.set("editedTagId", editedTagId); const index = dom.getIndexOfId(scope, editedTagId); const valueAtIndex = context.get("value")[index]; context.set("editedTagIndex", index); context.set("editedTagValue", valueAtIndex); }, clearEditedId({ context }) { context.set("editedTagId", null); }, clearEditedTagValue({ context }) { context.set("editedTagValue", ""); }, setEditedTagValue({ context, event }) { context.set("editedTagValue", event.value); }, submitEditedTagValue({ context, scope, refs, prop }) { const editedTagId = context.get("editedTagId"); if (!editedTagId) return; const index = dom.getIndexOfId(scope, editedTagId); const editedTagValue = context.get("editedTagValue"); const value = context.get("value"); const hasDuplicate = value.some((item, idx) => idx !== index && item === editedTagValue); if (!prop("allowDuplicates") && hasDuplicate) return; if (value[index] === editedTagValue) return; context.set("value", (prev) => { const nextValue = prev.slice(); nextValue[index] = editedTagValue; return nextValue; }); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "update", value: editedTagValue } }); }, setValueAtIndex({ context, event, refs, prop }) { if (event.value) { const value = context.get("value"); const hasDuplicate = value.some((item, idx) => idx !== event.index && item === event.value); if (!prop("allowDuplicates") && hasDuplicate) return; if (value[event.index] === event.value) return; context.set("value", (prev) => { const nextValue = prev.slice(); nextValue[event.index] = event.value; return nextValue; }); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "update", value: event.value } }); } else { warn("You need to provide a value for the tag"); } }, focusEditedTagInput({ context, scope }) { raf(() => { const editedTagId = context.get("editedTagId"); if (!editedTagId) return; const editTagInputEl = dom.getEditInputEl(scope, editedTagId); editTagInputEl?.select(); }); }, setInputValue({ context, event }) { context.set("inputValue", event.value); }, clearHighlightedId({ context }) { context.set("highlightedTagId", null); }, focusInput({ scope }) { raf(() => { dom.getInputEl(scope)?.focus(); }); }, clearInputValue({ context }) { raf(() => { context.set("inputValue", ""); }); }, syncInputValue({ context, scope }) { const inputEl = dom.getInputEl(scope); if (!inputEl) return; setElementValue(inputEl, context.get("inputValue")); }, syncEditedTagInputValue({ context, event, scope }) { const id = context.get("editedTagId") || context.get("highlightedTagId") || event.id; if (id == null) return; const editTagInputEl = dom.getEditInputEl(scope, id); if (!editTagInputEl) return; setElementValue(editTagInputEl, context.get("editedTagValue")); }, addTag({ context, event, computed, prop, refs }) { const inputValue = event.value ?? computed("sanitizedInputValue"); const value = context.get("value"); const guard = prop("validate")?.({ inputValue, value: Array.from(value) }); if (guard) { const nextValue = prop("allowDuplicates") ? value.concat(inputValue) : uniq(value.concat(inputValue)); context.set("value", nextValue); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "add", value: inputValue } }); } else { prop("onValueInvalid")?.({ reason: "invalidTag" }); } }, addTagFromPaste({ context, computed, prop, refs }) { raf(() => { const inputValue = computed("sanitizedInputValue"); const value = context.get("value"); const sanitize = prop("sanitizeValue"); const guard = prop("validate")?.({ inputValue, value: Array.from(value) }); if (guard) { const delimiter = prop("delimiter"); const sanitizedValues = delimiter ? inputValue.split(delimiter).map(sanitize) : [inputValue]; const nextValue = prop("allowDuplicates") ? value.concat(...sanitizedValues) : uniq(value.concat(...sanitizedValues)); context.set("value", nextValue); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "paste", values: sanitizedValues } }); } else { prop("onValueInvalid")?.({ reason: "invalidTag" }); } context.set("inputValue", ""); }); }, clearTags({ context, refs }) { context.set("value", []); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "clear" } }); }, setValue({ context, event }) { context.set("value", event.value); }, invokeOnInvalid({ prop, computed }) { if (computed("isOverflowing")) { prop("onValueInvalid")?.({ reason: "rangeOverflow" }); } }, clearLog({ refs }) { const log = refs.get("log"); log.prev = log.current = null; }, logHighlightedTag({ refs, context, scope }) { const highlightedTagId = context.get("highlightedTagId"); const log = refs.get("log"); if (highlightedTagId == null || !log.current) return; const index = dom.getIndexOfId(scope, highlightedTagId); const value = context.get("value")[index]; const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "select", value } }); }, // queue logs with screen reader and get it announced announceLog({ refs, prop }) { const liveRegion = refs.get("liveRegion"); const translations = prop("translations"); const log = refs.get("log"); if (!log.current || liveRegion == null) return; const region = liveRegion; const { current, prev } = log; let msg; switch (current.type) { case "add": msg = translations.tagAdded(current.value); break; case "delete": msg = translations.tagDeleted(current.value); break; case "update": msg = translations.tagUpdated(current.value); break; case "paste": msg = translations.tagsPasted(current.values); break; case "select": msg = translations.tagSelected(current.value); if (prev?.type === "delete") { msg = `${translations.tagDeleted(prev.value)}. ${msg}`; } else if (prev?.type === "update") { msg = `${translations.tagUpdated(prev.value)}. ${msg}`; } break; default: break; } if (msg) region.announce(msg); } } } }); export { machine };