@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
JavaScript
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 };