UNPKG

@zag-js/timer

Version:

Core logic for the timer widget implemented as a state machine

380 lines (374 loc) 10.5 kB
'use strict'; var anatomy$1 = require('@zag-js/anatomy'); var utils = require('@zag-js/utils'); var core = require('@zag-js/core'); var types = require('@zag-js/types'); // src/timer.anatomy.ts var anatomy = anatomy$1.createAnatomy("timer").parts( "root", "area", "control", "item", "itemValue", "itemLabel", "actionTrigger", "separator" ); var parts = anatomy.build(); // src/timer.dom.ts var getRootId = (ctx) => ctx.ids?.root ?? `timer:${ctx.id}:root`; var getAreaId = (ctx) => ctx.ids?.area ?? `timer:${ctx.id}:area`; // src/timer.connect.ts var validActions = /* @__PURE__ */ new Set(["start", "pause", "resume", "reset", "restart"]); function connect(service, normalize) { const { state, send, computed, scope } = service; const running = state.matches("running"); const paused = state.matches("paused"); const time = computed("time"); const formattedTime = computed("formattedTime"); const progressPercent = computed("progressPercent"); return { running, paused, time, formattedTime, progressPercent, start() { send({ type: "START" }); }, pause() { send({ type: "PAUSE" }); }, resume() { send({ type: "RESUME" }); }, reset() { send({ type: "RESET" }); }, restart() { send({ type: "RESTART" }); }, getRootProps() { return normalize.element({ id: getRootId(scope), ...parts.root.attrs }); }, getAreaProps() { return normalize.element({ role: "timer", id: getAreaId(scope), "aria-label": `${time.days} days ${formattedTime.hours}:${formattedTime.minutes}:${formattedTime.seconds}`, "aria-atomic": true, ...parts.area.attrs }); }, getControlProps() { return normalize.element({ ...parts.control.attrs }); }, getItemProps(props2) { const value = time[props2.type]; return normalize.element({ ...parts.item.attrs, "data-type": props2.type, style: { "--value": value } }); }, getItemLabelProps(props2) { return normalize.element({ ...parts.itemLabel.attrs, "data-type": props2.type }); }, getItemValueProps(props2) { return normalize.element({ ...parts.itemValue.attrs, "data-type": props2.type }); }, getSeparatorProps() { return normalize.element({ "aria-hidden": true, ...parts.separator.attrs }); }, getActionTriggerProps(props2) { if (!validActions.has(props2.action)) { throw new Error( `[zag-js] Invalid action: ${props2.action}. Must be one of: ${Array.from(validActions).join(", ")}` ); } return normalize.button({ ...parts.actionTrigger.attrs, hidden: utils.match(props2.action, { start: () => running || paused, pause: () => !running, reset: () => !running && !paused, resume: () => !paused, restart: () => false }), type: "button", onClick(event) { if (event.defaultPrevented) return; send({ type: props2.action.toUpperCase() }); } }); } }; } var machine = core.createMachine({ props({ props: props2 }) { validateProps(props2); return { interval: 1e3, ...props2 }; }, initialState({ prop }) { return prop("autoStart") ? "running" : "idle"; }, context({ prop, bindable }) { return { currentMs: bindable(() => ({ defaultValue: prop("startMs") ?? 0 })) }; }, watch({ track, send, prop }) { track([() => prop("startMs")], () => { send({ type: "RESTART" }); }); }, on: { RESTART: { target: "running:temp", actions: ["resetTime"] } }, computed: { time: ({ context }) => msToTime(context.get("currentMs")), formattedTime: ({ computed }) => formatTime(computed("time")), progressPercent: ({ context, prop }) => { const targetMs = prop("targetMs"); if (targetMs == null) return 0; const startMs = prop("startMs") ?? 0; const currentMs = context.get("currentMs"); if (prop("countdown")) { return utils.clampValue(toPercent(currentMs, targetMs, startMs), 0, 1); } return utils.clampValue(toPercent(currentMs, startMs, targetMs), 0, 1); } }, states: { idle: { on: { START: { target: "running" }, RESET: { actions: ["resetTime"] } } }, "running:temp": { effects: ["waitForNextTick"], on: { CONTINUE: { target: "running" } } }, running: { effects: ["keepTicking"], on: { PAUSE: { target: "paused" }, TICK: [ { target: "idle", guard: "hasReachedTarget", actions: ["invokeOnComplete"] }, { actions: ["updateTime", "invokeOnTick"] } ], RESET: { actions: ["resetTime"] } } }, paused: { on: { RESUME: { target: "running" }, RESET: { target: "idle", actions: ["resetTime"] } } } }, implementations: { effects: { keepTicking({ prop, send }) { return utils.setRafInterval(({ deltaMs }) => { send({ type: "TICK", deltaMs }); }, prop("interval")); }, waitForNextTick({ send }) { return utils.setRafTimeout(() => { send({ type: "CONTINUE" }); }, 0); } }, actions: { updateTime({ context, prop, event }) { const sign = prop("countdown") ? -1 : 1; const deltaMs = roundToInterval(event.deltaMs, prop("interval")); context.set("currentMs", (prev) => { const newValue = prev + sign * deltaMs; let targetMs = prop("targetMs"); if (targetMs == null && prop("countdown")) targetMs = 0; if (prop("countdown") && targetMs != null) { return Math.max(newValue, targetMs); } else if (!prop("countdown") && targetMs != null) { return Math.min(newValue, targetMs); } return newValue; }); }, resetTime({ context, prop }) { let targetMs = prop("targetMs"); if (targetMs == null && prop("countdown")) targetMs = 0; context.set("currentMs", prop("startMs") ?? 0); }, invokeOnTick({ context, prop, computed }) { prop("onTick")?.({ value: context.get("currentMs"), time: computed("time"), formattedTime: computed("formattedTime") }); }, invokeOnComplete({ prop }) { prop("onComplete")?.(); } }, guards: { hasReachedTarget: ({ context, prop }) => { let targetMs = prop("targetMs"); if (targetMs == null && prop("countdown")) targetMs = 0; if (targetMs == null) return false; const currentMs = context.get("currentMs"); return prop("countdown") ? currentMs <= targetMs : currentMs >= targetMs; } } } }); function msToTime(ms) { const time = Math.max(0, ms); const milliseconds = time % 1e3; const seconds = Math.floor(time / 1e3) % 60; const minutes = Math.floor(time / (1e3 * 60)) % 60; const hours = Math.floor(time / (1e3 * 60 * 60)) % 24; const days = Math.floor(time / (1e3 * 60 * 60 * 24)); return { days, hours, minutes, seconds, milliseconds }; } function toPercent(value, minValue, maxValue) { const range = maxValue - minValue; if (range === 0) return 0; return (value - minValue) / range; } function padStart(num, size = 2) { return num.toString().padStart(size, "0"); } function roundToInterval(value, interval) { return Math.floor(value / interval) * interval; } function formatTime(time) { const { days, hours, minutes, seconds } = time; return { days: padStart(days), hours: padStart(hours), minutes: padStart(minutes), seconds: padStart(seconds), milliseconds: padStart(time.milliseconds, 3) }; } function validateProps(props2) { const { startMs, targetMs, countdown, interval } = props2; if (interval != null && (typeof interval !== "number" || interval <= 0)) { throw new Error(`[timer] Invalid interval: ${interval}. Must be a positive number.`); } if (startMs != null && (typeof startMs !== "number" || startMs < 0)) { throw new Error(`[timer] Invalid startMs: ${startMs}. Must be a non-negative number.`); } if (targetMs != null && (typeof targetMs !== "number" || targetMs < 0)) { throw new Error(`[timer] Invalid targetMs: ${targetMs}. Must be a non-negative number.`); } if (countdown && startMs != null && targetMs != null) { if (startMs <= targetMs) { throw new Error( `[timer] Invalid countdown configuration: startMs (${startMs}) must be greater than targetMs (${targetMs}).` ); } } if (!countdown && startMs != null && targetMs != null) { if (startMs >= targetMs) { throw new Error( `[timer] Invalid stopwatch configuration: startMs (${startMs}) must be less than targetMs (${targetMs}).` ); } } if (countdown && targetMs == null && startMs != null && startMs <= 0) { throw new Error( `[timer] Invalid countdown configuration: startMs (${startMs}) must be greater than 0 when no targetMs is provided.` ); } } var segments = /* @__PURE__ */ new Set(["days", "hours", "minutes", "seconds"]); function isTimeSegment(date) { return utils.isObject(date) && Object.keys(date).some((key) => segments.has(key)); } function parse(date) { if (typeof date === "string") { return new Date(date).getTime(); } if (isTimeSegment(date)) { const { days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 } = date; const value = (days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds) * 1e3; return value + milliseconds; } throw new Error("Invalid date"); } var props = types.createProps()([ "autoStart", "countdown", "getRootNode", "id", "ids", "interval", "onComplete", "onTick", "startMs", "targetMs" ]); var splitProps = utils.createSplitProps(props); exports.anatomy = anatomy; exports.connect = connect; exports.machine = machine; exports.parse = parse; exports.props = props; exports.splitProps = splitProps;