@zag-js/editable
Version:
Core logic for the editable widget implemented as a state machine
529 lines (523 loc) • 16.1 kB
JavaScript
;
var anatomy$1 = require('@zag-js/anatomy');
var domQuery = require('@zag-js/dom-query');
var core = require('@zag-js/core');
var interactOutside = require('@zag-js/interact-outside');
var types = require('@zag-js/types');
var utils = require('@zag-js/utils');
// src/editable.anatomy.ts
var anatomy = anatomy$1.createAnatomy("editable").parts(
"root",
"area",
"label",
"preview",
"input",
"editTrigger",
"submitTrigger",
"cancelTrigger",
"control"
);
var parts = anatomy.build();
// src/editable.dom.ts
var getRootId = (ctx) => ctx.ids?.root ?? `editable:${ctx.id}`;
var getAreaId = (ctx) => ctx.ids?.area ?? `editable:${ctx.id}:area`;
var getLabelId = (ctx) => ctx.ids?.label ?? `editable:${ctx.id}:label`;
var getPreviewId = (ctx) => ctx.ids?.preview ?? `editable:${ctx.id}:preview`;
var getInputId = (ctx) => ctx.ids?.input ?? `editable:${ctx.id}:input`;
var getControlId = (ctx) => ctx.ids?.control ?? `editable:${ctx.id}:control`;
var getSubmitTriggerId = (ctx) => ctx.ids?.submitTrigger ?? `editable:${ctx.id}:submit`;
var getCancelTriggerId = (ctx) => ctx.ids?.cancelTrigger ?? `editable:${ctx.id}:cancel`;
var getEditTriggerId = (ctx) => ctx.ids?.editTrigger ?? `editable:${ctx.id}:edit`;
var getInputEl = (ctx) => ctx.getById(getInputId(ctx));
var getPreviewEl = (ctx) => ctx.getById(getPreviewId(ctx));
var getSubmitTriggerEl = (ctx) => ctx.getById(getSubmitTriggerId(ctx));
var getCancelTriggerEl = (ctx) => ctx.getById(getCancelTriggerId(ctx));
var getEditTriggerEl = (ctx) => ctx.getById(getEditTriggerId(ctx));
// src/editable.connect.ts
function connect(service, normalize) {
const { state, context, send, prop, scope, computed } = service;
const disabled = prop("disabled");
const interactive = computed("isInteractive");
const readOnly = prop("readOnly");
const invalid = prop("invalid");
const autoResize = prop("autoResize");
const translations = prop("translations");
const editing = state.matches("edit");
const placeholderProp = prop("placeholder");
const placeholder = typeof placeholderProp === "string" ? { edit: placeholderProp, preview: placeholderProp } : placeholderProp;
const value = context.get("value");
const empty = value.trim() === "";
const valueText = empty ? placeholder?.preview ?? "" : value;
return {
editing,
empty,
value,
valueText,
setValue(value2) {
send({ type: "VALUE.SET", value: value2, src: "setValue" });
},
clearValue() {
send({ type: "VALUE.SET", value: "", src: "clearValue" });
},
edit() {
if (!interactive) return;
send({ type: "EDIT" });
},
cancel() {
if (!interactive) return;
send({ type: "CANCEL" });
},
submit() {
if (!interactive) return;
send({ type: "SUBMIT" });
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
id: getRootId(scope),
dir: prop("dir")
});
},
getAreaProps() {
return normalize.element({
...parts.area.attrs,
id: getAreaId(scope),
dir: prop("dir"),
style: autoResize ? { display: "inline-grid" } : void 0,
"data-focus": domQuery.dataAttr(editing),
"data-disabled": domQuery.dataAttr(disabled),
"data-placeholder-shown": domQuery.dataAttr(empty)
});
},
getLabelProps() {
return normalize.label({
...parts.label.attrs,
id: getLabelId(scope),
dir: prop("dir"),
htmlFor: getInputId(scope),
"data-focus": domQuery.dataAttr(editing),
"data-invalid": domQuery.dataAttr(invalid),
onClick() {
if (editing) return;
const previewEl = getPreviewEl(scope);
previewEl?.focus({ preventScroll: true });
}
});
},
getInputProps() {
return normalize.input({
...parts.input.attrs,
dir: prop("dir"),
"aria-label": translations?.input,
name: prop("name"),
form: prop("form"),
id: getInputId(scope),
hidden: autoResize ? void 0 : !editing,
placeholder: placeholder?.edit,
maxLength: prop("maxLength"),
required: prop("required"),
disabled,
"data-disabled": domQuery.dataAttr(disabled),
readOnly,
"data-readonly": domQuery.dataAttr(readOnly),
"aria-invalid": domQuery.ariaAttr(invalid),
"data-invalid": domQuery.dataAttr(invalid),
"data-autoresize": domQuery.dataAttr(autoResize),
defaultValue: value,
size: autoResize ? 1 : void 0,
onChange(event) {
send({
type: "VALUE.SET",
src: "input.change",
value: event.currentTarget.value
});
},
onKeyDown(event) {
if (event.defaultPrevented) return;
if (domQuery.isComposingEvent(event)) return;
const keyMap = {
Escape() {
send({ type: "CANCEL" });
event.preventDefault();
},
Enter(event2) {
if (!computed("submitOnEnter")) return;
const { localName } = event2.currentTarget;
if (localName === "textarea") {
const submitMod = domQuery.isApple() ? event2.metaKey : event2.ctrlKey;
if (!submitMod) return;
send({ type: "SUBMIT", src: "keydown.enter" });
return;
}
if (localName === "input" && !event2.shiftKey && !event2.metaKey) {
send({ type: "SUBMIT", src: "keydown.enter" });
event2.preventDefault();
}
}
};
const exec = keyMap[event.key];
if (exec) {
exec(event);
}
},
style: autoResize ? {
gridArea: "1 / 1 / auto / auto",
visibility: !editing ? "hidden" : void 0
} : void 0
});
},
getPreviewProps() {
return normalize.element({
id: getPreviewId(scope),
...parts.preview.attrs,
dir: prop("dir"),
"data-placeholder-shown": domQuery.dataAttr(empty),
"aria-readonly": domQuery.ariaAttr(readOnly),
"data-readonly": domQuery.dataAttr(disabled),
"data-disabled": domQuery.dataAttr(disabled),
"aria-disabled": domQuery.ariaAttr(disabled),
"aria-invalid": domQuery.ariaAttr(invalid),
"data-invalid": domQuery.dataAttr(invalid),
"aria-label": translations?.edit,
"data-autoresize": domQuery.dataAttr(autoResize),
children: valueText,
hidden: autoResize ? void 0 : editing,
tabIndex: interactive ? 0 : void 0,
onClick() {
if (!interactive) return;
if (prop("activationMode") !== "click") return;
send({ type: "EDIT", src: "click" });
},
onFocus() {
if (!interactive) return;
if (prop("activationMode") !== "focus") return;
send({ type: "EDIT", src: "focus" });
},
onDoubleClick(event) {
if (event.defaultPrevented) return;
if (!interactive) return;
if (prop("activationMode") !== "dblclick") return;
send({ type: "EDIT", src: "dblclick" });
},
style: autoResize ? {
whiteSpace: "pre",
userSelect: "none",
gridArea: "1 / 1 / auto / auto",
visibility: editing ? "hidden" : void 0,
// in event the preview overflow's the parent element
overflow: "hidden",
textOverflow: "ellipsis"
} : void 0
});
},
getEditTriggerProps() {
return normalize.button({
...parts.editTrigger.attrs,
id: getEditTriggerId(scope),
dir: prop("dir"),
"aria-label": translations?.edit,
hidden: editing,
type: "button",
disabled,
onClick(event) {
if (event.defaultPrevented) return;
if (!interactive) return;
send({ type: "EDIT", src: "edit.click" });
}
});
},
getControlProps() {
return normalize.element({
id: getControlId(scope),
...parts.control.attrs,
dir: prop("dir")
});
},
getSubmitTriggerProps() {
return normalize.button({
...parts.submitTrigger.attrs,
dir: prop("dir"),
id: getSubmitTriggerId(scope),
"aria-label": translations?.submit,
hidden: !editing,
disabled,
type: "button",
onClick(event) {
if (event.defaultPrevented) return;
if (!interactive) return;
send({ type: "SUBMIT", src: "submit.click" });
}
});
},
getCancelTriggerProps() {
return normalize.button({
...parts.cancelTrigger.attrs,
dir: prop("dir"),
"aria-label": translations?.cancel,
id: getCancelTriggerId(scope),
hidden: !editing,
type: "button",
disabled,
onClick(event) {
if (event.defaultPrevented) return;
if (!interactive) return;
send({ type: "CANCEL", src: "cancel.click" });
}
});
}
};
}
var machine = core.createMachine({
props({ props: props2 }) {
return {
activationMode: "focus",
submitMode: "both",
defaultValue: "",
selectOnFocus: true,
...props2,
translations: {
input: "editable input",
edit: "edit",
submit: "submit",
cancel: "cancel",
...props2.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: ["blurInputIfNeeded"],
on: {
"CONTROLLED.EDIT": {
target: "edit",
actions: ["setPreviousValue", "focusInput"]
},
EDIT: [
{
guard: "isEditControlled",
actions: ["invokeOnEdit"]
},
{
target: "edit",
actions: ["setPreviousValue", "focusInput", "invokeOnEdit"]
}
]
}
},
edit: {
effects: ["trackInteractOutside"],
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 interactOutside.trackInteractOutside(getInputEl(scope), {
exclude(target) {
const ignore = [getCancelTriggerEl(scope), getSubmitTriggerEl(scope)];
return ignore.some((el) => domQuery.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;
domQuery.raf(() => {
const finalEl = prop("finalFocusEl")?.() ?? 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 }) {
domQuery.raf(() => {
const inputEl = 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 = getInputEl(scope);
if (!inputEl) return;
domQuery.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);
},
blurInputIfNeeded({ scope }) {
getInputEl(scope)?.blur();
}
}
}
});
var props = types.createProps()([
"activationMode",
"autoResize",
"dir",
"disabled",
"finalFocusEl",
"form",
"getRootNode",
"id",
"ids",
"invalid",
"maxLength",
"name",
"onEditChange",
"onFocusOutside",
"onInteractOutside",
"onPointerDownOutside",
"onValueChange",
"onValueCommit",
"onValueRevert",
"placeholder",
"readOnly",
"required",
"selectOnFocus",
"edit",
"defaultEdit",
"submitMode",
"translations",
"defaultValue",
"value"
]);
var splitProps = utils.createSplitProps(props);
exports.anatomy = anatomy;
exports.connect = connect;
exports.machine = machine;
exports.props = props;
exports.splitProps = splitProps;