@zag-js/editable
Version:
Core logic for the editable widget implemented as a state machine
227 lines (226 loc) • 6.53 kB
JavaScript
// src/editable.machine.ts
import { createMachine } from "@zag-js/core";
import { contains, raf, setElementValue } from "@zag-js/dom-query";
import { trackInteractOutside } from "@zag-js/interact-outside";
import * as dom from "./editable.dom.mjs";
var machine = createMachine({
props({ props }) {
return {
activationMode: "focus",
submitMode: "both",
defaultValue: "",
selectOnFocus: true,
...props,
translations: {
input: "editable input",
edit: "edit",
submit: "submit",
cancel: "cancel",
...props.translations
}
};
},
initialState({ prop }) {
const edit = prop("edit") || prop("defaultEdit");
return edit ? "edit" : "preview";
},
entry: ["focusInputIfNeeded"],
context: ({ bindable, prop }) => {
return {
value: bindable(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
onChange(value) {
return prop("onValueChange")?.({ value });
}
})),
previousValue: bindable(() => ({
defaultValue: ""
}))
};
},
watch({ track, action, context, prop }) {
track([() => context.get("value")], () => {
action(["syncInputValue"]);
});
track([() => prop("edit")], () => {
action(["toggleEditing"]);
});
},
computed: {
submitOnEnter({ prop }) {
const submitMode = prop("submitMode");
return submitMode === "both" || submitMode === "enter";
},
submitOnBlur({ prop }) {
const submitMode = prop("submitMode");
return submitMode === "both" || submitMode === "blur";
},
isInteractive({ prop }) {
return !(prop("disabled") || prop("readOnly"));
}
},
on: {
"VALUE.SET": {
actions: ["setValue"]
}
},
states: {
preview: {
entry: ["blurInput"],
on: {
"CONTROLLED.EDIT": {
target: "edit",
actions: ["setPreviousValue", "focusInput"]
},
EDIT: [
{
guard: "isEditControlled",
actions: ["invokeOnEdit"]
},
{
target: "edit",
actions: ["setPreviousValue", "focusInput", "invokeOnEdit"]
}
]
}
},
edit: {
effects: ["trackInteractOutside"],
entry: ["syncInputValue"],
on: {
"CONTROLLED.PREVIEW": [
{
guard: "isSubmitEvent",
target: "preview",
actions: ["setPreviousValue", "restoreFocus", "invokeOnSubmit"]
},
{
target: "preview",
actions: ["revertValue", "restoreFocus", "invokeOnCancel"]
}
],
CANCEL: [
{
guard: "isEditControlled",
actions: ["invokeOnPreview"]
},
{
target: "preview",
actions: ["revertValue", "restoreFocus", "invokeOnCancel", "invokeOnPreview"]
}
],
SUBMIT: [
{
guard: "isEditControlled",
actions: ["invokeOnPreview"]
},
{
target: "preview",
actions: ["setPreviousValue", "restoreFocus", "invokeOnSubmit", "invokeOnPreview"]
}
]
}
}
},
implementations: {
guards: {
isEditControlled: ({ prop }) => prop("edit") != void 0,
isSubmitEvent: ({ event }) => event.previousEvent?.type === "SUBMIT"
},
effects: {
trackInteractOutside({ send, scope, prop, computed }) {
return trackInteractOutside(dom.getInputEl(scope), {
exclude(target) {
const ignore = [dom.getCancelTriggerEl(scope), dom.getSubmitTriggerEl(scope)];
return ignore.some((el) => contains(el, target));
},
onFocusOutside: prop("onFocusOutside"),
onPointerDownOutside: prop("onPointerDownOutside"),
onInteractOutside(event) {
prop("onInteractOutside")?.(event);
if (event.defaultPrevented) return;
const { focusable } = event.detail;
send({
type: computed("submitOnBlur") ? "SUBMIT" : "CANCEL",
src: "interact-outside",
focusable
});
}
});
}
},
actions: {
restoreFocus({ event, scope, prop }) {
if (event.focusable) return;
raf(() => {
const finalEl = prop("finalFocusEl")?.() ?? dom.getEditTriggerEl(scope);
finalEl?.focus({ preventScroll: true });
});
},
clearValue({ context }) {
context.set("value", "");
},
focusInputIfNeeded({ action, prop }) {
const edit = prop("edit") || prop("defaultEdit");
if (!edit) return;
action(["focusInput"]);
},
focusInput({ scope, prop }) {
raf(() => {
const inputEl = dom.getInputEl(scope);
if (!inputEl) return;
if (prop("selectOnFocus")) {
inputEl.select();
} else {
inputEl.focus({ preventScroll: true });
}
});
},
invokeOnCancel({ prop, context }) {
const prev = context.get("previousValue");
prop("onValueRevert")?.({ value: prev });
},
invokeOnSubmit({ prop, context }) {
const value = context.get("value");
prop("onValueCommit")?.({ value });
},
invokeOnEdit({ prop }) {
prop("onEditChange")?.({ edit: true });
},
invokeOnPreview({ prop }) {
prop("onEditChange")?.({ edit: false });
},
toggleEditing({ prop, send, event }) {
send({
type: prop("edit") ? "CONTROLLED.EDIT" : "CONTROLLED.PREVIEW",
previousEvent: event
});
},
syncInputValue({ context, scope }) {
const inputEl = dom.getInputEl(scope);
if (!inputEl) return;
setElementValue(inputEl, context.get("value"));
},
setValue({ context, prop, event }) {
const max = prop("maxLength");
const value = max != null ? event.value.slice(0, max) : event.value;
context.set("value", value);
},
setPreviousValue({ context }) {
context.set("previousValue", context.get("value"));
},
revertValue({ context }) {
const value = context.get("previousValue");
if (!value) return;
context.set("value", value);
},
blurInput({ scope }) {
dom.getInputEl(scope)?.blur();
}
}
}
});
export {
machine
};