UNPKG

@zag-js/color-picker

Version:

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

650 lines (649 loc) • 22.6 kB
// src/color-picker.connect.ts import { getColorAreaGradient, normalizeColor } from "@zag-js/color-utils"; import { getEventKey, getEventPoint, getEventStep, isLeftClick, isModifierKey } from "@zag-js/dom-query"; import { dataAttr, query, visuallyHiddenStyle } from "@zag-js/dom-query"; import { getPlacementSide, getPlacementStyles } from "@zag-js/popper"; import { parts } from "./color-picker.anatomy.mjs"; import * as dom from "./color-picker.dom.mjs"; import { getChannelDisplayColor } from "./utils/get-channel-display-color.mjs"; import { getChannelRange, getChannelValue } from "./utils/get-channel-input-value.mjs"; import { getSliderBackground } from "./utils/get-slider-background.mjs"; 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 readOnly = !!prop("readOnly"); const invalid = !!prop("invalid"); const required = !!prop("required"); const interactive = computed("interactive"); const dragging = state.hasTag("dragging"); const open = state.hasTag("open"); const focused = state.hasTag("focused"); const getAreaChannels = (props) => { const channels = areaValue.getChannels(); return { xChannel: props.xChannel ?? channels[1], yChannel: props.yChannel ?? channels[2] }; }; const currentPlacement = context.get("currentPlacement"); const currentPlacementSide = currentPlacement ? getPlacementSide(currentPlacement) : void 0; const popperStyles = getPlacementStyles({ ...prop("positioning"), placement: currentPlacement }); function getSwatchTriggerState(props) { const color = normalizeColor(props.value).toFormat(context.get("format")); return { value: color, valueAsString: color.toString("hex"), checked: color.isEqual(value), disabled: props.disabled || !interactive }; } return { dragging, open, valueAsString, value, inline: !!prop("inline"), setOpen(nextOpen) { if (prop("inline")) return; 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: dom.getRootId(scope), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), "data-invalid": dataAttr(invalid), style: { "--value": value.toString("css") } }); }, getLabelProps() { return normalize.element({ ...parts.label.attrs, dir: prop("dir"), id: dom.getLabelId(scope), htmlFor: dom.getHiddenInputId(scope), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), "data-invalid": dataAttr(invalid), "data-required": dataAttr(required), "data-focus": dataAttr(focused), onClick(event) { event.preventDefault(); const inputEl = query(dom.getControlEl(scope), "[data-channel=hex]"); inputEl?.focus({ preventScroll: true }); } }); }, getControlProps() { return normalize.element({ ...parts.control.attrs, id: dom.getControlId(scope), dir: prop("dir"), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), "data-invalid": dataAttr(invalid), "data-state": open ? "open" : "closed", "data-focus": dataAttr(focused) }); }, getTriggerProps() { return normalize.button({ ...parts.trigger.attrs, id: dom.getTriggerId(scope), dir: prop("dir"), disabled, "aria-label": `select color. current color is ${valueAsString}`, "aria-controls": dom.getContentId(scope), "aria-labelledby": dom.getLabelId(scope), "aria-haspopup": prop("inline") ? void 0 : "dialog", "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), "data-invalid": dataAttr(invalid), "data-placement": currentPlacement, "data-side": currentPlacementSide, "aria-expanded": 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: dom.getPositionerId(scope), dir: prop("dir"), style: popperStyles.floating }); }, getContentProps() { return normalize.element({ ...parts.content.attrs, id: dom.getContentId(scope), dir: prop("dir"), role: prop("inline") ? void 0 : "dialog", tabIndex: -1, "data-placement": currentPlacement, "data-side": currentPlacementSide, "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(props = {}) { const { xChannel, yChannel } = getAreaChannels(props); const { areaStyles } = getColorAreaGradient(areaValue, { xChannel, yChannel, dir: prop("dir") }); return normalize.element({ ...parts.area.attrs, id: dom.getAreaId(scope), role: "group", "data-invalid": dataAttr(invalid), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(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(props = {}) { const { xChannel, yChannel } = getAreaChannels(props); const { areaGradientStyles } = getColorAreaGradient(areaValue, { xChannel, yChannel, dir: prop("dir") }); return normalize.element({ ...parts.areaBackground.attrs, id: dom.getAreaGradientId(scope), "data-invalid": dataAttr(invalid), "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(readOnly), style: { position: "relative", touchAction: "none", forcedColorAdjust: "none", ...areaGradientStyles } }); }, getAreaThumbProps(props = {}) { const { xChannel, yChannel } = getAreaChannels(props); const channel = { xChannel, yChannel }; const xPercent = areaValue.getChannelValuePercent(xChannel); const yPercent = 1 - areaValue.getChannelValuePercent(yChannel); const isRtl = prop("dir") === "rtl"; const finalXPercent = isRtl ? 1 - xPercent : xPercent; 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: dom.getAreaThumbId(scope), dir: prop("dir"), tabIndex: disabled ? void 0 : 0, "data-disabled": dataAttr(disabled), "data-invalid": dataAttr(invalid), "data-readonly": dataAttr(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: `${finalXPercent * 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(props = {}) { const { size = "12px" } = props; 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(props) { const { orientation = "horizontal", channel, format: format2 } = props; 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(props) { const { orientation = "horizontal", channel, format: format2 } = props; const normalizedValue = format2 ? value.toFormat(format2) : areaValue; return normalize.element({ ...parts.channelSliderTrack.attrs, id: dom.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(props) { const { channel } = props; return normalize.element({ ...parts.channelSliderLabel.attrs, "data-channel": channel, onClick(event) { if (!interactive) return; event.preventDefault(); const thumbId = dom.getChannelSliderThumbId(scope, channel); scope.getById(thumbId)?.focus({ preventScroll: true }); }, style: { userSelect: "none", WebkitUserSelect: "none" } }); }, getChannelSliderValueTextProps(props) { return normalize.element({ ...parts.channelSliderValueText.attrs, "data-channel": props.channel }); }, getChannelSliderThumbProps(props) { const { orientation = "horizontal", channel, format: format2 } = props; 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 isRtl = prop("dir") === "rtl"; const finalOffset = orientation === "horizontal" && isRtl ? 1 - offset : offset; const placementStyles = orientation === "horizontal" ? { left: `${finalOffset * 100}%`, top: "50%" } : { top: `${offset * 100}%`, left: "50%" }; return normalize.element({ ...parts.channelSliderThumb.attrs, id: dom.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(props) { const { channel } = props; 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(invalid), "data-readonly": dataAttr(readOnly), 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, required, id: dom.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(invalid), "data-readonly": dataAttr(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(props) { const swatchState = getSwatchTriggerState(props); 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(props) { const swatchState = getSwatchTriggerState(props); return normalize.element({ ...parts.swatchIndicator.attrs, dir: prop("dir"), hidden: !swatchState.checked }); }, getSwatchProps(props) { const { respectAlpha = true } = props; const swatchState = getSwatchTriggerState(props); 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}`); } export { connect };