[State Machine] Zag-js
import { createMachine } from "@zag-js/core";
type MachineState = {
value: "idle" | "focused";
};
type MachineContext = {
value: string[];
focusedIndex: number;
name?: string;
readonly isCompleted: boolean;
onCompleted?: (value: string[]) => void;
};
export type MachineOptions = {
value?: string[];
onCompleted?: (value: string[]) => void;
numOfFields: number;
name?: string;
};
function getNextValue(eventValue: string, currentValue: string) {
let nextValue = eventValue;
if (currentValue[0] === eventValue[0]) {
// "2", "29" => "9"
nextValue = eventValue[1];
} else if (currentValue[0] === eventValue[1]) {
// "2", "92" => "9"
nextValue = eventValue[0];
}
// "2", "22" => "2"
return nextValue;
}
export function machine(options: MachineOptions) {
const { numOfFields, name, ...restOptions } = options;
return createMachine<MachineContext, MachineState>(
{
id: "pin-input",
initial: "idle",
context: {
name: name ?? "pin-input",
value: Array.from<string>({ length: numOfFields }).fill(""),
focusedIndex: -1,
...restOptions,
},
computed: {
isCompleted(context) {
return context.value.every((val) => val !== "");
},
},
watch: {
focusedIndex: ["executeFocus"],
isCompleted: ["invokeOnCompleted"],
},
states: {
idle: {
on: {
FOCUS: {
target: "focused",
actions: ["setFocusedIndex"],
},
LABEL_CLICK: {
actions: ["focusFirstInput"],
},
},
},
focused: {
on: {
BLUR: {
target: "idle",
actions: ["clearFocusedIndex"],
},
INPUT: {
actions: ["setFocusedValue", "focusNextInput"],
},
BACKSPACE: {
actions: ["clearFocusedValue", "focusPreviousInput"],
},
PASTE: {
actions: ["setPastedValue", "focusLastEmptyInput"],
},
},
},
},
},
{
actions: {
setFocusedIndex(context, event) {
context.focusedIndex = event.index;
},
clearFocusedValue(context) {
context.value[context.focusedIndex] = "";
},
clearFocusedIndex(context, event) {
context.focusedIndex = -1;
},
setFocusedValue(context, event) {
// should only allow latest input value
const nextValue = getNextValue(
event.value,
context.value[context.focusedIndex]
);
context.value[context.focusedIndex] = nextValue;
},
setPastedValue(context, event) {
const pastedValue: string[] = event.value
.split("")
.slice(0, context.value.length);
pastedValue.forEach((value, index) => {
context.value[index] = value;
});
},
focusFirstInput(context) {
context.focusedIndex = 0;
},
focusNextInput(context, event) {
const nextIndex = Math.min(event.index + 1, context.value.length - 1);
context.focusedIndex = nextIndex;
},
focusPreviousInput(context, event) {
const prevIndex = Math.max(context.focusedIndex - 1, 0);
context.focusedIndex = prevIndex;
},
focusLastEmptyInput(context) {
const index = context.value.findIndex((value) => value === "");
const lastIndex = context.value.length - 1;
context.focusedIndex = index === -1 ? lastIndex : index;
},
executeFocus(context) {
const inpugGroup = document.querySelector("[data-part=input-group]");
if (!inpugGroup || context.focusedIndex < 0) return;
const inputElements = Array.from<HTMLInputElement>(
document.querySelectorAll("[data-part=input]")
);
const input = inputElements[context.focusedIndex];
// avoid mutli dom mutation problem
// schedule for next event
requestAnimationFrame(() => {
input?.focus();
});
},
invokeOnCompleted(context) {
if (!context.isCompleted) return;
context.onCompleted?.(Array.from(context.value));
},
},
}
);
}