UNPKG

@zag-js/color-picker

Version:

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

645 lines (643 loc) • 23.5 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/color-picker.machine.ts var color_picker_machine_exports = {}; __export(color_picker_machine_exports, { machine: () => machine }); module.exports = __toCommonJS(color_picker_machine_exports); var import_color_utils = require("@zag-js/color-utils"); var import_core = require("@zag-js/core"); var import_dismissable = require("@zag-js/dismissable"); var import_dom_query = require("@zag-js/dom-query"); var import_popper = require("@zag-js/popper"); var import_utils = require("@zag-js/utils"); var dom = __toESM(require("./color-picker.dom.js")); var import_color_picker = require("./color-picker.parse.js"); var import_get_channel_input_value = require("./utils/get-channel-input-value.js"); var import_is_valid_hex = require("./utils/is-valid-hex.js"); var { and } = (0, import_core.createGuards)(); var hashObject = (obj) => { let hash = ""; for (const key in obj) hash += `${key}:${obj[key] ?? ""};`; return hash; }; var DEFAULT_COLOR = (0, import_color_picker.parse)("#000000"); var machine = (0, import_core.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 (0, import_popper.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 (0, import_dismissable.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 (0, import_dom_query.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 (0, import_dom_query.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 (0, import_dom_query.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 = (0, import_color_utils.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 }) { (0, import_dom_query.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 = (0, import_utils.tryCatch)( () => { const parseValue = channel === "hex" ? (0, import_is_valid_hex.prefixHex)(value) : value; return (0, import_color_picker.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 }) { (0, import_dom_query.raf)(() => { dom.getAreaThumbEl(scope)?.focus({ preventScroll: true }); }); }, focusChannelThumb({ event, scope }) { (0, import_dom_query.raf)(() => { dom.getChannelSliderThumbEl(scope, event.channel)?.focus({ preventScroll: true }); }); }, setInitialFocus({ prop, scope }) { if (!prop("openAutoFocus")) return; (0, import_dom_query.raf)(() => { const element = (0, import_dom_query.getInitialFocus)({ root: dom.getContentEl(scope), getInitialEl: prop("initialFocusEl") }); element?.focus({ preventScroll: true }); }); }, setReturnFocus({ scope }) { (0, import_dom_query.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); (0, import_dom_query.raf)(() => { channelInputEls.forEach((inputEl) => { const channel = inputEl.dataset.channel; (0, import_dom_query.setElementValue)(inputEl, (0, import_get_channel_input_value.getChannelValue)(nextValue || currentValue, channel)); }); }); } function syncFormatSelect(scope, format) { const selectEl = dom.getFormatSelectEl(scope); if (!selectEl) return; (0, import_dom_query.raf)(() => (0, import_dom_query.setElementValue)(selectEl, format)); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { machine });