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