UNPKG

@zag-js/color-picker

Version:

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

1,455 lines (1,449 loc) • 49.9 kB
import { createAnatomy } from '@zag-js/anatomy'; import { parseColor, getColorAreaGradient, normalizeColor } from '@zag-js/color-utils'; import { raf, getInitialFocus, dispatchInputValueEvent, disableTextSelection, trackPointerMove, trackFormControl, setElementValue, getRelativePoint, queryAll, dataAttr, visuallyHiddenStyle, getEventStep, getEventKey, isLeftClick, isModifierKey, getEventPoint, query } from '@zag-js/dom-query'; import { getPlacement, getPlacementStyles } from '@zag-js/popper'; import { createGuards, createMachine } from '@zag-js/core'; import { trackDismissableElement } from '@zag-js/dismissable'; import { tryCatch, createSplitProps } from '@zag-js/utils'; import { createProps } from '@zag-js/types'; // src/color-picker.anatomy.ts var anatomy = createAnatomy("color-picker", [ "root", "label", "control", "trigger", "positioner", "content", "area", "areaThumb", "valueText", "areaBackground", "channelSlider", "channelSliderLabel", "channelSliderTrack", "channelSliderThumb", "channelSliderValueText", "channelInput", "transparencyGrid", "swatchGroup", "swatchTrigger", "swatchIndicator", "swatch", "eyeDropperTrigger", "formatTrigger", "formatSelect" ]); var parts = anatomy.build(); var getRootId = (ctx) => ctx.ids?.root ?? `color-picker:${ctx.id}`; var getLabelId = (ctx) => ctx.ids?.label ?? `color-picker:${ctx.id}:label`; var getHiddenInputId = (ctx) => ctx.ids?.hiddenInput ?? `color-picker:${ctx.id}:hidden-input`; var getControlId = (ctx) => ctx.ids?.control ?? `color-picker:${ctx.id}:control`; var getTriggerId = (ctx) => ctx.ids?.trigger ?? `color-picker:${ctx.id}:trigger`; var getContentId = (ctx) => ctx.ids?.content ?? `color-picker:${ctx.id}:content`; var getPositionerId = (ctx) => ctx.ids?.positioner ?? `color-picker:${ctx.id}:positioner`; var getFormatSelectId = (ctx) => ctx.ids?.formatSelect ?? `color-picker:${ctx.id}:format-select`; var getAreaId = (ctx) => ctx.ids?.area ?? `color-picker:${ctx.id}:area`; var getAreaGradientId = (ctx) => ctx.ids?.areaGradient ?? `color-picker:${ctx.id}:area-gradient`; var getAreaThumbId = (ctx) => ctx.ids?.areaThumb ?? `color-picker:${ctx.id}:area-thumb`; var getChannelSliderTrackId = (ctx, channel) => ctx.ids?.channelSliderTrack?.(channel) ?? `color-picker:${ctx.id}:slider-track:${channel}`; var getChannelSliderThumbId = (ctx, channel) => ctx.ids?.channelSliderThumb?.(channel) ?? `color-picker:${ctx.id}:slider-thumb:${channel}`; var getContentEl = (ctx) => ctx.getById(getContentId(ctx)); var getAreaThumbEl = (ctx) => ctx.getById(getAreaThumbId(ctx)); var getChannelSliderThumbEl = (ctx, channel) => ctx.getById(getChannelSliderThumbId(ctx, channel)); var getFormatSelectEl = (ctx) => ctx.getById(getFormatSelectId(ctx)); var getHiddenInputEl = (ctx) => ctx.getById(getHiddenInputId(ctx)); var getAreaEl = (ctx) => ctx.getById(getAreaId(ctx)); var getAreaValueFromPoint = (ctx, point) => { const areaEl = getAreaEl(ctx); if (!areaEl) return; const { percent } = getRelativePoint(point, areaEl); return percent; }; var getControlEl = (ctx) => ctx.getById(getControlId(ctx)); var getTriggerEl = (ctx) => ctx.getById(getTriggerId(ctx)); var getPositionerEl = (ctx) => ctx.getById(getPositionerId(ctx)); var getChannelSliderTrackEl = (ctx, channel) => ctx.getById(getChannelSliderTrackId(ctx, channel)); var getChannelSliderValueFromPoint = (ctx, point, channel) => { const trackEl = getChannelSliderTrackEl(ctx, channel); if (!trackEl) return; const { percent } = getRelativePoint(point, trackEl); return percent; }; var getChannelInputEls = (ctx) => { return [ ...queryAll(getContentEl(ctx), "input[data-channel]"), ...queryAll(getControlEl(ctx), "input[data-channel]") ]; }; function getChannelDisplayColor(color, channel) { switch (channel) { case "hue": return parseColor(`hsl(${color.getChannelValue("hue")}, 100%, 50%)`); case "lightness": case "brightness": case "saturation": case "red": case "green": case "blue": return color.withChannelValue("alpha", 1); case "alpha": { return color; } default: throw new Error("Unknown color channel: " + channel); } } function getChannelValue(color, channel) { if (channel == null) return ""; if (channel === "hex") { return color.toString("hex"); } if (channel === "css") { return color.toString("css"); } if (channel in color) { return color.getChannelValue(channel).toString(); } const isHSL = color.getFormat() === "hsla"; switch (channel) { case "hue": return isHSL ? color.toFormat("hsla").getChannelValue("hue").toString() : color.toFormat("hsba").getChannelValue("hue").toString(); case "saturation": return isHSL ? color.toFormat("hsla").getChannelValue("saturation").toString() : color.toFormat("hsba").getChannelValue("saturation").toString(); case "lightness": return color.toFormat("hsla").getChannelValue("lightness").toString(); case "brightness": return color.toFormat("hsba").getChannelValue("brightness").toString(); case "red": case "green": case "blue": return color.toFormat("rgba").getChannelValue(channel).toString(); default: return color.getChannelValue(channel).toString(); } } function getChannelRange(color, channel) { switch (channel) { case "hex": const minColor = parseColor("#000000"); const maxColor = parseColor("#FFFFFF"); return { minValue: minColor.toHexInt(), maxValue: maxColor.toHexInt(), pageSize: 10, step: 1 }; case "css": return void 0; case "hue": case "saturation": case "lightness": return color.toFormat("hsla").getChannelRange(channel); case "brightness": return color.toFormat("hsba").getChannelRange(channel); case "red": case "green": case "blue": return color.toFormat("rgba").getChannelRange(channel); default: return color.getChannelRange(channel); } } // src/utils/get-slider-background.ts function getSliderBackgroundDirection(orientation, dir) { if (orientation === "vertical") { return "top"; } else if (dir === "ltr") { return "right"; } else { return "left"; } } var getSliderBackground = (props2) => { const { channel, value, dir, orientation } = props2; const bgDirection = getSliderBackgroundDirection(orientation, dir); const { minValue, maxValue } = value.getChannelRange(channel); switch (channel) { case "hue": return `linear-gradient(to ${bgDirection}, rgb(255, 0, 0) 0%, rgb(255, 255, 0) 17%, rgb(0, 255, 0) 33%, rgb(0, 255, 255) 50%, rgb(0, 0, 255) 67%, rgb(255, 0, 255) 83%, rgb(255, 0, 0) 100%)`; case "lightness": { let start = value.withChannelValue(channel, minValue).toString("css"); let middle = value.withChannelValue(channel, (maxValue - minValue) / 2).toString("css"); let end = value.withChannelValue(channel, maxValue).toString("css"); return `linear-gradient(to ${bgDirection}, ${start}, ${middle}, ${end})`; } case "saturation": case "brightness": case "red": case "green": case "blue": case "alpha": { let start = value.withChannelValue(channel, minValue).toString("css"); let end = value.withChannelValue(channel, maxValue).toString("css"); return `linear-gradient(to ${bgDirection}, ${start}, ${end})`; } default: throw new Error("Unknown color channel: " + channel); } }; // src/color-picker.connect.ts function connect(service, normalize) { const { context, send, prop, computed, state, scope } = service; const value = context.get("value"); const format = context.get("format"); const areaValue = computed("areaValue"); const valueAsString = computed("valueAsString"); const disabled = computed("disabled"); const interactive = computed("interactive"); const dragging = state.hasTag("dragging"); const open = state.hasTag("open"); const focused = state.hasTag("focused"); const getAreaChannels = (props2) => { const channels = areaValue.getChannels(); return { xChannel: props2.xChannel ?? channels[1], yChannel: props2.yChannel ?? channels[2] }; }; const currentPlacement = context.get("currentPlacement"); const popperStyles = getPlacementStyles({ ...prop("positioning"), placement: currentPlacement }); function getSwatchTriggerState(props2) { const color = normalizeColor(props2.value).toFormat(context.get("format")); return { value: color, valueAsString: color.toString("hex"), checked: color.isEqual(value), disabled: props2.disabled || !interactive }; } return { dragging, open, valueAsString, value, setOpen(nextOpen) { const open2 = state.hasTag("open"); if (open2 === nextOpen) return; send({ type: nextOpen ? "OPEN" : "CLOSE" }); }, setValue(value2) { send({ type: "VALUE.SET", value: normalizeColor(value2), src: "set-color" }); }, getChannelValue(channel) { return getChannelValue(value, channel); }, getChannelValueText(channel, locale) { return value.formatChannelValue(channel, locale); }, setChannelValue(channel, channelValue) { const color = value.withChannelValue(channel, channelValue); send({ type: "VALUE.SET", value: color, src: "set-channel" }); }, format: context.get("format"), setFormat(format2) { const formatValue = value.toFormat(format2); send({ type: "VALUE.SET", value: formatValue, src: "set-format" }); }, alpha: value.getChannelValue("alpha"), setAlpha(alphaValue) { const color = value.withChannelValue("alpha", alphaValue); send({ type: "VALUE.SET", value: color, src: "set-alpha" }); }, getRootProps() { return normalize.element({ ...parts.root.attrs, dir: prop("dir"), id: getRootId(scope), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), style: { "--value": value.toString("css") } }); }, getLabelProps() { return normalize.element({ ...parts.label.attrs, dir: prop("dir"), id: getLabelId(scope), htmlFor: getHiddenInputId(scope), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), "data-focus": dataAttr(focused), onClick(event) { event.preventDefault(); const inputEl = query(getControlEl(scope), "[data-channel=hex]"); inputEl?.focus({ preventScroll: true }); } }); }, getControlProps() { return normalize.element({ ...parts.control.attrs, id: getControlId(scope), dir: prop("dir"), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), "data-state": open ? "open" : "closed", "data-focus": dataAttr(focused) }); }, getTriggerProps() { return normalize.button({ ...parts.trigger.attrs, id: getTriggerId(scope), dir: prop("dir"), disabled, "aria-label": `select color. current color is ${valueAsString}`, "aria-controls": getContentId(scope), "aria-labelledby": getLabelId(scope), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), "data-placement": currentPlacement, "aria-expanded": dataAttr(open), "data-state": open ? "open" : "closed", "data-focus": dataAttr(focused), type: "button", onClick() { if (!interactive) return; send({ type: "TRIGGER.CLICK" }); }, onBlur() { if (!interactive) return; send({ type: "TRIGGER.BLUR" }); }, style: { position: "relative" } }); }, getPositionerProps() { return normalize.element({ ...parts.positioner.attrs, id: getPositionerId(scope), dir: prop("dir"), style: popperStyles.floating }); }, getContentProps() { return normalize.element({ ...parts.content.attrs, id: getContentId(scope), dir: prop("dir"), tabIndex: -1, "data-placement": currentPlacement, "data-state": open ? "open" : "closed", hidden: !open }); }, getValueTextProps() { return normalize.element({ ...parts.valueText.attrs, dir: prop("dir"), "data-disabled": dataAttr(disabled), "data-focus": dataAttr(focused) }); }, getAreaProps(props2 = {}) { const { xChannel, yChannel } = getAreaChannels(props2); const { areaStyles } = getColorAreaGradient(areaValue, { xChannel, yChannel, dir: prop("dir") }); return normalize.element({ ...parts.area.attrs, id: getAreaId(scope), role: "group", "data-invalid": dataAttr(prop("invalid")), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), onPointerDown(event) { if (!interactive) return; if (!isLeftClick(event)) return; if (isModifierKey(event)) return; const point = getEventPoint(event); const channel = { xChannel, yChannel }; send({ type: "AREA.POINTER_DOWN", point, channel, id: "area" }); event.preventDefault(); }, style: { position: "relative", touchAction: "none", forcedColorAdjust: "none", ...areaStyles } }); }, getAreaBackgroundProps(props2 = {}) { const { xChannel, yChannel } = getAreaChannels(props2); const { areaGradientStyles } = getColorAreaGradient(areaValue, { xChannel, yChannel, dir: prop("dir") }); return normalize.element({ ...parts.areaBackground.attrs, id: getAreaGradientId(scope), "data-invalid": dataAttr(prop("invalid")), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), style: { position: "relative", touchAction: "none", forcedColorAdjust: "none", ...areaGradientStyles } }); }, getAreaThumbProps(props2 = {}) { const { xChannel, yChannel } = getAreaChannels(props2); const channel = { xChannel, yChannel }; const xPercent = areaValue.getChannelValuePercent(xChannel); const yPercent = 1 - areaValue.getChannelValuePercent(yChannel); const xValue = areaValue.getChannelValue(xChannel); const yValue = areaValue.getChannelValue(yChannel); const color = areaValue.withChannelValue("alpha", 1).toString("css"); return normalize.element({ ...parts.areaThumb.attrs, id: getAreaThumbId(scope), dir: prop("dir"), tabIndex: disabled ? void 0 : 0, "data-disabled": dataAttr(disabled), "data-invalid": dataAttr(prop("invalid")), "data-readonly": dataAttr(prop("readOnly")), role: "slider", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": xValue, "aria-label": `${xChannel} and ${yChannel}`, "aria-roledescription": "2d slider", "aria-valuetext": `${xChannel} ${xValue}, ${yChannel} ${yValue}`, style: { position: "absolute", left: `${xPercent * 100}%`, top: `${yPercent * 100}%`, transform: "translate(-50%, -50%)", touchAction: "none", forcedColorAdjust: "none", "--color": color, background: color }, onFocus() { if (!interactive) return; send({ type: "AREA.FOCUS", id: "area", channel }); }, onKeyDown(event) { if (event.defaultPrevented) return; if (!interactive) return; const step = getEventStep(event); const keyMap = { ArrowUp() { send({ type: "AREA.ARROW_UP", channel, step }); }, ArrowDown() { send({ type: "AREA.ARROW_DOWN", channel, step }); }, ArrowLeft() { send({ type: "AREA.ARROW_LEFT", channel, step }); }, ArrowRight() { send({ type: "AREA.ARROW_RIGHT", channel, step }); }, PageUp() { send({ type: "AREA.PAGE_UP", channel, step }); }, PageDown() { send({ type: "AREA.PAGE_DOWN", channel, step }); }, Escape(event2) { event2.stopPropagation(); } }; const exec = keyMap[getEventKey(event, { dir: prop("dir") })]; if (exec) { exec(event); event.preventDefault(); } } }); }, getTransparencyGridProps(props2 = {}) { const { size = "12px" } = props2; return normalize.element({ ...parts.transparencyGrid.attrs, style: { "--size": size, width: "100%", height: "100%", position: "absolute", backgroundColor: "#fff", backgroundImage: "conic-gradient(#eeeeee 0 25%, transparent 0 50%, #eeeeee 0 75%, transparent 0)", backgroundSize: "var(--size) var(--size)", inset: "0px", zIndex: "auto", pointerEvents: "none" } }); }, getChannelSliderProps(props2) { const { orientation = "horizontal", channel, format: format2 } = props2; return normalize.element({ ...parts.channelSlider.attrs, "data-channel": channel, "data-orientation": orientation, role: "presentation", onPointerDown(event) { if (!interactive) return; if (!isLeftClick(event)) return; if (isModifierKey(event)) return; const point = getEventPoint(event); send({ type: "CHANNEL_SLIDER.POINTER_DOWN", channel, format: format2, point, id: channel, orientation }); event.preventDefault(); }, style: { position: "relative", touchAction: "none" } }); }, getChannelSliderTrackProps(props2) { const { orientation = "horizontal", channel, format: format2 } = props2; const normalizedValue = format2 ? value.toFormat(format2) : areaValue; return normalize.element({ ...parts.channelSliderTrack.attrs, id: getChannelSliderTrackId(scope, channel), role: "group", "data-channel": channel, "data-orientation": orientation, style: { position: "relative", forcedColorAdjust: "none", backgroundImage: getSliderBackground({ orientation, channel, dir: prop("dir"), value: normalizedValue }) } }); }, getChannelSliderLabelProps(props2) { const { channel } = props2; return normalize.element({ ...parts.channelSliderLabel.attrs, "data-channel": channel, onClick(event) { if (!interactive) return; event.preventDefault(); const thumbId = getChannelSliderThumbId(scope, channel); scope.getById(thumbId)?.focus({ preventScroll: true }); }, style: { userSelect: "none", WebkitUserSelect: "none" } }); }, getChannelSliderValueTextProps(props2) { return normalize.element({ ...parts.channelSliderValueText.attrs, "data-channel": props2.channel }); }, getChannelSliderThumbProps(props2) { const { orientation = "horizontal", channel, format: format2 } = props2; const normalizedValue = format2 ? value.toFormat(format2) : areaValue; const channelRange = normalizedValue.getChannelRange(channel); const channelValue = normalizedValue.getChannelValue(channel); const offset = (channelValue - channelRange.minValue) / (channelRange.maxValue - channelRange.minValue); const placementStyles = orientation === "horizontal" ? { left: `${offset * 100}%`, top: "50%" } : { top: `${offset * 100}%`, left: "50%" }; return normalize.element({ ...parts.channelSliderThumb.attrs, id: getChannelSliderThumbId(scope, channel), role: "slider", "aria-label": channel, tabIndex: disabled ? void 0 : 0, "data-channel": channel, "data-disabled": dataAttr(disabled), "data-orientation": orientation, "aria-disabled": dataAttr(disabled), "aria-orientation": orientation, "aria-valuemax": channelRange.maxValue, "aria-valuemin": channelRange.minValue, "aria-valuenow": channelValue, "aria-valuetext": `${channel} ${channelValue}`, style: { forcedColorAdjust: "none", position: "absolute", background: getChannelDisplayColor(areaValue, channel).toString("css"), ...placementStyles }, onFocus() { if (!interactive) return; send({ type: "CHANNEL_SLIDER.FOCUS", channel }); }, onKeyDown(event) { if (event.defaultPrevented) return; if (!interactive) return; const step = getEventStep(event) * channelRange.step; const keyMap = { ArrowUp() { send({ type: "CHANNEL_SLIDER.ARROW_UP", channel, step }); }, ArrowDown() { send({ type: "CHANNEL_SLIDER.ARROW_DOWN", channel, step }); }, ArrowLeft() { send({ type: "CHANNEL_SLIDER.ARROW_LEFT", channel, step }); }, ArrowRight() { send({ type: "CHANNEL_SLIDER.ARROW_RIGHT", channel, step }); }, PageUp() { send({ type: "CHANNEL_SLIDER.PAGE_UP", channel }); }, PageDown() { send({ type: "CHANNEL_SLIDER.PAGE_DOWN", channel }); }, Home() { send({ type: "CHANNEL_SLIDER.HOME", channel }); }, End() { send({ type: "CHANNEL_SLIDER.END", channel }); }, Escape(event2) { event2.stopPropagation(); } }; const exec = keyMap[getEventKey(event, { dir: prop("dir") })]; if (exec) { exec(event); event.preventDefault(); } } }); }, getChannelInputProps(props2) { const { channel } = props2; const isTextField = channel === "hex" || channel === "css"; const channelRange = getChannelRange(value, channel); return normalize.input({ ...parts.channelInput.attrs, dir: prop("dir"), type: isTextField ? "text" : "number", "data-channel": channel, "aria-label": channel, spellCheck: false, autoComplete: "off", disabled, "data-disabled": dataAttr(disabled), "data-invalid": dataAttr(prop("invalid")), "data-readonly": dataAttr(prop("readOnly")), readOnly: prop("readOnly"), defaultValue: getChannelValue(value, channel), min: channelRange?.minValue, max: channelRange?.maxValue, step: channelRange?.step, onBeforeInput(event) { if (isTextField || !interactive) return; const value2 = event.currentTarget.value; if (value2.match(/[^0-9.]/g)) { event.preventDefault(); } }, onFocus(event) { if (!interactive) return; send({ type: "CHANNEL_INPUT.FOCUS", channel }); event.currentTarget.select(); }, onBlur(event) { if (!interactive) return; const value2 = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber; send({ type: "CHANNEL_INPUT.BLUR", channel, value: value2, isTextField }); }, onKeyDown(event) { if (event.defaultPrevented) return; if (!interactive) return; if (event.key === "Enter") { const value2 = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber; send({ type: "CHANNEL_INPUT.CHANGE", channel, value: value2, isTextField }); event.preventDefault(); } }, style: { appearance: "none", WebkitAppearance: "none", MozAppearance: "textfield" } }); }, getHiddenInputProps() { return normalize.input({ type: "text", disabled, name: prop("name"), tabIndex: -1, readOnly: prop("readOnly"), required: prop("required"), id: getHiddenInputId(scope), style: visuallyHiddenStyle, defaultValue: valueAsString }); }, getEyeDropperTriggerProps() { return normalize.button({ ...parts.eyeDropperTrigger.attrs, type: "button", dir: prop("dir"), disabled, "data-disabled": dataAttr(disabled), "data-invalid": dataAttr(prop("invalid")), "data-readonly": dataAttr(prop("readOnly")), "aria-label": "Pick a color from the screen", onClick() { if (!interactive) return; send({ type: "EYEDROPPER.CLICK" }); } }); }, getSwatchGroupProps() { return normalize.element({ ...parts.swatchGroup.attrs, role: "group" }); }, getSwatchTriggerState, getSwatchTriggerProps(props2) { const swatchState = getSwatchTriggerState(props2); return normalize.button({ ...parts.swatchTrigger.attrs, disabled: swatchState.disabled, dir: prop("dir"), type: "button", "aria-label": `select ${swatchState.valueAsString} as the color`, "data-state": swatchState.checked ? "checked" : "unchecked", "data-value": swatchState.valueAsString, "data-disabled": dataAttr(swatchState.disabled), onClick() { if (swatchState.disabled) return; send({ type: "SWATCH_TRIGGER.CLICK", value: swatchState.value }); }, style: { "--color": swatchState.valueAsString, position: "relative" } }); }, getSwatchIndicatorProps(props2) { const swatchState = getSwatchTriggerState(props2); return normalize.element({ ...parts.swatchIndicator.attrs, dir: prop("dir"), hidden: !swatchState.checked }); }, getSwatchProps(props2) { const { respectAlpha = true } = props2; const swatchState = getSwatchTriggerState(props2); const color = swatchState.value.toString(respectAlpha ? "css" : "hex"); return normalize.element({ ...parts.swatch.attrs, dir: prop("dir"), "data-state": swatchState.checked ? "checked" : "unchecked", "data-value": swatchState.valueAsString, style: { "--color": color, position: "relative", background: color } }); }, getFormatTriggerProps() { return normalize.button({ ...parts.formatTrigger.attrs, dir: prop("dir"), type: "button", "aria-label": `change color format to ${getNextFormat(format)}`, onClick(event) { if (event.currentTarget.disabled) return; const nextFormat = getNextFormat(format); send({ type: "FORMAT.SET", format: nextFormat, src: "format-trigger" }); } }); }, getFormatSelectProps() { return normalize.select({ ...parts.formatSelect.attrs, "aria-label": "change color format", dir: prop("dir"), defaultValue: prop("format"), disabled, onChange(event) { const format2 = assertFormat(event.currentTarget.value); send({ type: "FORMAT.SET", format: format2, src: "format-select" }); } }); } }; } var formats = ["hsba", "hsla", "rgba"]; var formatRegex = new RegExp(`^(${formats.join("|")})$`); function getNextFormat(format) { const index = formats.indexOf(format); return formats[index + 1] ?? formats[0]; } function assertFormat(format) { if (formatRegex.test(format)) return format; throw new Error(`Unsupported color format: ${format}`); } var parse = (colorString) => { return parseColor(colorString); }; // src/color-picker.machine.ts var { and } = createGuards(); var machine = createMachine({ props({ props: props2 }) { return { dir: "ltr", defaultValue: parse("#000000"), defaultFormat: "rgba", openAutoFocus: true, ...props2, positioning: { placement: "bottom", ...props2.positioning } }; }, initialState({ prop }) { const open = prop("open") || prop("defaultOpen"); return open ? "open" : "idle"; }, context({ prop, bindable, getContext }) { return { value: bindable(() => ({ defaultValue: prop("defaultValue"), value: prop("value"), isEqual(a, b) { return a.toString("css") === b?.toString("css"); }, hash(a) { return a.toString("css"); }, onChange(value) { const ctx = getContext(); const valueAsString = value.toString(ctx.get("format")); prop("onValueChange")?.({ value, valueAsString }); } })), 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"]); }); 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: { 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"], on: { "CONTROLLED.CLOSE": [ { guard: "shouldRestoreFocus", target: "focused", actions: ["setReturnFocus"] }, { target: "idle" } ], "TRIGGER.CLICK": [ { guard: "isOpenControlled", actions: ["invokeOnClose"] }, { target: "idle", actions: ["invokeOnClose"] } ], "AREA.POINTER_DOWN": { target: "open:dragging", actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.FOCUS": { actions: ["setActiveChannel"] }, "CHANNEL_SLIDER.POINTER_DOWN": { target: "open: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"] }, 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"] } ], "SWATCH_TRIGGER.CLICK": [ { guard: and("isOpenControlled", "closeOnSelect"), actions: ["setValue", "invokeOnClose"] }, { guard: "closeOnSelect", target: "focused", actions: ["setValue", "invokeOnClose", "setReturnFocus"] }, { actions: ["setValue"] } ] } }, "open:dragging": { tags: ["open"], exit: ["clearActiveChannel"], effects: ["trackPointerMove", "disableTextSelection", "trackPositioning", "trackDismissableElement"], on: { "CONTROLLED.CLOSE": [ { guard: "shouldRestoreFocus", target: "focused", actions: ["setReturnFocus"] }, { target: "idle" } ], "AREA.POINTER_MOVE": { actions: ["setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.POINTER_UP": { target: "open", actions: ["invokeOnChangeEnd"] }, "CHANNEL_SLIDER.POINTER_MOVE": { actions: ["setChannelColorFromPoint", "focusChannelThumb"] }, "CHANNEL_SLIDER.POINTER_UP": { target: "open", actions: ["invokeOnChangeEnd"] }, 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"] } ] } } }, implementations: { guards: { closeOnSelect: ({ prop }) => !!prop("closeOnSelect"), isOpenControlled: ({ prop }) => prop("open") != null, shouldRestoreFocus: ({ context }) => !!context.get("restoreFocus") }, effects: { trackPositioning({ context, prop, scope }) { if (!context.get("currentPlacement")) { context.set("currentPlacement", prop("positioning")?.placement); } const anchorEl = getTriggerEl(scope); const getPositionerEl2 = () => getPositionerEl(scope); return getPlacement(anchorEl, getPositionerEl2, { ...prop("positioning"), defer: true, onComplete(data) { context.set("currentPlacement", data.placement); } }); }, trackDismissableElement({ context, scope, prop, send }) { const getContentEl2 = () => getContentEl(scope); return trackDismissableElement(getContentEl2, { exclude: 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 = 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 }); }, onPointerUp() { const type = context.get("activeId") === "area" ? "AREA.POINTER_UP" : "CHANNEL_SLIDER.POINTER_UP"; send({ type }); } }); }, disableTextSelection({ scope }) { return disableTextSelection({ doc: scope.getDoc(), target: getContentEl(scope) }); } }, actions: { openEyeDropper({ scope, context }) { 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); }).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 }) { const v = event.format ? context.get("value").toFormat(event.format) : computed("areaValue"); const { xChannel, yChannel } = event.channel || context.get("activeChannel"); const percent = getAreaValueFromPoint(scope, event.point); 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 }) { const channel = event.channel || context.get("activeId"); const normalizedValue = event.format ? context.get("value").toFormat(event.format) : computed("areaValue"); const percent = getChannelSliderValueFromPoint(scope, event.point, channel); if (!percent) return; const 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 }) { context.set("value", event.value); }, setFormat({ context, event }) { context.set("format", event.format); }, dispatchChangeEvent({ scope, computed }) { dispatchInputValueEvent(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( () => parse(value).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(() => { getAreaThumbEl(scope)?.focus({ preventScroll: true }); }); }, focusChannelThumb({ event, scope }) { raf(() => { getChannelSliderThumbEl(scope, event.channel)?.focus({ preventScroll: true }); }); }, setInitialFocus({ prop, scope }) { if (!prop("openAutoFocus")) return; raf(() => { const element = getInitialFocus({ root: getContentEl(scope), getInitialEl: prop("initialFocusEl") }); element?.focus({ preventScroll: true }); }); }, setReturnFocus({ scope }) { raf(() => { getTriggerEl(scope)?.focus({ preventScroll: true }); }); }, syncFormatSelectElement({ context, scope }) { syncFormatSelect(scope, context.get("format")); }, invokeOnOpen({ prop }) { prop("onOpenChange")?.({ open: true }); }, invokeOnClose({ prop }) { prop("onOpenChange")?.({ open: false }); }, toggleVisibility({ prop, event, send }) { send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }); } } } }); function syncChannelInputs(scope, currentValue, nextValue) { const channelInputEls = getChannelInputEls(scope); raf(() => { channelInputEls.forEach((inputEl) => { const channel = inputEl.dataset.channel; setElementValue(inputEl, getChannelValue(nextValue || currentValue, channel)); }); }); } function syncFormatSelect(scope, format) { const selectEl = getFormatSelectEl(scope); if (!selectEl) return; raf(() => setElementValue(selectEl, format)); } var props = createProps()([ "closeOnSelect", "dir", "disabled", "format", "defaultFormat", "getRootNode", "id", "ids", "initialFocusEl", "name", "positioning", "onFocusOutside", "onFormatChange", "onInteractOutside", "onOpenChange", "onPointerDownOutside", "onValueChange", "onValueChangeEnd", "defaultOpen", "open", "positioning", "required", "readOnly", "value", "defaultValue", "invalid", "openAutoFocus" ]); var splitProps = createSplitProps(props); var areaProps = createProps()(["xChannel", "yChannel"]); var splitAreaProps = createSplitProps(areaProps); var channelProps = createProps()(["channel", "orientation"]); var splitChannelProps = createSplitProps(channelProps); var swatchTriggerProps = createProps()(["value", "disabled"]); var splitSwatchTriggerProps = createSplitProps(swatchTriggerProps); var swatchProps = createProps()(["value", "respectAlpha"]); var splitSwatchProps = createSplitProps(swatchProps); var transparencyGridProps = createProps()(["size"]); var splitTransparencyGridProps = createSplitProps(transparencyGridProps); export { anatomy, areaProps, channelProps, connect, machine, parse, props, splitAreaProps, splitChannelProps, splitProps, splitSwatchProps, splitSwatchTriggerProps, splitTransparencyGridProps, swatchProps, swatchTriggerProps, transparencyGridProps };