UNPKG

@zag-js/tags-input

Version:

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

1,080 lines (1,076 loc) • 36 kB
import { createAnatomy } from '@zag-js/anatomy'; import { isCaretAtStart, indexOfId, raf, setElementValue, queryAll, prevById, nextById, trackFormControl, contains, dispatchInputValueEvent, dataAttr, isLeftClick, isComposingEvent, ariaAttr, getEventKey, getNativeEvent } from '@zag-js/dom-query'; import { autoResizeInput } from '@zag-js/auto-resize'; import { createGuards, createMachine } from '@zag-js/core'; import { trackInteractOutside } from '@zag-js/interact-outside'; import { createLiveRegion } from '@zag-js/live-region'; import { uniq, warn, removeAt, isEqual, createSplitProps } from '@zag-js/utils'; import { createProps } from '@zag-js/types'; // src/tags-input.anatomy.ts var anatomy = createAnatomy("tagsInput").parts( "root", "label", "control", "input", "clearTrigger", "item", "itemPreview", "itemInput", "itemText", "itemDeleteTrigger" ); var parts = anatomy.build(); var getRootId = (ctx) => ctx.ids?.root ?? `tags-input:${ctx.id}`; var getInputId = (ctx) => ctx.ids?.input ?? `tags-input:${ctx.id}:input`; var getClearTriggerId = (ctx) => ctx.ids?.clearBtn ?? `tags-input:${ctx.id}:clear-btn`; var getHiddenInputId = (ctx) => ctx.ids?.hiddenInput ?? `tags-input:${ctx.id}:hidden-input`; var getLabelId = (ctx) => ctx.ids?.label ?? `tags-input:${ctx.id}:label`; var getControlId = (ctx) => ctx.ids?.control ?? `tags-input:${ctx.id}:control`; var getItemId = (ctx, opt) => ctx.ids?.item?.(opt) ?? `tags-input:${ctx.id}:tag:${opt.value}:${opt.index}`; var getItemDeleteTriggerId = (ctx, opt) => ctx.ids?.itemDeleteTrigger?.(opt) ?? `${getItemId(ctx, opt)}:delete-btn`; var getItemInputId = (ctx, opt) => ctx.ids?.itemInput?.(opt) ?? `${getItemId(ctx, opt)}:input`; var getEditInputId = (id) => `${id}:input`; var getEditInputEl = (ctx, id) => ctx.getById(getEditInputId(id)); var getTagInputEl = (ctx, opt) => ctx.getById(getItemInputId(ctx, opt)); var getRootEl = (ctx) => ctx.getById(getRootId(ctx)); var getInputEl = (ctx) => ctx.getById(getInputId(ctx)); var getHiddenInputEl = (ctx) => ctx.getById(getHiddenInputId(ctx)); var getTagElements = (ctx) => queryAll(getRootEl(ctx), `[data-part=item-preview]:not([data-disabled])`); var getFirstEl = (ctx) => getTagElements(ctx)[0]; var getLastEl = (ctx) => getTagElements(ctx)[getTagElements(ctx).length - 1]; var getPrevEl = (ctx, id) => prevById(getTagElements(ctx), id, false); var getNextEl = (ctx, id) => nextById(getTagElements(ctx), id, false); var getTagElAtIndex = (ctx, index) => getTagElements(ctx)[index]; var getIndexOfId = (ctx, id) => indexOfId(getTagElements(ctx), id); var setHoverIntent = (el) => { const tagEl = el.closest("[data-part=item-preview]"); if (!tagEl) return; tagEl.dataset.deleteIntent = ""; }; var clearHoverIntent = (el) => { const tagEl = el.closest("[data-part=item-preview]"); if (!tagEl) return; delete tagEl.dataset.deleteIntent; }; var dispatchInputEvent = (ctx, value) => { const inputEl = getHiddenInputEl(ctx); if (!inputEl) return; dispatchInputValueEvent(inputEl, { value }); }; // src/tags-input.connect.ts function connect(service, normalize) { const { state, send, computed, prop, scope, context } = service; const interactive = computed("isInteractive"); const disabled = prop("disabled"); const readOnly = prop("readOnly"); const invalid = prop("invalid") || computed("isOverflowing"); const translations = prop("translations"); const focused = state.hasTag("focused"); const editingTag = state.matches("editing:tag"); const empty = computed("count") === 0; function getItemState(options) { const id = getItemId(scope, options); const editedTagId = context.get("editedTagId"); const highlightedTagId = context.get("highlightedTagId"); return { id, editing: editingTag && editedTagId === id, highlighted: id === highlightedTagId, disabled: Boolean(options.disabled || disabled) }; } return { empty, inputValue: computed("trimmedInputValue"), value: context.get("value"), valueAsString: computed("valueAsString"), count: computed("count"), atMax: computed("isAtMax"), setValue(value) { send({ type: "SET_VALUE", value }); }, clearValue(id) { if (id) { send({ type: "CLEAR_TAG", id }); } else { send({ type: "CLEAR_VALUE" }); } }, addValue(value) { send({ type: "ADD_TAG", value }); }, setValueAtIndex(index, value) { send({ type: "SET_VALUE_AT_INDEX", index, value }); }, setInputValue(value) { send({ type: "SET_INPUT_VALUE", value }); }, clearInputValue() { send({ type: "SET_INPUT_VALUE", value: "" }); }, focus() { getInputEl(scope)?.focus(); }, getItemState, getRootProps() { return normalize.element({ dir: prop("dir"), ...parts.root.attrs, "data-invalid": dataAttr(invalid), "data-readonly": dataAttr(readOnly), "data-disabled": dataAttr(disabled), "data-focus": dataAttr(focused), "data-empty": dataAttr(empty), id: getRootId(scope), onPointerDown() { if (!interactive) return; send({ type: "POINTER_DOWN" }); } }); }, getLabelProps() { return normalize.label({ ...parts.label.attrs, "data-disabled": dataAttr(disabled), "data-invalid": dataAttr(invalid), "data-readonly": dataAttr(readOnly), id: getLabelId(scope), dir: prop("dir"), htmlFor: getInputId(scope) }); }, getControlProps() { return normalize.element({ id: getControlId(scope), ...parts.control.attrs, dir: prop("dir"), tabIndex: readOnly ? 0 : void 0, "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), "data-invalid": dataAttr(invalid), "data-focus": dataAttr(focused) }); }, getInputProps() { return normalize.input({ ...parts.input.attrs, dir: prop("dir"), "data-invalid": dataAttr(invalid), "aria-invalid": ariaAttr(invalid), "data-readonly": dataAttr(readOnly), maxLength: prop("maxLength"), id: getInputId(scope), defaultValue: context.get("inputValue"), autoComplete: "off", autoCorrect: "off", autoCapitalize: "none", disabled: disabled || readOnly, onInput(event) { const evt = getNativeEvent(event); const value = event.currentTarget.value; if (evt.inputType === "insertFromPaste") { send({ type: "PASTE", value }); return; } if (endsWith(value, prop("delimiter"))) { send({ type: "DELIMITER_KEY" }); return; } send({ type: "TYPE", value, key: evt.inputType }); }, onFocus() { queueMicrotask(() => { send({ type: "FOCUS" }); }); }, onKeyDown(event) { if (event.defaultPrevented) return; if (isComposingEvent(event)) return; const target = event.currentTarget; const isCombobox = target.getAttribute("role") === "combobox"; const isExpanded = target.ariaExpanded === "true"; const keyMap = { ArrowDown() { send({ type: "ARROW_DOWN" }); }, ArrowLeft() { if (isCombobox && isExpanded) return; send({ type: "ARROW_LEFT" }); }, ArrowRight(event2) { if (context.get("highlightedTagId")) { event2.preventDefault(); } if (isCombobox && isExpanded) return; send({ type: "ARROW_RIGHT" }); }, Escape(event2) { event2.preventDefault(); send({ type: "ESCAPE" }); }, Backspace() { send({ type: "BACKSPACE" }); }, Delete() { send({ type: "DELETE" }); }, Enter(event2) { if (isCombobox && isExpanded) return; send({ type: "ENTER" }); event2.preventDefault(); } }; const key = getEventKey(event, { dir: prop("dir") }); const exec = keyMap[key]; if (exec) { exec(event); return; } } }); }, getHiddenInputProps() { return normalize.input({ type: "text", hidden: true, name: prop("name"), form: prop("form"), disabled, readOnly, required: prop("required"), id: getHiddenInputId(scope), defaultValue: computed("valueAsString") }); }, getItemProps(props2) { return normalize.element({ ...parts.item.attrs, dir: prop("dir"), "data-value": props2.value, "data-disabled": dataAttr(disabled) }); }, getItemPreviewProps(props2) { const itemState = getItemState(props2); return normalize.element({ ...parts.itemPreview.attrs, id: itemState.id, dir: prop("dir"), hidden: itemState.editing, "data-value": props2.value, "data-disabled": dataAttr(disabled), "data-highlighted": dataAttr(itemState.highlighted), onPointerDown(event) { if (!interactive || itemState.disabled) return; if (!isLeftClick(event)) return; event.preventDefault(); send({ type: "POINTER_DOWN_TAG", id: itemState.id }); }, onDoubleClick() { if (!interactive || itemState.disabled) return; send({ type: "DOUBLE_CLICK_TAG", id: itemState.id }); } }); }, getItemTextProps(props2) { const itemState = getItemState(props2); return normalize.element({ ...parts.itemText.attrs, dir: prop("dir"), "data-disabled": dataAttr(disabled), "data-highlighted": dataAttr(itemState.highlighted) }); }, getItemInputProps(props2) { const itemState = getItemState(props2); return normalize.input({ ...parts.itemInput.attrs, dir: prop("dir"), "aria-label": translations?.tagEdited?.(props2.value), disabled, id: getItemInputId(scope, props2), tabIndex: -1, hidden: !itemState.editing, defaultValue: itemState.editing ? context.get("editedTagValue") : "", onInput(event) { send({ type: "TAG_INPUT_TYPE", value: event.currentTarget.value }); }, onBlur(event) { queueMicrotask(() => { send({ type: "TAG_INPUT_BLUR", target: event.relatedTarget, id: itemState.id }); }); }, onKeyDown(event) { if (event.defaultPrevented) return; if (isComposingEvent(event)) return; const keyMap = { Enter() { send({ type: "TAG_INPUT_ENTER" }); }, Escape() { send({ type: "TAG_INPUT_ESCAPE" }); } }; const exec = keyMap[event.key]; if (exec) { event.preventDefault(); exec(event); } } }); }, getItemDeleteTriggerProps(props2) { const id = getItemId(scope, props2); return normalize.button({ ...parts.itemDeleteTrigger.attrs, dir: prop("dir"), id: getItemDeleteTriggerId(scope, props2), type: "button", disabled, "aria-label": translations?.deleteTagTriggerLabel?.(props2.value), tabIndex: -1, onPointerDown(event) { if (!isLeftClick(event)) return; if (!interactive) { event.preventDefault(); } }, onPointerMove(event) { if (!interactive) return; setHoverIntent(event.currentTarget); }, onPointerLeave(event) { if (!interactive) return; clearHoverIntent(event.currentTarget); }, onClick(event) { if (event.defaultPrevented) return; if (!interactive) return; send({ type: "CLICK_DELETE_TAG", id }); } }); }, getClearTriggerProps() { return normalize.button({ ...parts.clearTrigger.attrs, dir: prop("dir"), id: getClearTriggerId(scope), type: "button", "data-readonly": dataAttr(readOnly), disabled, "aria-label": translations?.clearTriggerLabel, hidden: empty, onClick() { if (!interactive) return; send({ type: "CLEAR_VALUE" }); } }); } }; } function endsWith(str, del) { if (!del) return false; if (typeof del === "string") return str.endsWith(del); return new RegExp(`${del.source}$`).test(str); } var { and, not, or } = createGuards(); var machine = createMachine({ props({ props: props2 }) { return { dir: "ltr", addOnPaste: false, editable: true, validate: () => true, delimiter: ",", defaultValue: [], defaultInputValue: "", max: Infinity, ...props2, 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.`, ...props2.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"), trimmedInputValue: ({ context }) => context.get("inputValue").trim(), 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([() => 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 === getInputEl(scope), isAtMax: ({ computed }) => computed("isAtMax"), hasHighlightedTag: ({ context }) => context.get("highlightedTagId") != null, isFirstTagHighlighted: ({ context, scope }) => { const value = context.get("value"); const firstItemId = getItemId(scope, { value: value[0], index: 0 }); return firstItemId === context.get("highlightedTagId"); }, isEditedTagEmpty: ({ context }) => context.get("editedTagValue").trim() === "", isLastTagHighlighted: ({ context, scope }) => { const value = context.get("value"); const lastIndex = value.length - 1; const lastItemId = getItemId(scope, { value: value[lastIndex], index: lastIndex }); return lastItemId === context.get("highlightedTagId"); }, isInputValueEmpty: ({ context }) => context.get("inputValue").trim().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(getInputEl(scope)) }, effects: { trackInteractOutside({ scope, prop, send }) { return trackInteractOutside(getInputEl(scope), { exclude(target) { return contains(getRootEl(scope), 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(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; const raf_cleanup = raf(() => { const editedTagValue = context.get("editedTagValue"); const editedTagIndex = context.get("editedTagIndex"); if (!editedTagValue || editedTagIndex == null || !prop("editable")) return; const inputEl = getTagInputEl(scope, { value: editedTagValue, index: editedTagIndex }); fn_cleanup = autoResizeInput(inputEl); }); return () => { raf_cleanup(); 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 }) { dispatchInputEvent(scope, computed("valueAsString")); }, highlightNextTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId"); if (highlightedTagId == null) return; const next = getNextEl(scope, highlightedTagId); context.set("highlightedTagId", next?.id ?? null); }, highlightFirstTag({ context, scope }) { raf(() => { const first = getFirstEl(scope); context.set("highlightedTagId", first?.id ?? null); }); }, highlightLastTag({ context, scope }) { const last = getLastEl(scope); context.set("highlightedTagId", last?.id ?? null); }, highlightPrevTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId"); if (highlightedTagId == null) return; const prev = 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 = getTagElAtIndex(scope, idx); if (tagEl == null) return; context.set("highlightedTagId", tagEl.id); context.set("editedTagIndex", null); }); }, deleteTag({ context, scope, event, refs }) { const index = 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 = 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 = 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 }) { const editedTagId = context.get("editedTagId"); if (!editedTagId) return; const index = getIndexOfId(scope, editedTagId); context.set("value", (prev) => { const value = prev.slice(); value[index] = context.get("editedTagValue"); return value; }); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "update", value: context.get("editedTagValue") } }); }, setValueAtIndex({ context, event, refs }) { if (event.value) { context.set("value", (prev) => { const value = prev.slice(); value[event.index] = event.value; return value; }); 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 = getEditInputEl(scope, editedTagId); editTagInputEl?.select(); }); }, setInputValue({ context, event }) { context.set("inputValue", event.value); }, clearHighlightedId({ context }) { context.set("highlightedTagId", null); }, focusInput({ scope }) { raf(() => { getInputEl(scope)?.focus(); }); }, clearInputValue({ context }) { raf(() => { context.set("inputValue", ""); }); }, syncInputValue({ context, scope }) { const inputEl = 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 = getEditInputEl(scope, id); if (!editTagInputEl) return; setElementValue(editTagInputEl, context.get("editedTagValue")); }, addTag({ context, event, computed, prop, refs }) { const inputValue = event.value ?? computed("trimmedInputValue"); const value = context.get("value"); const guard = prop("validate")?.({ inputValue, value: Array.from(value) }); if (guard) { const nextValue = 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("trimmedInputValue"); const value = context.get("value"); const guard = prop("validate")?.({ inputValue, value: Array.from(value) }); if (guard) { const delimiter = prop("delimiter"); const trimmedValue = delimiter ? inputValue.split(delimiter).map((v) => v.trim()) : [inputValue]; const nextValue = uniq(value.concat(...trimmedValue)); context.set("value", nextValue); const prevLog = refs.get("log"); refs.set("log", { prev: prevLog.current, current: { type: "paste", values: trimmedValue } }); } 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 = 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; } if (msg) region.announce(msg); } } } }); var props = createProps()([ "addOnPaste", "allowOverflow", "autoFocus", "blurBehavior", "delimiter", "dir", "disabled", "editable", "form", "getRootNode", "id", "ids", "inputValue", "invalid", "max", "maxLength", "name", "onFocusOutside", "onHighlightChange", "onInputValueChange", "onInteractOutside", "onPointerDownOutside", "onValueChange", "onValueInvalid", "required", "readOnly", "translations", "validate", "value", "defaultValue", "defaultInputValue" ]); var splitProps = createSplitProps(props); var itemProps = createProps()(["index", "disabled", "value"]); var splitItemProps = createSplitProps(itemProps); export { anatomy, connect, itemProps, machine, props, splitItemProps, splitProps };