UNPKG

@zag-js/color-picker

Version:

Core logic for the color-picker widget implemented as a state machine

618 lines (617 loc) • 21.5 kB
// src/color-picker.machine.ts import { parseColor } from "@zag-js/color-utils"; import { createGuards, createMachine } from "@zag-js/core"; import { trackDismissableElement } from "@zag-js/dismissable"; import { disableTextSelection, dispatchInputValueEvent, getInitialFocus, raf, setElementValue, trackFormControl, trackPointerMove } from "@zag-js/dom-query"; import { getPlacement } from "@zag-js/popper"; import { tryCatch } from "@zag-js/utils"; import * as dom from "./color-picker.dom.mjs"; import { parse } from "./color-picker.parse.mjs"; import { getChannelValue } from "./utils/get-channel-input-value.mjs"; import { prefixHex } from "./utils/is-valid-hex.mjs"; var { and } = createGuards(); var hashObject = (obj) => { let hash = ""; for (const key in obj) hash += `${key}:${obj[key] ?? ""};`; return hash; }; var DEFAULT_COLOR = parse("#000000"); var machine = createMachine({ props({ props }) { const color = props.value ?? props.defaultValue ?? DEFAULT_COLOR; return { dir: "ltr", defaultValue: DEFAULT_COLOR, defaultFormat: color.getFormat(), openAutoFocus: true, ...props, positioning: { placement: "bottom", ...props.positioning } }; }, initialState({ prop }) { const open = prop("open") || prop("defaultOpen") || prop("inline"); return open ? "open" : "idle"; }, context({ prop, bindable, getContext }) { return { value: bindable(() => ({ defaultValue: prop("defaultValue").toFormat(prop("format") ?? prop("defaultFormat")), value: prop("value")?.toFormat(prop("format") ?? prop("defaultFormat")), isEqual(a, b) { return b != null && a.isEqual(b); }, hash(a) { return hashObject(a.toJSON()); }, onChange(value) { const ctx = getContext(); const format = ctx.get("format"); prop("onValueChange")?.({ value, valueAsString: value.toString(format) }); } })), format: bindable(() => ({ defaultValue: prop("defaultFormat"), value: prop("format"), onChange(format) { prop("onFormatChange")?.({ format }); } })), activeId: bindable(() => ({ defaultValue: null })), activeChannel: bindable(() => ({ defaultValue: null })), activeOrientation: bindable(() => ({ defaultValue: null })), fieldsetDisabled: bindable(() => ({ defaultValue: false })), restoreFocus: bindable(() => ({ defaultValue: true })), currentPlacement: bindable(() => ({ defaultValue: void 0 })) }; }, computed: { rtl: ({ prop }) => prop("dir") === "rtl", disabled: ({ prop, context }) => !!prop("disabled") || context.get("fieldsetDisabled"), interactive: ({ prop }) => !(prop("disabled") || prop("readOnly")), valueAsString: ({ context }) => context.get("value").toString(context.get("format")), areaValue: ({ context }) => { const format = context.get("format").startsWith("hsl") ? "hsla" : "hsba"; return context.get("value").toFormat(format); } }, effects: ["trackFormControl"], watch({ prop, context, action, track }) { track([() => context.hash("value")], () => { action(["syncInputElements", "dispatchChangeEvent"]); }); track([() => context.get("format")], () => { action(["syncFormatSelectElement", "syncValueWithFormat"]); }); track([() => prop("open")], () => { action(["toggleVisibility"]); }); }, on: { "VALUE.SET": { actions: ["setValue"] }, "FORMAT.SET": { actions: ["setFormat"] }, "CHANNEL_INPUT.CHANGE": { actions: ["setChannelColorFromInput"] }, "EYEDROPPER.CLICK": { actions: ["openEyeDropper"] }, "SWATCH_TRIGGER.CLICK": { actions: ["setValue"] } }, states: { idle: { tags: ["closed"], on: { "CONTROLLED.OPEN": { target: "open", actions: ["setInitialFocus"] }, OPEN: [ { guard: "isOpenControlled", actions: ["invokeOnOpen"] }, { target: "open", actions: ["invokeOnOpen", "setInitialFocus"] } ], "TRIGGER.CLICK": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"] }, { target: "open", actions: ["invokeOnOpen", "setInitialFocus"] } ], "CHANNEL_INPUT.FOCUS": { target: "focused", actions: ["setActiveChannel"] } } }, focused: { id: "color-picker-focused", tags: ["closed", "focused"], on: { "CONTROLLED.OPEN": { target: "open", actions: ["setInitialFocus"] }, OPEN: [ { guard: "isOpenControlled", actions: ["invokeOnOpen"] }, { target: "open", actions: ["invokeOnOpen", "setInitialFocus"] } ], "TRIGGER.CLICK": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"] }, { target: "open", actions: ["invokeOnOpen", "setInitialFocus"] } ], "CHANNEL_INPUT.FOCUS": { actions: ["setActiveChannel"] }, "CHANNEL_INPUT.BLUR": { target: "idle", actions: ["setChannelColorFromInput"] }, "TRIGGER.BLUR": { target: "idle" } } }, open: { tags: ["open"], effects: ["trackPositioning", "trackDismissableElement"], initial: "idle", on: { "CONTROLLED.CLOSE": [ { guard: "shouldRestoreFocus", target: "focused", actions: ["setReturnFocus"] }, { target: "idle" } ], INTERACT_OUTSIDE: [ { guard: "isOpenControlled", actions: ["invokeOnClose"] }, { guard: "shouldRestoreFocus", target: "focused", actions: ["invokeOnClose", "setReturnFocus"] }, { target: "idle", actions: ["invokeOnClose"] } ], CLOSE: [ { guard: "isOpenControlled", actions: ["invokeOnClose"] }, { target: "idle", actions: ["invokeOnClose"] } ] }, states: { idle: { on: { "TRIGGER.CLICK": [ { guard: "isOpenControlled", actions: ["invokeOnClose"] }, { target: "#color-picker-focused", actions: ["invokeOnClose"] } ], "AREA.POINTER_DOWN": { target: "dragging", actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.FOCUS": { actions: ["setActiveChannel"] }, "CHANNEL_SLIDER.POINTER_DOWN": { target: "dragging", actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"] }, "CHANNEL_SLIDER.FOCUS": { actions: ["setActiveChannel"] }, "AREA.ARROW_LEFT": { actions: ["decrementAreaXChannel"] }, "AREA.ARROW_RIGHT": { actions: ["incrementAreaXChannel"] }, "AREA.ARROW_UP": { actions: ["incrementAreaYChannel"] }, "AREA.ARROW_DOWN": { actions: ["decrementAreaYChannel"] }, "AREA.PAGE_UP": { actions: ["incrementAreaXChannel"] }, "AREA.PAGE_DOWN": { actions: ["decrementAreaXChannel"] }, "CHANNEL_SLIDER.ARROW_LEFT": { actions: ["decrementChannel"] }, "CHANNEL_SLIDER.ARROW_RIGHT": { actions: ["incrementChannel"] }, "CHANNEL_SLIDER.ARROW_UP": { actions: ["incrementChannel"] }, "CHANNEL_SLIDER.ARROW_DOWN": { actions: ["decrementChannel"] }, "CHANNEL_SLIDER.PAGE_UP": { actions: ["incrementChannel"] }, "CHANNEL_SLIDER.PAGE_DOWN": { actions: ["decrementChannel"] }, "CHANNEL_SLIDER.HOME": { actions: ["setChannelToMin"] }, "CHANNEL_SLIDER.END": { actions: ["setChannelToMax"] }, "CHANNEL_INPUT.BLUR": { actions: ["setChannelColorFromInput"] }, "SWATCH_TRIGGER.CLICK": [ { guard: and("isOpenControlled", "closeOnSelect"), actions: ["setValue", "invokeOnClose"] }, { guard: "closeOnSelect", target: "focused", actions: ["setValue", "invokeOnClose", "setReturnFocus"] }, { actions: ["setValue"] } ] } }, dragging: { tags: ["dragging"], exit: ["clearActiveChannel"], effects: ["trackPointerMove", "disableTextSelection"], on: { "AREA.POINTER_MOVE": { actions: ["setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.POINTER_UP": { target: "idle", actions: ["invokeOnChangeEnd"] }, "CHANNEL_SLIDER.POINTER_MOVE": { actions: ["setChannelColorFromPoint", "focusChannelThumb"] }, "CHANNEL_SLIDER.POINTER_UP": { target: "idle", actions: ["invokeOnChangeEnd"] } } } } } }, implementations: { guards: { closeOnSelect: ({ prop }) => !!prop("closeOnSelect"), isOpenControlled: ({ prop }) => prop("open") != null || !!prop("inline"), shouldRestoreFocus: ({ context }) => !!context.get("restoreFocus") }, effects: { trackPositioning({ context, prop, scope }) { if (prop("inline")) return; if (!context.get("currentPlacement")) { context.set("currentPlacement", prop("positioning")?.placement); } const anchorEl = dom.getTriggerEl(scope); const getPositionerEl2 = () => dom.getPositionerEl(scope); return getPlacement(anchorEl, getPositionerEl2, { ...prop("positioning"), defer: true, onComplete(data) { context.set("currentPlacement", data.placement); } }); }, trackDismissableElement({ context, scope, prop, send }) { if (prop("inline")) return; const getContentEl2 = () => dom.getContentEl(scope); return trackDismissableElement(getContentEl2, { type: "popover", exclude: dom.getTriggerEl(scope), defer: true, onInteractOutside(event) { prop("onInteractOutside")?.(event); if (event.defaultPrevented) return; context.set("restoreFocus", !(event.detail.focusable || event.detail.contextmenu)); }, onPointerDownOutside: prop("onPointerDownOutside"), onFocusOutside: prop("onFocusOutside"), onDismiss() { send({ type: "INTERACT_OUTSIDE" }); } }); }, trackFormControl({ context, scope, send }) { const inputEl = dom.getHiddenInputEl(scope); return trackFormControl(inputEl, { onFieldsetDisabledChange(disabled) { context.set("fieldsetDisabled", disabled); }, onFormReset() { send({ type: "VALUE.SET", value: context.initial("value"), src: "form.reset" }); } }); }, trackPointerMove({ context, scope, event, send }) { return trackPointerMove(scope.getDoc(), { onPointerMove({ point }) { const type = context.get("activeId") === "area" ? "AREA.POINTER_MOVE" : "CHANNEL_SLIDER.POINTER_MOVE"; send({ type, point, format: event.format, orientation: context.get("activeOrientation") ?? void 0 }); }, onPointerUp() { const type = context.get("activeId") === "area" ? "AREA.POINTER_UP" : "CHANNEL_SLIDER.POINTER_UP"; send({ type }); } }); }, disableTextSelection({ scope }) { return disableTextSelection({ doc: scope.getDoc(), target: dom.getContentEl(scope) }); } }, actions: { openEyeDropper({ scope, context, prop }) { const win = scope.getWin(); const isSupported = "EyeDropper" in win; if (!isSupported) return; const picker = new win.EyeDropper(); picker.open().then(({ sRGBHex }) => { const format = context.get("value").getFormat(); const color = parseColor(sRGBHex).toFormat(format); context.set("value", color); return color; }).then((value) => { prop("onValueChangeEnd")?.({ value, valueAsString: value.toString(context.get("format")) }); }).catch(() => void 0); }, setActiveChannel({ context, event }) { context.set("activeId", event.id); if (event.channel) context.set("activeChannel", event.channel); if (event.orientation) context.set("activeOrientation", event.orientation); }, clearActiveChannel({ context }) { context.set("activeChannel", null); context.set("activeId", null); context.set("activeOrientation", null); }, setAreaColorFromPoint({ context, event, computed, scope, prop }) { const v = event.format ? context.get("value").toFormat(event.format) : computed("areaValue"); const { xChannel, yChannel } = event.channel || context.get("activeChannel"); const percent = dom.getAreaValueFromPoint(scope, event.point, prop("dir")); if (!percent) return; const xValue = v.getChannelPercentValue(xChannel, percent.x); const yValue = v.getChannelPercentValue(yChannel, 1 - percent.y); const color = v.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue); context.set("value", color); }, setChannelColorFromPoint({ context, event, computed, scope, prop }) { const channel = event.channel || context.get("activeId"); const normalizedValue = event.format ? context.get("value").toFormat(event.format) : computed("areaValue"); const percent = dom.getChannelSliderValueFromPoint(scope, event.point, channel, prop("dir")); if (!percent) return; const orientation = event.orientation || context.get("activeOrientation") || "horizontal"; const channelPercent = orientation === "horizontal" ? percent.x : percent.y; const value = normalizedValue.getChannelPercentValue(channel, channelPercent); const color = normalizedValue.withChannelValue(channel, value); context.set("value", color); }, setValue({ context, event }) { const format = context.get("format"); context.set("value", event.value.toFormat(format)); }, setFormat({ context, event }) { context.set("format", event.format); }, dispatchChangeEvent({ scope, computed }) { dispatchInputValueEvent(dom.getHiddenInputEl(scope), { value: computed("valueAsString") }); }, syncInputElements({ context, scope }) { syncChannelInputs(scope, context.get("value")); }, invokeOnChangeEnd({ context, prop, computed }) { prop("onValueChangeEnd")?.({ value: context.get("value"), valueAsString: computed("valueAsString") }); }, setChannelColorFromInput({ context, event, scope, prop }) { const { channel, isTextField, value } = event; const currentAlpha = context.get("value").getChannelValue("alpha"); let color; if (channel === "alpha") { let valueAsNumber = parseFloat(value); valueAsNumber = Number.isNaN(valueAsNumber) ? currentAlpha : valueAsNumber; color = context.get("value").withChannelValue("alpha", valueAsNumber); } else if (isTextField) { color = tryCatch( () => { const parseValue = channel === "hex" ? prefixHex(value) : value; return parse(parseValue).withChannelValue("alpha", currentAlpha); }, () => context.get("value") ); } else { const current = context.get("value").toFormat(context.get("format")); const valueAsNumber = Number.isNaN(value) ? current.getChannelValue(channel) : value; color = current.withChannelValue(channel, valueAsNumber); } syncChannelInputs(scope, context.get("value"), color); context.set("value", color); prop("onValueChangeEnd")?.({ value: color, valueAsString: color.toString(context.get("format")) }); }, incrementChannel({ context, event }) { const color = context.get("value").incrementChannel(event.channel, event.step); context.set("value", color); }, decrementChannel({ context, event }) { const color = context.get("value").decrementChannel(event.channel, event.step); context.set("value", color); }, incrementAreaXChannel({ context, event, computed }) { const { xChannel } = event.channel; const color = computed("areaValue").incrementChannel(xChannel, event.step); context.set("value", color); }, decrementAreaXChannel({ context, event, computed }) { const { xChannel } = event.channel; const color = computed("areaValue").decrementChannel(xChannel, event.step); context.set("value", color); }, incrementAreaYChannel({ context, event, computed }) { const { yChannel } = event.channel; const color = computed("areaValue").incrementChannel(yChannel, event.step); context.set("value", color); }, decrementAreaYChannel({ context, event, computed }) { const { yChannel } = event.channel; const color = computed("areaValue").decrementChannel(yChannel, event.step); context.set("value", color); }, setChannelToMax({ context, event }) { const value = context.get("value"); const range = value.getChannelRange(event.channel); const color = value.withChannelValue(event.channel, range.maxValue); context.set("value", color); }, setChannelToMin({ context, event }) { const value = context.get("value"); const range = value.getChannelRange(event.channel); const color = value.withChannelValue(event.channel, range.minValue); context.set("value", color); }, focusAreaThumb({ scope }) { raf(() => { dom.getAreaThumbEl(scope)?.focus({ preventScroll: true }); }); }, focusChannelThumb({ event, scope }) { raf(() => { dom.getChannelSliderThumbEl(scope, event.channel)?.focus({ preventScroll: true }); }); }, setInitialFocus({ prop, scope }) { if (!prop("openAutoFocus")) return; raf(() => { const element = getInitialFocus({ root: dom.getContentEl(scope), getInitialEl: prop("initialFocusEl") }); element?.focus({ preventScroll: true }); }); }, setReturnFocus({ scope }) { raf(() => { dom.getTriggerEl(scope)?.focus({ preventScroll: true }); }); }, syncFormatSelectElement({ context, scope }) { syncFormatSelect(scope, context.get("format")); }, syncValueWithFormat({ context }) { const value = context.get("value"); const newValue = value.toFormat(context.get("format")); if (newValue.isEqual(value)) return; context.set("value", newValue); }, invokeOnOpen({ prop, context }) { if (prop("inline")) return; prop("onOpenChange")?.({ open: true, value: context.get("value") }); }, invokeOnClose({ prop, context }) { if (prop("inline")) return; prop("onOpenChange")?.({ open: false, value: context.get("value") }); }, toggleVisibility({ prop, event, send }) { send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }); } } } }); function syncChannelInputs(scope, currentValue, nextValue) { const channelInputEls = dom.getChannelInputEls(scope); raf(() => { channelInputEls.forEach((inputEl) => { const channel = inputEl.dataset.channel; setElementValue(inputEl, getChannelValue(nextValue || currentValue, channel)); }); }); } function syncFormatSelect(scope, format) { const selectEl = dom.getFormatSelectEl(scope); if (!selectEl) return; raf(() => setElementValue(selectEl, format)); } export { machine };