diff --git a/.changeset/curvy-jokes-promise.md b/.changeset/curvy-jokes-promise.md new file mode 100644 index 0000000000..42af8f532f --- /dev/null +++ b/.changeset/curvy-jokes-promise.md @@ -0,0 +1,5 @@ +--- +"@zag-js/color-picker": minor +--- + +Redesign the color picker API diff --git a/.xstate/color-picker.js b/.xstate/color-picker.js index 36dc5e0a0d..d671fb3feb 100644 --- a/.xstate/color-picker.js +++ b/.xstate/color-picker.js @@ -11,14 +11,34 @@ const { } = actions; const fetchMachine = createMachine({ id: "color-picker", - initial: "idle", - context: {}, + initial: ctx.open ? "open" : "idle", + context: { + "shouldRestoreFocus": false, + "shouldRestoreFocus": false + }, + activities: ["trackFormControl"], on: { "VALUE.SET": { actions: ["setValue"] + }, + "CHANNEL_INPUT.FOCUS": [{ + cond: stateIn("idle"), + target: "focused", + actions: ["setActiveChannel"] + }, { + actions: ["setActiveChannel"] + }], + "CHANNEL_INPUT.BLUR": [{ + cond: stateIn("focused"), + target: "idle", + actions: ["setChannelColorFromInput"] + }, { + actions: ["setChannelColorFromInput"] + }], + "CHANNEL_INPUT.CHANGE": { + actions: ["setChannelColorFromInput"] } }, - activities: ["trackFormControl"], on: { UPDATE_CONTEXT: { actions: "updateContext" @@ -26,45 +46,56 @@ const fetchMachine = createMachine({ }, states: { idle: { + tags: ["closed"], on: { + OPEN: { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"] + }, + "TRIGGER.CLICK": { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"] + } + } + }, + focused: { + tags: ["closed", "focused"], + on: { + OPEN: { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"] + }, + "TRIGGER.CLICK": { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"] + } + } + }, + open: { + tags: ["open"], + activities: ["trackPositioning", "trackDismissableElement"], + on: { + "TRIGGER.CLICK": { + target: "idle", + actions: ["invokeOnClose"] + }, "EYEDROPPER.CLICK": { actions: ["openEyeDropper"] }, "AREA.POINTER_DOWN": { - target: "dragging", + target: "open:dragging", actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.FOCUS": { - target: "focused", actions: ["setActiveChannel"] }, "CHANNEL_SLIDER.POINTER_DOWN": { - target: "dragging", + target: "open:dragging", actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"] }, "CHANNEL_SLIDER.FOCUS": { - target: "focused", - actions: ["setActiveChannel"] - }, - "CHANNEL_INPUT.FOCUS": { - target: "focused", actions: ["setActiveChannel"] }, - "CHANNEL_INPUT.CHANGE": { - actions: ["setChannelColorFromInput"] - } - } - }, - focused: { - on: { - "AREA.POINTER_DOWN": { - target: "dragging", - actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"] - }, - "CHANNEL_SLIDER.POINTER_DOWN": { - target: "dragging", - actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"] - }, "AREA.ARROW_LEFT": { actions: ["decrementXChannel"] }, @@ -107,37 +138,50 @@ const fetchMachine = createMachine({ "CHANNEL_SLIDER.END": { actions: ["setChannelToMax"] }, - "CHANNEL_INPUT.FOCUS": { - actions: ["setActiveChannel"] - }, - "CHANNEL_INPUT.CHANGE": { - actions: ["setChannelColorFromInput"] - }, - "CHANNEL_SLIDER.BLUR": { - target: "idle" - }, - "AREA.BLUR": { - target: "idle" + INTERACT_OUTSIDE: [{ + cond: "shouldRestoreFocus", + target: "focused", + actions: ["setReturnFocus", "invokeOnClose"] + }, { + target: "idle", + actions: ["invokeOnClose"] + }], + CLOSE: { + target: "idle", + actions: ["invokeOnClose"] } } }, - dragging: { + "open:dragging": { + tags: ["open"], exit: ["clearActiveChannel"], - activities: ["trackPointerMove", "disableTextSelection"], + activities: ["trackPointerMove", "disableTextSelection", "trackPositioning", "trackDismissableElement"], on: { "AREA.POINTER_MOVE": { - actions: ["setAreaColorFromPoint"] + actions: ["setAreaColorFromPoint", "focusAreaThumb"] }, "AREA.POINTER_UP": { - target: "focused", + target: "open", actions: ["invokeOnChangeEnd"] }, "CHANNEL_SLIDER.POINTER_MOVE": { - actions: ["setChannelColorFromPoint"] + actions: ["setChannelColorFromPoint", "focusChannelThumb"] }, "CHANNEL_SLIDER.POINTER_UP": { - target: "focused", + target: "open", actions: ["invokeOnChangeEnd"] + }, + INTERACT_OUTSIDE: [{ + cond: "shouldRestoreFocus", + target: "focused", + actions: ["setReturnFocus", "invokeOnClose"] + }, { + target: "idle", + actions: ["invokeOnClose"] + }], + CLOSE: { + target: "idle", + actions: ["invokeOnClose"] } } } @@ -150,5 +194,7 @@ const fetchMachine = createMachine({ }; }) }, - guards: {} + guards: { + "shouldRestoreFocus": ctx => ctx["shouldRestoreFocus"] + } }); \ No newline at end of file diff --git a/.xstate/date-picker.js b/.xstate/date-picker.js index 5bcc679b08..1d13c5bf87 100644 --- a/.xstate/date-picker.js +++ b/.xstate/date-picker.js @@ -11,7 +11,7 @@ const { } = actions; const fetchMachine = createMachine({ id: "datepicker", - initial: ctx.inline ? "open" : "idle", + initial: ctx.inline || ctx.open ? "open" : "idle", context: { "isYearView": false, "isMonthView": false, @@ -103,6 +103,10 @@ const fetchMachine = createMachine({ "TRIGGER.CLICK": { target: "open", actions: ["focusFirstSelectedDate", "invokeOnOpen"] + }, + OPEN: { + target: "open", + actions: ["invokeOnOpen"] } } }, @@ -125,6 +129,10 @@ const fetchMachine = createMachine({ "CELL.FOCUS": { target: "open", actions: ["setView", "invokeOnOpen"] + }, + OPEN: { + target: "open", + actions: ["invokeOnOpen"] } } }, @@ -315,7 +323,11 @@ const fetchMachine = createMachine({ }, { target: "focused", actions: ["focusTriggerElement", "setStartIndex", "invokeOnClose"] - }] + }], + CLOSE: { + target: "idle", + actions: ["setStartIndex", "invokeOnClose"] + } } } } diff --git a/e2e/color-picker.e2e.ts b/e2e/color-picker.e2e.ts index 92182fcd29..2486565bb6 100644 --- a/e2e/color-picker.e2e.ts +++ b/e2e/color-picker.e2e.ts @@ -1,11 +1,56 @@ -import { expect, test } from "@playwright/test" -import { a11y, testid } from "./__utils" +import { test as base, expect, type Locator, type Page } from "@playwright/test" +import { a11y, clickOutside, part } from "./__utils" -const readOnlySwatch = testid("readonly-swatch") -const clickableSwatch1 = testid("clickable-swatch-1") -const clickableSwatch2 = testid("clickable-swatch-2") -const value = testid("value") -const channelInputHex = "[data-part=channel-input][data-channel=hex]" +/* ----------------------------------------------------------------------------- + * Setup + * -----------------------------------------------------------------------------*/ + +class Parts { + constructor(private readonly page: Page) {} + get trigger(): Locator { + return this.page.locator(part("trigger")) + } + get content(): Locator { + return this.page.locator(part("content")) + } + get input(): Locator { + return this.page.locator(`[data-part=control] [data-channel=hex]`) + } + get areaThumb(): Locator { + return this.page.locator(part("area-thumb")) + } + getChannelInput(channel: string) { + return this.page.locator(`[data-part=channel-input][data-channel=${channel}]`) + } + getChannelThumb(channel: string) { + return this.page.locator(`[data-part=channel-slider-thumb][data-channel=${channel}]`) + } + getChannelSlider(channel: string) { + return this.page.locator(`[data-part=channel-slider][data-channel=${channel}]`) + } + get swatchTriggers() { + return this.page.locator(part("swatch-trigger")) + } + get resetButton() { + return this.page.getByRole("button", { name: "Reset" }) + } + get valueText() { + return this.page.getByTestId("value-text").first() + } +} + +const INITIAL_VALUE = "#FF0000" +const PINK_VALUE = "#FFC0CB" + +const test = base.extend<{ parts: Parts }>({ + parts: async ({ page }, use) => { + await use(new Parts(page)) + }, +}) + +/* ----------------------------------------------------------------------------- + * Tests + * -----------------------------------------------------------------------------*/ test.describe("color-picker", () => { test.beforeEach(async ({ page }) => { @@ -16,21 +61,117 @@ test.describe("color-picker", () => { await a11y(page, ".color-picker") }) - test("[swatch] should change the value if is not readonly", async ({ page }) => { - await page.click(readOnlySwatch) - await expect(page.locator(value)).toContainText("hsl(0, 100%, 50%)") - await page.click(clickableSwatch1) - await expect(page.locator(value)).toContainText("hsla(0, 85.43%, 70.39%, 1)") - await page.click(clickableSwatch2) - await expect(page.locator(value)).toContainText("hsla(215.62, 13.22%, 47.45%, 1)") + test("[closed] typing the same native css colors switch show hex", async ({ parts }) => { + await parts.input.fill("red") + await parts.input.press("Enter") + await parts.input.blur() + await expect(parts.input).toHaveValue(INITIAL_VALUE) + }) + + test("[closed] typing different native css colors should update color", async ({ parts }) => { + await parts.input.fill("pink") + await parts.input.press("Enter") + await parts.input.blur() + await expect(parts.input).toHaveValue(PINK_VALUE) + }) + + test("[closed] typing in alpha should update color", async ({ parts, page }) => { + const alpha = parts.getChannelInput("alpha").first() + await alpha.fill("0.3") + await page.keyboard.press("Enter") + + await expect(parts.valueText).toContainText("hsla(0, 100%, 50%, 0.3)") + }) + + test("click on trigger should open picker", async ({ parts }) => { + await parts.trigger.click() + await expect(parts.content).toBeVisible() + }) + + test("should re-focus trigger on outside click", async ({ parts, page }) => { + await parts.trigger.click() + await expect(parts.content).toBeVisible() + + await clickOutside(page) + await expect(parts.trigger).toBeFocused() + }) + + test("opening the picker should focus area", async ({ parts }) => { + await parts.trigger.click() + await expect(parts.content).toBeVisible() + await expect(parts.areaThumb).toBeFocused() }) - test("[swatch / channel input hex] should change the hex channel input if is not readonly", async ({ page }) => { - await page.click(readOnlySwatch) - await expect(page.locator(channelInputHex)).toHaveValue("#FF0000") - await page.click(clickableSwatch1) - await expect(page.locator(channelInputHex)).toHaveValue("#F47373") - await page.click(clickableSwatch2) - await expect(page.locator(channelInputHex)).toHaveValue("#697689") + test("keyboard focus movement", async ({ parts, page }) => { + await parts.trigger.click() + await expect(parts.content).toBeVisible() + await expect(parts.areaThumb).toBeFocused() + + await page.keyboard.press("Tab") + await expect(parts.getChannelThumb("hue")).toBeFocused() + + await page.keyboard.press("Tab") + await expect(parts.getChannelThumb("alpha")).toBeFocused() + }) + + test("[swatch] should set value on click swatch", async ({ parts }) => { + await parts.trigger.click() + const [swatch] = await parts.swatchTriggers.all() + await swatch.click() + const swatchValue = (await swatch.getAttribute("data-value")) ?? "" + await expect(parts.input).toHaveValue(swatchValue) + }) + + test("[form] should reset value to initial on reset", async ({ parts, page }) => { + await parts.trigger.click() + + const [swatch] = await parts.swatchTriggers.all() + await swatch.click() + + await clickOutside(page) + await parts.resetButton.click() + + await expect(parts.input).toHaveValue(INITIAL_VALUE) + }) + + test("hsl channel inputs should work as expected", async ({ page, parts }) => { + await parts.trigger.click() + + const hue = parts.getChannelInput("hue") + const saturation = parts.getChannelInput("saturation") + const lightness = parts.getChannelInput("lightness") + const alpha = parts.getChannelInput("alpha").nth(1) + + await hue.fill("20") + await page.keyboard.press("Enter") + await expect(parts.valueText).toContainText("hsla(20, 100%, 50%, 1)") + + await saturation.fill("56") + await page.keyboard.press("Enter") + await expect(parts.valueText).toContainText("hsla(20, 56%, 50%, 1)") + + await lightness.fill("78") + await page.keyboard.press("Enter") + await expect(parts.valueText).toContainText("hsla(20, 56%, 78%, 1)") + + await alpha.fill("0.5") + await page.keyboard.press("Enter") + await expect(parts.valueText).toContainText("hsla(20, 56%, 78%, 0.5)") + }) + + test("[slider] should change hue when clicking the hue bar", async ({ parts }) => { + await parts.trigger.click() + + const hue = parts.getChannelSlider("hue") + await hue.click() + await expect(parts.valueText).not.toContainText("hsla(0, 100%, 50%, 1)") + }) + + test("[slider] should change alpha when clicking the alpha bar", async ({ parts }) => { + await parts.trigger.click() + + const alpha = parts.getChannelSlider("alpha") + await alpha.click() + await expect(parts.valueText).not.toContainText("hsla(0, 100%, 50%, 1)") }) }) diff --git a/examples/next-ts/pages/color-picker.tsx b/examples/next-ts/pages/color-picker.tsx index a2df3dfe86..a05b5fb84d 100644 --- a/examples/next-ts/pages/color-picker.tsx +++ b/examples/next-ts/pages/color-picker.tsx @@ -1,18 +1,36 @@ import * as colorPicker from "@zag-js/color-picker" import { normalizeProps, useMachine } from "@zag-js/react" import { colorPickerControls } from "@zag-js/shared" +import serialize from "form-serialize" import { useId } from "react" import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" +const EyeDropIcon = () => ( + + + +) + +const presets = ["#f47373", "#697689"] + export default function Page() { const controls = useControls(colorPickerControls) const [state, send] = useMachine( colorPicker.machine({ id: useId(), - value: "hsl(0, 100%, 50%)", + name: "color", + value: colorPicker.parse("hsl(0, 100%, 50%)"), }), { context: controls.context, @@ -20,54 +38,87 @@ export default function Page() { ) const api = colorPicker.connect(state, send, normalizeProps) - const [hue, saturation, lightness] = api.channels return ( <> - - - - - + { + console.log("change:", serialize(e.currentTarget, { hash: true })) + }} + > + + + + Select Color: {api.valueAsString} + - - - + + + + + + + + - - - - + + + + + + + - - - - - - + + + + - - - - - {api.value} - + + + + + - + + + + + + - - - - + + + + + + {api.valueAsString} + - - + + + + {presets.map((preset) => ( + + + + + + + ))} + + + + + + + - - Eye Dropper - + Submit + Reset + diff --git a/examples/solid-ts/src/pages/color-picker.tsx b/examples/solid-ts/src/pages/color-picker.tsx index cc1da2fa6f..318b026605 100644 --- a/examples/solid-ts/src/pages/color-picker.tsx +++ b/examples/solid-ts/src/pages/color-picker.tsx @@ -1,10 +1,27 @@ import * as colorPicker from "@zag-js/color-picker" import { normalizeProps, useMachine } from "@zag-js/solid" import { colorPickerControls } from "@zag-js/shared" -import { createMemo, createUniqueId } from "solid-js" +import { Index, createMemo, createUniqueId } from "solid-js" import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" +import serialize from "form-serialize" + +const presets = ["#f47373", "#697689"] + +const EyeDropIcon = () => ( + + + +) export default function Page() { const controls = useControls(colorPickerControls) @@ -12,7 +29,8 @@ export default function Page() { const [state, send] = useMachine( colorPicker.machine({ id: createUniqueId(), - value: "hsl(0, 100%, 50%)", + name: "color", + value: colorPicker.parse("hsl(0, 100%, 50%)"), }), { context: controls.context, @@ -20,54 +38,89 @@ export default function Page() { ) const api = createMemo(() => colorPicker.connect(state, send, normalizeProps)) - const [hue, saturation, lightness] = api().channels return ( <> - - - - - + { + console.log("change:", serialize(e.currentTarget, { hash: true })) + }} + > + + + + Select Color: {api().valueAsString} + - - - + + + + + + + + - - - - + + + + + + + - - - - - - + + + + - - - - - {api().value} - + + + + + - + + + + + + - - - - + + + + + + {api().valueAsString} + - - + + + + + {(preset) => ( + + + + + + + )} + + + + + + + + - - Eye Dropper - + Submit + Reset + diff --git a/examples/vue-ts/src/pages/color-picker.tsx b/examples/vue-ts/src/pages/color-picker.tsx index 20a31a8aed..6f68c43119 100644 --- a/examples/vue-ts/src/pages/color-picker.tsx +++ b/examples/vue-ts/src/pages/color-picker.tsx @@ -5,68 +5,125 @@ import { computed, defineComponent } from "vue" import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" +import serialize from "form-serialize" + +const presets = ["#f47373", "#697689"] + +const EyeDropIcon = () => ( + + + +) export default defineComponent({ name: "ColorPicker", setup() { const controls = useControls(colorPickerControls) - const [state, send] = useMachine(colorPicker.machine({ id: "1", value: "hsl(0, 100%, 50%)" }), { - context: controls.context, - }) + const [state, send] = useMachine( + colorPicker.machine({ + id: "1", + name: "color", + value: colorPicker.parse("hsl(0, 100%, 50%)"), + }), + { + context: controls.context, + }, + ) const apiRef = computed(() => colorPicker.connect(state.value, send, normalizeProps)) return () => { const api = apiRef.value - const [hue, saturation, lightness] = api.channels return ( <> - - - - - + { + console.log("change:", serialize(e.target as HTMLFormElement, { hash: true })) + }} + > + + + + Select Color: {api.valueAsString} + - - - + + + + + + + + - - - - + + + + + + + - - - - - - + + + + - - - - - {api.value} - + + + + + - + + + + + + - - - - + + + + + + {api.valueAsString} + - - + + + + {presets.map((preset) => ( + + + + + + + ))} + + + + + + + - - Eye Dropper - + Submit + Reset + diff --git a/packages/machines/color-picker/package.json b/packages/machines/color-picker/package.json index 0d9a96cc18..ac28ddd392 100644 --- a/packages/machines/color-picker/package.json +++ b/packages/machines/color-picker/package.json @@ -38,12 +38,14 @@ "@zag-js/core": "workspace:*", "@zag-js/anatomy": "workspace:*", "@zag-js/dom-query": "workspace:*", + "@zag-js/tabbable": "workspace:*", + "@zag-js/dismissable": "workspace:*", "@zag-js/dom-event": "workspace:*", "@zag-js/utils": "workspace:*", "@zag-js/form-utils": "workspace:*", "@zag-js/color-utils": "workspace:*", "@zag-js/visually-hidden": "workspace:*", - "@zag-js/numeric-range": "workspace:*", + "@zag-js/popper": "workspace:*", "@zag-js/text-selection": "workspace:*", "@zag-js/types": "workspace:*" }, diff --git a/packages/machines/color-picker/src/color-picker.anatomy.ts b/packages/machines/color-picker/src/color-picker.anatomy.ts index 8ac2ebce24..2f9b83ed30 100644 --- a/packages/machines/color-picker/src/color-picker.anatomy.ts +++ b/packages/machines/color-picker/src/color-picker.anatomy.ts @@ -1,17 +1,23 @@ import { createAnatomy } from "@zag-js/anatomy" export const anatomy = createAnatomy("color-picker", [ + "root", + "label", + "control", + "trigger", + "positioner", + "content", "area", "areaThumb", - "areaGradient", + "areaBackground", + "channelSlider", "channelSliderTrack", - "channelSliderTrackBackground", "channelSliderThumb", "channelInput", + "transparancyGrid", + "swatchGroup", + "swatchTrigger", "swatch", - "swatchBackground", - "content", - "label", "eyeDropperTrigger", ]) diff --git a/packages/machines/color-picker/src/color-picker.connect.ts b/packages/machines/color-picker/src/color-picker.connect.ts index a958a8bd41..793ed3c70f 100644 --- a/packages/machines/color-picker/src/color-picker.connect.ts +++ b/packages/machines/color-picker/src/color-picker.connect.ts @@ -1,10 +1,4 @@ -import { - getColorAreaGradient, - normalizeColor, - type Color, - type ColorChannel, - type ColorFormat, -} from "@zag-js/color-utils" +import { getColorAreaGradient, normalizeColor } from "@zag-js/color-utils" import { getEventKey, getEventPoint, @@ -14,69 +8,136 @@ import { isModifiedEvent, type EventKeyMap, } from "@zag-js/dom-event" -import { dataAttr, raf } from "@zag-js/dom-query" +import { dataAttr, query } from "@zag-js/dom-query" +import { getPlacementStyles } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./color-picker.anatomy" import { dom } from "./color-picker.dom" -import type { - ColorAreaProps, - ColorChannelInputProps, - ColorChannelProps, - ColorSwatchProps, - MachineApi, - Send, - State, -} from "./color-picker.types" -import { getChannelDetails } from "./utils/get-channel-details" +import type { AreaProps, MachineApi, Send, State } from "./color-picker.types" import { getChannelDisplayColor } from "./utils/get-channel-display-color" -import { getChannelInputRange, getChannelInputValue } from "./utils/get-channel-input-value" -import { getSliderBgImage } from "./utils/get-slider-background" +import { getChannelRange, getChannelValue } from "./utils/get-channel-input-value" +import { getSliderBackground } from "./utils/get-slider-background" export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { - const valueAsColor = state.context.valueAsColor const value = state.context.value + const valueAsString = state.context.valueAsString const isDisabled = state.context.isDisabled const isInteractive = state.context.isInteractive - const isDragging = state.matches("dragging") - const channels = valueAsColor.getColorChannels() + const isDragging = state.hasTag("dragging") + const isOpen = state.hasTag("open") + + const channels = value.getChannels() + + const getAreaChannels = (props: AreaProps) => ({ + value: value.toFormat("hsl"), + xChannel: props.xChannel ?? "saturation", + yChannel: props.yChannel ?? "lightness", + }) + + const currentPlacement = state.context.currentPlacement + const popperStyles = getPlacementStyles({ + ...state.context.positioning, + placement: currentPlacement, + }) return { isDragging, - value, - valueAsColor, - alpha: valueAsColor.getChannelValue("alpha"), + isOpen, + valueAsString, + value: value, channels, - - setColor(value: string | Color) { + setColor(value) { send({ type: "VALUE.SET", value: normalizeColor(value), src: "set-color" }) }, - - setChannelValue(channel: ColorChannel, value: number) { - const color = valueAsColor.withChannelValue(channel, value) + getChannelValue(channel) { + return getChannelValue(value, channel) + }, + setChannelValue(channel, channelValue) { + const color = value.withChannelValue(channel, channelValue) send({ type: "VALUE.SET", value: color, src: "set-channel" }) }, - - setFormat(format: ColorFormat) { - const value = valueAsColor.toFormat(format) - send({ type: "VALUE.SET", value, src: "set-format" }) + setFormat(format) { + const formatValue = value.toFormat(format) + send({ type: "VALUE.SET", value: formatValue, src: "set-format" }) }, - - setAlpha(value: number) { - const color = valueAsColor.withChannelValue("alpha", value) + getAlpha() { + return value.getChannelValue("alpha") + }, + setAlpha(alphaValue) { + const color = value.withChannelValue("alpha", alphaValue) send({ type: "VALUE.SET", value: color, src: "set-alpha" }) }, + rootProps: normalize.element({ + ...parts.root.attrs, + dir: state.context.dir, + id: dom.getRootId(state.context), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(state.context.readOnly), + style: { + "--value": value.toString("css"), + }, + }), + + labelProps: normalize.element({ + ...parts.label.attrs, + dir: state.context.dir, + id: dom.getLabelId(state.context), + htmlFor: dom.getHiddenInputId(state.context), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(state.context.readOnly), + onClick(event) { + event.preventDefault() + const inputEl = query(dom.getControlEl(state.context), "[data-channel=hex]") + inputEl?.focus({ preventScroll: true }) + }, + }), + + controlProps: normalize.element({ + ...parts.control.attrs, + id: dom.getControlId(state.context), + dir: state.context.dir, + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(state.context.readOnly), + }), + + triggerProps: normalize.button({ + ...parts.trigger.attrs, + id: dom.getTriggerId(state.context), + dir: state.context.dir, + "aria-labelledby": dom.getLabelId(state.context), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(state.context.readOnly), + "data-placement": currentPlacement, + type: "button", + onClick() { + send({ type: "TRIGGER.CLICK" }) + }, + style: { + position: "relative", + }, + }), + + positionerProps: normalize.element({ + ...parts.positioner.attrs, + id: dom.getPositionerId(state.context), + dir: state.context.dir, + style: popperStyles.floating, + }), + contentProps: normalize.element({ ...parts.content.attrs, id: dom.getContentId(state.context), dir: state.context.dir, + "data-placement": currentPlacement, + hidden: !isOpen, }), - getAreaProps(props: ColorAreaProps) { - const { xChannel, yChannel } = props - const { areaStyles } = getColorAreaGradient(state.context.valueAsColor, { + getAreaProps(props = {}) { + const { xChannel, yChannel } = getAreaChannels(props) + const { areaStyles } = getColorAreaGradient(value, { xChannel, yChannel, dir: state.context.dir, @@ -96,6 +157,7 @@ export function connect(state: State, send: Send, normalize const channel = { xChannel, yChannel } send({ type: "AREA.POINTER_DOWN", point, channel, id: "area" }) + event.preventDefault() }, style: { position: "relative", @@ -106,16 +168,16 @@ export function connect(state: State, send: Send, normalize }) }, - getAreaGradientProps(props: ColorAreaProps) { - const { xChannel, yChannel } = props - const { areaGradientStyles } = getColorAreaGradient(valueAsColor, { + getAreaBackgroundProps(props = {}) { + const { xChannel, yChannel } = getAreaChannels(props) + const { areaGradientStyles } = getColorAreaGradient(value, { xChannel, yChannel, dir: state.context.dir, }) return normalize.element({ - ...parts.areaGradient.attrs, + ...parts.areaBackground.attrs, id: dom.getAreaGradientId(state.context), style: { position: "relative", @@ -126,16 +188,17 @@ export function connect(state: State, send: Send, normalize }) }, - getAreaThumbProps(props: ColorAreaProps) { - const { xChannel, yChannel } = props - const { getThumbPosition } = getChannelDetails(valueAsColor, xChannel, yChannel) - const { x, y } = getThumbPosition() - + getAreaThumbProps(props = {}) { + const { xChannel, yChannel, value: valueAsHSL } = getAreaChannels(props) const channel = { xChannel, yChannel } + const x = valueAsHSL.getChannelValuePercent(xChannel) + const y = 1 - valueAsHSL.getChannelValuePercent(yChannel) + return normalize.element({ ...parts.areaThumb.attrs, id: dom.getAreaThumbId(state.context), + dir: state.context.dir, tabIndex: isDisabled ? undefined : 0, "data-disabled": dataAttr(isDisabled), role: "presentation", @@ -146,13 +209,10 @@ export function connect(state: State, send: Send, normalize transform: "translate(-50%, -50%)", touchAction: "none", forcedColorAdjust: "none", - background: valueAsColor.withChannelValue("alpha", 1).toString("css"), - }, - onBlur() { - send("AREA.BLUR") + background: value.withChannelValue("alpha", 1).toString("css"), }, onFocus() { - send({ type: "AREA.FOCUS", id: "area" }) + send({ type: "AREA.FOCUS", id: "area", channel }) }, onKeyDown(event) { if (!isInteractive) return @@ -178,6 +238,9 @@ export function connect(state: State, send: Send, normalize PageDown() { send({ type: "AREA.PAGE_DOWN", channel, step }) }, + Escape(event) { + event.stopPropagation() + }, } const exec = keyMap[getEventKey(event, state.context)] @@ -190,15 +253,32 @@ export function connect(state: State, send: Send, normalize }) }, - getChannelSliderTrackProps(props: ColorChannelProps) { - const { orientation = "horizontal", channel } = props + getTransparencyGridProps(props) { + const { size } = props + return normalize.element({ + ...parts.transparancyGrid.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 } = props return normalize.element({ - ...parts.channelSliderTrack.attrs, - id: dom.getChannelSliderTrackId(state.context, channel), - role: "group", + ...parts.channelSlider.attrs, "data-channel": channel, "data-orientation": orientation, + role: "presentation", onPointerDown(event) { if (!isInteractive) return @@ -207,43 +287,37 @@ export function connect(state: State, send: Send, normalize const point = getEventPoint(evt) send({ type: "CHANNEL_SLIDER.POINTER_DOWN", channel, point, id: channel, orientation }) + + event.preventDefault() }, style: { position: "relative", touchAction: "none", - forcedColorAdjust: "none", - backgroundImage: getSliderBgImage(state.context, { orientation, channel }), }, }) }, - getChannelSliderBackgroundProps(props: ColorChannelProps) { + getChannelSliderTrackProps(props) { const { orientation = "horizontal", channel } = props + return normalize.element({ - ...parts.channelSliderTrackBackground.attrs, - "data-orientation": orientation, + ...parts.channelSliderTrack.attrs, + id: dom.getChannelSliderId(state.context, channel), + role: "group", "data-channel": channel, + "data-orientation": orientation, style: { - position: "absolute", - backgroundColor: "#fff", - backgroundImage: [ - "linear-gradient(-45deg,#0000 75.5%,#bcbcbc 75.5%)", - "linear-gradient(45deg,#0000 75.5%,#bcbcbc 75.5%)", - "linear-gradient(-45deg,#bcbcbc 25.5%,#0000 25.5%)", - "linear-gradient(45deg,#bcbcbc 25.5%,#0000 25.5%)", - ].join(","), - backgroundSize: "16px 16px", - backgroundPosition: "-2px -2px,-2px 6px,6px -10px,-10px -2px", - inset: 0, - zIndex: -1, + position: "relative", + forcedColorAdjust: "none", + backgroundImage: getSliderBackground(state.context, { orientation, channel }), }, }) }, - getChannelSliderThumbProps(props: ColorChannelProps) { + getChannelSliderThumbProps(props) { const { orientation = "horizontal", channel } = props - const { minValue, maxValue, step: stepValue } = valueAsColor.getChannelRange(channel) - const channelValue = valueAsColor.getChannelValue(channel) + const { minValue, maxValue, step: stepValue } = value.getChannelRange(channel) + const channelValue = value.getChannelValue(channel) const offset = (channelValue - minValue) / (maxValue - minValue) @@ -269,17 +343,13 @@ export function connect(state: State, send: Send, normalize style: { forcedColorAdjust: "none", position: "absolute", - background: getChannelDisplayColor(valueAsColor, channel).toString("css"), + background: getChannelDisplayColor(value, channel).toString("css"), ...placementStyles, }, onFocus() { if (!isInteractive) return send({ type: "CHANNEL_SLIDER.FOCUS", channel }) }, - onBlur() { - if (!isInteractive) return - send({ type: "CHANNEL_SLIDER.BLUR", channel }) - }, onKeyDown(event) { if (!isInteractive) return const step = getEventStep(event) * stepValue @@ -309,6 +379,9 @@ export function connect(state: State, send: Send, normalize End() { send({ type: "CHANNEL_SLIDER.END", channel }) }, + Escape(event) { + event.stopPropagation() + }, } const exec = keyMap[getEventKey(event, state.context)] @@ -321,10 +394,10 @@ export function connect(state: State, send: Send, normalize }) }, - getChannelInputProps(props: ColorChannelInputProps) { + getChannelInputProps(props) { const { channel } = props const isTextField = channel === "hex" || channel === "css" - const range = getChannelInputRange(valueAsColor, channel) + const range = getChannelRange(value, channel) return normalize.input({ ...parts.channelInput.attrs, @@ -335,22 +408,30 @@ export function connect(state: State, send: Send, normalize "data-disabled": dataAttr(isDisabled), readOnly: state.context.readOnly, id: dom.getChannelInputId(state.context, channel), - defaultValue: getChannelInputValue(valueAsColor, channel), + defaultValue: getChannelValue(value, channel), min: range?.minValue, max: range?.maxValue, step: range?.step, + onBeforeInput(event) { + if (isTextField) return + const value = event.currentTarget.value + if (value.match(/[^0-9.]/g)) { + event.preventDefault() + } + }, onFocus(event) { send({ type: "CHANNEL_INPUT.FOCUS", channel }) - raf(() => event.target.select()) + event.target.select() }, onBlur(event) { - const value = event.currentTarget.value - send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField }) + const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber + send({ type: "CHANNEL_INPUT.BLUR", channel, value, isTextField }) }, onKeyDown(event) { if (event.key === "Enter") { - const value = event.currentTarget.value + const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField }) + event.preventDefault() } }, style: { @@ -367,15 +448,12 @@ export function connect(state: State, send: Send, normalize name: state.context.name, id: dom.getHiddenInputId(state.context), style: visuallyHiddenStyle, - defaultValue: value, - onChange(event) { - const value = event.currentTarget.value - send({ type: "VALUE.SET", value, src: "input.change" }) - }, + defaultValue: valueAsString, }), eyeDropperTriggerProps: normalize.button({ ...parts.eyeDropperTrigger.attrs, + type: "button", disabled: isDisabled, "data-disabled": dataAttr(isDisabled), "aria-label": "Pick a color from the screen", @@ -385,43 +463,40 @@ export function connect(state: State, send: Send, normalize }, }), - getSwatchBackgroundProps(props: ColorSwatchProps) { - const { value } = props - const alpha = normalizeColor(value).getChannelValue("alpha") - return normalize.element({ - ...parts.swatchBackground.attrs, - "data-alpha": alpha, + swatchGroupProps: normalize.element({ + ...parts.swatchGroup.attrs, + role: "group", + }), + + getSwatchTriggerProps(props) { + const { value: valueProp } = props + const color = normalizeColor(valueProp).toFormat(value.getFormat()) + return normalize.button({ + ...parts.swatchTrigger.attrs, + disabled: isDisabled, + type: "button", + "data-value": color.toString("hex"), + onClick() { + if (!isInteractive) return + send({ type: "VALUE.SET", value: color }) + }, style: { - width: "100%", - height: "100%", - background: "#fff", - backgroundImage: [ - "linear-gradient(-45deg,#0000 75.5%,#bcbcbc 75.5%)", - "linear-gradient(45deg,#0000 75.5%,#bcbcbc 75.5%)", - "linear-gradient(-45deg,#bcbcbc 25.5%,#0000 25.5%)", - "linear-gradient(45deg,#bcbcbc 25.5%,#0000 25.5%)", - ].join(","), - backgroundPosition: "-2px -2px,-2px 6px,6px -10px,-10px -2px", - backgroundSize: "16px 16px", - position: "absolute", - inset: "0px", - zIndex: -1, + position: "relative", }, }) }, - getSwatchProps(props: ColorSwatchProps) { - const { value, readOnly } = props - const color = normalizeColor(value).toFormat(valueAsColor.getColorFormat()) + getSwatchProps(props) { + const { value: valueProp, respectAlpha = true } = props + const colorValue = normalizeColor(valueProp) + const color = colorValue.toFormat(value.getFormat()) return normalize.element({ ...parts.swatch.attrs, - onClick() { - if (readOnly || !isInteractive) return - send({ type: "VALUE.SET", value: color }) - }, + "data-state": colorValue.isEqual(value) ? "selected" : "unselected", + "data-value": color.toString("hex"), style: { position: "relative", - background: color.toString("css"), + background: color.toString(respectAlpha ? "css" : "hex"), }, }) }, diff --git a/packages/machines/color-picker/src/color-picker.dom.ts b/packages/machines/color-picker/src/color-picker.dom.ts index e69baab9cd..06f7d60864 100644 --- a/packages/machines/color-picker/src/color-picker.dom.ts +++ b/packages/machines/color-picker/src/color-picker.dom.ts @@ -2,19 +2,28 @@ import type { ColorChannel } from "@zag-js/color-utils" import { getRelativePoint, type Point } from "@zag-js/dom-event" import { createScope, queryAll } from "@zag-js/dom-query" import type { MachineContext as Ctx } from "./color-picker.types" +import { getFirstFocusable } from "@zag-js/tabbable" +import { runIfFn } from "@zag-js/utils" export const dom = createScope({ + getRootId: (ctx: Ctx) => ctx.ids?.root ?? `color-picker:${ctx.id}`, + getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `color-picker:${ctx.id}:label`, + getHiddenInputId: (ctx: Ctx) => `color-picker:${ctx.id}:hidden-input`, + getControlId: (ctx: Ctx) => ctx.ids?.control ?? `color-picker:${ctx.id}:control`, + getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `color-picker:${ctx.id}:trigger`, getContentId: (ctx: Ctx) => ctx.ids?.content ?? `color-picker:${ctx.id}:content`, + getPositionerId: (ctx: Ctx) => `color-picker:${ctx.id}:positioner`, + getAreaId: (ctx: Ctx) => ctx.ids?.area ?? `color-picker:${ctx.id}:area`, getAreaGradientId: (ctx: Ctx) => ctx.ids?.areaGradient ?? `color-picker:${ctx.id}:area-gradient`, getAreaThumbId: (ctx: Ctx) => ctx.ids?.areaThumb ?? `color-picker:${ctx.id}:area-thumb`, - getChannelSliderTrackId: (ctx: Ctx, channel: ColorChannel) => + + getChannelSliderId: (ctx: Ctx, channel: ColorChannel) => ctx.ids?.channelSliderTrack?.(channel) ?? `color-picker:${ctx.id}:slider-track:${channel}`, getChannelInputId: (ctx: Ctx, channel: string) => ctx.ids?.channelInput?.(channel) ?? `color-picker:${ctx.id}:input:${channel}`, getChannelSliderThumbId: (ctx: Ctx, channel: ColorChannel) => ctx.ids?.channelSliderThumb?.(channel) ?? `color-picker:${ctx.id}:slider-thumb:${channel}`, - getHiddenInputId: (ctx: Ctx) => `color-picker:${ctx.id}:hidden-input`, getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)), getAreaThumbEl: (ctx: Ctx) => dom.getById(ctx, dom.getAreaThumbId(ctx)), @@ -32,8 +41,11 @@ export const dom = createScope({ return percent }, + getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)), + getTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getTriggerId(ctx)), + getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)), getChannelSliderTrackEl: (ctx: Ctx, channel: ColorChannel) => { - return dom.getById(ctx, dom.getChannelSliderTrackId(ctx, channel)) + return dom.getById(ctx, dom.getChannelSliderId(ctx, channel)) }, getChannelSliderValueFromPoint(ctx: Ctx, point: Point, channel: ColorChannel) { const trackEl = dom.getChannelSliderTrackEl(ctx, channel) @@ -44,4 +56,11 @@ export const dom = createScope({ getChannelInputEls: (ctx: Ctx) => { return queryAll(dom.getContentEl(ctx), "input[data-channel]") }, + getFirstFocusableEl: (ctx: Ctx) => getFirstFocusable(dom.getContentEl(ctx), "if-empty"), + getInitialFocusEl: (ctx: Ctx): HTMLElement | undefined => { + let el: any = runIfFn(ctx.initialFocusEl) + if (!el && ctx.autoFocus) el = dom.getFirstFocusableEl(ctx) + if (!el) el = dom.getContentEl(ctx) + return el + }, }) diff --git a/packages/machines/color-picker/src/color-picker.machine.ts b/packages/machines/color-picker/src/color-picker.machine.ts index 3647453d33..55512eaf1e 100644 --- a/packages/machines/color-picker/src/color-picker.machine.ts +++ b/packages/machines/color-picker/src/color-picker.machine.ts @@ -1,12 +1,14 @@ import { parseColor, type Color } from "@zag-js/color-utils" -import { createMachine } from "@zag-js/core" +import { createMachine, guards, ref } from "@zag-js/core" +import { trackDismissableElement } from "@zag-js/dismissable" import { trackPointerMove } from "@zag-js/dom-event" import { raf } from "@zag-js/dom-query" -import { trackFormControl } from "@zag-js/form-utils" -import { clampValue, getPercentValue, snapValueToStep } from "@zag-js/numeric-range" +import { dispatchInputValueEvent, trackFormControl } from "@zag-js/form-utils" +import { getPlacement } from "@zag-js/popper" import { disableTextSelection } from "@zag-js/text-selection" import { compact, tryCatch } from "@zag-js/utils" import { dom } from "./color-picker.dom" +import { parse } from "./color-picker.parse" import type { ColorFormat, ColorType, @@ -15,87 +17,129 @@ import type { MachineState, UserDefinedContext, } from "./color-picker.types" -import { getChannelDetails } from "./utils/get-channel-details" -import { getChannelInputValue } from "./utils/get-channel-input-value" +import { getChannelValue } from "./utils/get-channel-input-value" + +const { stateIn } = guards export function machine(userContext: UserDefinedContext) { const ctx = compact(userContext) return createMachine( { id: "color-picker", - initial: "idle", + initial: ctx.open ? "open" : "idle", context: { dir: "ltr", - value: "#D9D9D9", + value: parse("#000000"), disabled: false, ...ctx, activeId: null, activeChannel: null, activeOrientation: null, fieldsetDisabled: false, + autoFocus: true, + positioning: { + ...ctx.positioning, + placement: "bottom", + }, }, computed: { isRtl: (ctx) => ctx.dir === "rtl", isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled, isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly), - valueAsColor: (ctx) => parseColor(ctx.value) as Color, + valueAsString: (ctx) => ctx.value.toString("css"), + }, + + activities: ["trackFormControl"], + + watch: { + valueAsString: ["syncInputElements"], + open: ["toggleVisibility"], }, on: { "VALUE.SET": { actions: ["setValue"], }, - }, - - activities: ["trackFormControl"], - - watch: { - value: ["syncInputElements"], + "CHANNEL_INPUT.FOCUS": [ + { + guard: stateIn("idle"), + target: "focused", + actions: ["setActiveChannel"], + }, + { + actions: ["setActiveChannel"], + }, + ], + "CHANNEL_INPUT.BLUR": [ + { + guard: stateIn("focused"), + target: "idle", + actions: ["setChannelColorFromInput"], + }, + { + actions: ["setChannelColorFromInput"], + }, + ], + "CHANNEL_INPUT.CHANGE": { + actions: ["setChannelColorFromInput"], + }, }, states: { idle: { + tags: ["closed"], + on: { + OPEN: { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"], + }, + "TRIGGER.CLICK": { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"], + }, + }, + }, + + focused: { + tags: ["closed", "focused"], on: { + OPEN: { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"], + }, + "TRIGGER.CLICK": { + target: "open", + actions: ["setInitialFocus", "invokeOnOpen"], + }, + }, + }, + + open: { + tags: ["open"], + activities: ["trackPositioning", "trackDismissableElement"], + on: { + "TRIGGER.CLICK": { + target: "idle", + actions: ["invokeOnClose"], + }, "EYEDROPPER.CLICK": { actions: ["openEyeDropper"], }, "AREA.POINTER_DOWN": { - target: "dragging", + target: "open:dragging", actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"], }, "AREA.FOCUS": { - target: "focused", actions: ["setActiveChannel"], }, "CHANNEL_SLIDER.POINTER_DOWN": { - target: "dragging", + target: "open:dragging", actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"], }, "CHANNEL_SLIDER.FOCUS": { - target: "focused", - actions: ["setActiveChannel"], - }, - "CHANNEL_INPUT.FOCUS": { - target: "focused", actions: ["setActiveChannel"], }, - "CHANNEL_INPUT.CHANGE": { - actions: ["setChannelColorFromInput"], - }, - }, - }, - - focused: { - on: { - "AREA.POINTER_DOWN": { - target: "dragging", - actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"], - }, - "CHANNEL_SLIDER.POINTER_DOWN": { - target: "dragging", - actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"], - }, "AREA.ARROW_LEFT": { actions: ["decrementXChannel"], }, @@ -138,45 +182,100 @@ export function machine(userContext: UserDefinedContext) { "CHANNEL_SLIDER.END": { actions: ["setChannelToMax"], }, - "CHANNEL_INPUT.FOCUS": { - actions: ["setActiveChannel"], - }, - "CHANNEL_INPUT.CHANGE": { - actions: ["setChannelColorFromInput"], - }, - "CHANNEL_SLIDER.BLUR": { - target: "idle", - }, - "AREA.BLUR": { + INTERACT_OUTSIDE: [ + { + guard: "shouldRestoreFocus", + target: "focused", + actions: ["setReturnFocus", "invokeOnClose"], + }, + { + target: "idle", + actions: ["invokeOnClose"], + }, + ], + CLOSE: { target: "idle", + actions: ["invokeOnClose"], }, }, }, - dragging: { + "open:dragging": { + tags: ["open"], exit: ["clearActiveChannel"], - activities: ["trackPointerMove", "disableTextSelection"], + activities: ["trackPointerMove", "disableTextSelection", "trackPositioning", "trackDismissableElement"], on: { "AREA.POINTER_MOVE": { - actions: ["setAreaColorFromPoint"], + actions: ["setAreaColorFromPoint", "focusAreaThumb"], }, "AREA.POINTER_UP": { - target: "focused", + target: "open", actions: ["invokeOnChangeEnd"], }, "CHANNEL_SLIDER.POINTER_MOVE": { - actions: ["setChannelColorFromPoint"], + actions: ["setChannelColorFromPoint", "focusChannelThumb"], }, "CHANNEL_SLIDER.POINTER_UP": { - target: "focused", + target: "open", actions: ["invokeOnChangeEnd"], }, + INTERACT_OUTSIDE: [ + { + guard: "shouldRestoreFocus", + target: "focused", + actions: ["setReturnFocus", "invokeOnClose"], + }, + { + target: "idle", + actions: ["invokeOnClose"], + }, + ], + CLOSE: { + target: "idle", + actions: ["invokeOnClose"], + }, }, }, }, }, { + guards: { + isTargetFocusable: (_ctx, evt) => evt.restoreFocus, + }, activities: { + trackPositioning(ctx) { + ctx.currentPlacement = ctx.positioning.placement + const anchorEl = dom.getTriggerEl(ctx) + const getPositionerEl = () => dom.getPositionerEl(ctx) + return getPlacement(anchorEl, getPositionerEl, { + ...ctx.positioning, + defer: true, + onComplete(data) { + ctx.currentPlacement = data.placement + }, + onCleanup() { + ctx.currentPlacement = undefined + }, + }) + }, + trackDismissableElement(ctx, _evt, { send }) { + const getContentEl = () => dom.getContentEl(ctx) + let restoreFocus = true + return trackDismissableElement(getContentEl, { + exclude: dom.getTriggerEl(ctx), + defer: true, + onInteractOutside(event) { + ctx.onInteractOutside?.(event) + if (event.defaultPrevented) return + restoreFocus = !(event.detail.focusable || event.detail.contextmenu) + }, + onPointerDownOutside: ctx.onPointerDownOutside, + onFocusOutside: ctx.onFocusOutside, + onDismiss() { + send({ type: "INTERACT_OUTSIDE", restoreFocus }) + }, + }) + }, trackFormControl(ctx, _evt, { send, initialContext }) { const inputEl = dom.getHiddenInputEl(ctx) return trackFormControl(inputEl, { @@ -213,21 +312,17 @@ export function machine(userContext: UserDefinedContext) { picker .open() .then(({ sRGBHex }: { sRGBHex: string }) => { - const format = ctx.valueAsColor.getColorFormat() + const format = ctx.value.getFormat() const color = parseColor(sRGBHex).toFormat(format) as Color set.value(ctx, color) - ctx.onValueChangeEnd?.({ value: ctx.value, valueAsColor: color }) + ctx.onValueChangeEnd?.({ value: ctx.value, valueAsString: ctx.valueAsString }) }) .catch(() => void 0) }, setActiveChannel(ctx, evt) { ctx.activeId = evt.id - if (evt.channel) { - ctx.activeChannel = evt.channel - } - if (evt.orientation) { - ctx.activeOrientation = evt.orientation - } + if (evt.channel) ctx.activeChannel = evt.channel + if (evt.orientation) ctx.activeOrientation = evt.orientation }, clearActiveChannel(ctx) { ctx.activeChannel = null @@ -240,10 +335,10 @@ export function machine(userContext: UserDefinedContext) { const percent = dom.getAreaValueFromPoint(ctx, evt.point) if (!percent) return - const { getColorFromPoint } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const color = getColorFromPoint(percent.x, percent.y) + const xValue = ctx.value.getChannelPercentValue(xChannel, percent.x) + const yValue = ctx.value.getChannelPercentValue(yChannel, 1 - percent.y) - if (!color) return + const color = ctx.value.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue) set.value(ctx, color) }, setChannelColorFromPoint(ctx, evt) { @@ -252,30 +347,18 @@ export function machine(userContext: UserDefinedContext) { const percent = dom.getChannelSliderValueFromPoint(ctx, evt.point, channel) if (!percent) return - const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(channel) const orientation = ctx.activeOrientation || "horizontal" + const channelPercent = orientation === "horizontal" ? percent.x : percent.y - const point = orientation === "horizontal" ? percent.x : percent.y - const channelValue = getPercentValue(point, minValue, maxValue, step) - - const value = snapValueToStep(channelValue - step, minValue, maxValue, step) - const newColor = ctx.valueAsColor.withChannelValue(channel, value) - - set.value(ctx, newColor) + const value = ctx.value.getChannelPercentValue(channel, channelPercent) + const color = ctx.value.withChannelValue(channel, value) + set.value(ctx, color) }, setValue(ctx, evt) { set.value(ctx, evt.value) }, syncInputElements(ctx) { - // sync channel inputs - const inputs = dom.getChannelInputEls(ctx) - inputs.forEach((input) => { - const channel = input.dataset.channel as ExtendedColorChannel | null - dom.setValue(input, getChannelInputValue(ctx.valueAsColor, channel)) - }) - - // sync hidden input - dom.setValue(dom.getHiddenInputEl(ctx), ctx.value) + sync.inputs(ctx) }, invokeOnChangeEnd(ctx) { invoke.changeEnd(ctx) @@ -285,7 +368,7 @@ export function machine(userContext: UserDefinedContext) { // handle alpha channel if (channel === "alpha") { - const newColor = ctx.valueAsColor.withChannelValue("alpha", value) + const newColor = ctx.value.withChannelValue("alpha", parseFloat(value)) set.value(ctx, newColor) return } @@ -293,111 +376,130 @@ export function machine(userContext: UserDefinedContext) { // handle other text channels if (isTextField) { const format: ColorFormat = "hsl" - const currentAlpha = ctx.valueAsColor.getChannelValue("alpha") + const currentAlpha = ctx.value.getChannelValue("alpha") - const newColor = tryCatch( + const color = tryCatch( () => parseColor(value).toFormat(format).withChannelValue("alpha", currentAlpha), - () => ctx.valueAsColor, + () => ctx.value, ) // set channel input value immediately (in event user types native css color, we need to convert it to the current channel format) const inputEl = dom.getChannelInputEl(ctx, channel) - dom.setValue(inputEl, getChannelInputValue(ctx.valueAsColor, channel)) + dom.setValue(inputEl, getChannelValue(color, channel)) - set.value(ctx, newColor) + set.value(ctx, color) return } // handle other channels - const newColor = ctx.valueAsColor.withChannelValue(channel, value) - set.value(ctx, newColor) + const color = ctx.value.withChannelValue(channel, value) + set.value(ctx, color) }, - incrementChannel(ctx, evt) { - const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel) - const channelValue = ctx.valueAsColor.getChannelValue(evt.channel) - const value = snapValueToStep(channelValue + evt.step, minValue, maxValue, step) - const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) + const color = ctx.value.incrementChannel(evt.channel, evt.step) set.value(ctx, color) }, decrementChannel(ctx, evt) { - const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel) - const channelValue = ctx.valueAsColor.getChannelValue(evt.channel) - const value = snapValueToStep(channelValue - evt.step, minValue, maxValue, step) - const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) + const color = ctx.value.decrementChannel(evt.channel, evt.step) set.value(ctx, color) }, - incrementXChannel(ctx, evt) { - const { xChannel, yChannel } = evt.channel - const { incrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const color = ctx.valueAsColor.withChannelValue(xChannel, incrementX(evt.step)) + const { xChannel } = evt.channel + const color = ctx.value.incrementChannel(xChannel, evt.step) set.value(ctx, color) }, decrementXChannel(ctx, evt) { - const { xChannel, yChannel } = evt.channel - const { decrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const color = ctx.valueAsColor.withChannelValue(xChannel, decrementX(evt.step)) + const { xChannel } = evt.channel + const color = ctx.value.decrementChannel(xChannel, evt.step) set.value(ctx, color) }, - incrementYChannel(ctx, evt) { - const { xChannel, yChannel } = evt.channel - const { incrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const color = ctx.valueAsColor.withChannelValue(yChannel, incrementY(evt.step)) + const { yChannel } = evt.channel + const color = ctx.value.incrementChannel(yChannel, evt.step) set.value(ctx, color) }, decrementYChannel(ctx, evt) { - const { xChannel, yChannel } = evt.channel - const { decrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const color = ctx.valueAsColor.withChannelValue(yChannel, decrementY(evt.step)) + const { yChannel } = evt.channel + const color = ctx.value.decrementChannel(yChannel, evt.step) set.value(ctx, color) }, - setChannelToMax(ctx, evt) { - const { maxValue } = ctx.valueAsColor.getChannelRange(evt.channel) - const color = ctx.valueAsColor.withChannelValue(evt.channel, maxValue) + const range = ctx.value.getChannelRange(evt.channel) + const color = ctx.value.withChannelValue(evt.channel, range.maxValue) set.value(ctx, color) }, setChannelToMin(ctx, evt) { - const { minValue } = ctx.valueAsColor.getChannelRange(evt.channel) - const color = ctx.valueAsColor.withChannelValue(evt.channel, minValue) + const range = ctx.value.getChannelRange(evt.channel) + const color = ctx.value.withChannelValue(evt.channel, range.minValue) set.value(ctx, color) }, focusAreaThumb(ctx) { raf(() => { - dom.getAreaThumbEl(ctx)?.focus({ preventScroll: true }) + dom?.focus(ctx, dom.getAreaThumbEl(ctx)) }) }, focusChannelThumb(ctx, evt) { raf(() => { - dom.getChannelSliderThumbEl(ctx, evt.channel)?.focus({ preventScroll: true }) + dom?.focus(ctx, dom.getChannelSliderThumbEl(ctx, evt.channel)) }) }, + setInitialFocus(ctx) { + raf(() => { + dom.getInitialFocusEl(ctx)?.focus({ preventScroll: true }) + }) + }, + setReturnFocus(ctx) { + raf(() => { + dom?.focus(ctx, dom.getTriggerEl(ctx)) + }) + }, + invokeOnOpen(ctx) { + ctx.onOpenChange?.({ open: true }) + }, + invokeOnClose(ctx) { + ctx.onOpenChange?.({ open: false }) + }, + toggleVisibility(ctx, _evt, { send }) { + send({ type: ctx.open ? "OPEN" : "CLOSE", src: "controlled" }) + }, }, }, ) } +const sync = { + inputs(ctx: MachineContext) { + // sync channel inputs + const channelInputs = dom.getChannelInputEls(ctx) + channelInputs.forEach((inputEl) => { + const channel = inputEl.dataset.channel as ExtendedColorChannel | null + dom.setValue(inputEl, getChannelValue(ctx.value, channel)) + }) + }, +} + const invoke = { changeEnd(ctx: MachineContext) { ctx.onValueChangeEnd?.({ value: ctx.value, - valueAsColor: ctx.valueAsColor, + valueAsString: ctx.valueAsString, }) }, change(ctx: MachineContext) { ctx.onValueChange?.({ value: ctx.value, - valueAsColor: ctx.valueAsColor, + valueAsString: ctx.valueAsString, }) + + dispatchInputValueEvent(dom.getHiddenInputEl(ctx), { value: ctx.valueAsString }) }, } const set = { - value(ctx: MachineContext, color: Color | ColorType) { - if (ctx.valueAsColor.isEqual(color)) return - ctx.value = color.toString("css") + value(ctx: MachineContext, color: Color | ColorType | undefined) { + sync.inputs(ctx) + if (!color || ctx.value.isEqual(color)) return + ctx.value = ref(color) as Color invoke.change(ctx) }, } diff --git a/packages/machines/color-picker/src/color-picker.parse.ts b/packages/machines/color-picker/src/color-picker.parse.ts new file mode 100644 index 0000000000..2aa9f86353 --- /dev/null +++ b/packages/machines/color-picker/src/color-picker.parse.ts @@ -0,0 +1,4 @@ +import { parseColor, type Color } from "@zag-js/color-utils" +import { ref } from "@zag-js/core" + +export const parse = (color: string): Color => ref(parseColor(color)) as unknown as Color diff --git a/packages/machines/color-picker/src/color-picker.types.ts b/packages/machines/color-picker/src/color-picker.types.ts index e76c7637f8..946cfc8711 100644 --- a/packages/machines/color-picker/src/color-picker.types.ts +++ b/packages/machines/color-picker/src/color-picker.types.ts @@ -1,14 +1,21 @@ import type { Color, ColorAxes, ColorChannel, ColorFormat, ColorType } from "@zag-js/color-utils" import type { StateMachine as S } from "@zag-js/core" -import type { CommonProperties, Context, Orientation, PropTypes, RequiredBy } from "@zag-js/types" +import type { InteractOutsideHandlers } from "@zag-js/dismissable" +import type { PositioningOptions } from "@zag-js/popper" +import type { CommonProperties, Context, MaybeElement, Orientation, PropTypes, RequiredBy } from "@zag-js/types" +import type { MaybeFunction } from "@zag-js/utils" /* ----------------------------------------------------------------------------- * Callback details * -----------------------------------------------------------------------------*/ export interface ValueChangeDetails { - value: string - valueAsColor: Color + value: Color + valueAsString: string +} + +export interface OpenChangeDetails { + open: boolean } /* ----------------------------------------------------------------------------- @@ -18,6 +25,11 @@ export interface ValueChangeDetails { export type ExtendedColorChannel = ColorChannel | "hex" | "css" type ElementIds = Partial<{ + root: string + control: string + trigger: string + label: string + input: string content: string area: string areaGradient: string @@ -27,7 +39,7 @@ type ElementIds = Partial<{ channelSliderThumb(id: ColorChannel): string }> -interface PublicContext extends CommonProperties { +interface PublicContext extends CommonProperties, InteractOutsideHandlers { /** * The ids of the elements in the color picker. Useful for composition. */ @@ -39,7 +51,7 @@ interface PublicContext extends CommonProperties { /** * The current color value */ - value: string + value: Color /** * Whether the color picker is disabled */ @@ -57,9 +69,40 @@ interface PublicContext extends CommonProperties { */ onValueChangeEnd?: (details: ValueChangeDetails) => void /** - * The name for the form input + * Handler that is called when the user opens or closes the color picker. + */ + onOpenChange?: (details: OpenChangeDetails) => void + /** + * The name for the form input */ name?: string + /** + * The color format to use (rgb, hsl, hsb) + * @default "hsl" + */ + format?: ColorFormat + /** + * The alpha format to use (decimal, percent) + * @default "decimal" + */ + alphaFormat?: "decimal" | "percent" + /** + * The positioning options for the color picker + */ + positioning: PositioningOptions + /** + * Whether to automatically set focus on the first focusable + * content within the color picker when opened. + */ + autoFocus?: boolean + /** + * The initial focus element when the color picker is opened. + */ + initialFocusEl?: MaybeFunction + /** + * Whether the color picker is open + */ + open?: boolean } type PrivateContext = Context<{ @@ -83,6 +126,11 @@ type PrivateContext = Context<{ * Whether the checkbox's fieldset is disabled */ fieldsetDisabled: boolean + /** + * @internal + * The current placement of the color picker + */ + currentPlacement?: PositioningOptions["placement"] }> type ComputedContext = Readonly<{ @@ -100,7 +148,7 @@ type ComputedContext = Readonly<{ * @computed * The color value as a Color object */ - valueAsColor: Color + valueAsString: string /** * @computed * Whether the color picker is disabled @@ -113,7 +161,8 @@ export type UserDefinedContext = RequiredBy export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {} export interface MachineState { - value: "idle" | "focused" | "dragging" + tags: "open" | "closed" | "dragging" | "focused" + value: "idle" | "focused" | "open" | "open:dragging" } export type State = S.State @@ -124,43 +173,60 @@ export type Send = S.Send * Component API * -----------------------------------------------------------------------------*/ -export interface ColorChannelProps { +export interface ChannelProps { channel: ColorChannel orientation?: Orientation } -export interface ColorChannelInputProps { +export interface ChannelInputProps { channel: ExtendedColorChannel orientation?: Orientation } -export interface ColorAreaProps { - xChannel: ColorChannel - yChannel: ColorChannel +export interface AreaProps { + xChannel?: ColorChannel + yChannel?: ColorChannel } -export interface ColorSwatchProps { - readOnly?: boolean +export interface SwatchTriggerProps { + /** + * The color value + */ value: string | Color } +export interface SwatchProps { + /** + * The color value + */ + value: string | Color + /** + * Whether to include the alpha channel in the color + */ + respectAlpha?: boolean +} + +export interface TransparancyGridProps { + size: string +} + export interface MachineApi { /** * Whether the color picker is being dragged */ isDragging: boolean /** - * The current color value (as a string) + * Whether the color picker is open */ - value: string + isOpen: boolean /** - * The current color value (as a Color object) + * The current color value (as a string) */ - valueAsColor: Color + value: Color /** - * The alpha value of the color + * The current color value (as a Color object) */ - alpha: number + valueAsString: string /** * The current color channels of the color */ @@ -169,6 +235,10 @@ export interface MachineApi { * Function to set the color value */ setColor(value: string | Color): void + /** + * Function to set the color value + */ + getChannelValue(channel: ColorChannel): string /** * Function to set the color value of a specific channel */ @@ -177,23 +247,39 @@ export interface MachineApi { * Function to set the color format */ setFormat(format: ColorFormat): void + /** + * The alpha value of the color + */ + getAlpha(): number /** * Function to set the color alpha */ setAlpha(value: number): void + rootProps: T["element"] + labelProps: T["element"] + controlProps: T["element"] + triggerProps: T["button"] + positionerProps: T["element"] contentProps: T["element"] hiddenInputProps: T["input"] - getAreaProps(props: ColorAreaProps): T["element"] - getAreaGradientProps(props: ColorAreaProps): T["element"] - getAreaThumbProps(props: ColorAreaProps): T["element"] - getChannelSliderTrackProps(props: ColorChannelProps): T["element"] - getChannelSliderBackgroundProps(props: ColorChannelProps): T["element"] - getChannelSliderThumbProps(props: ColorChannelProps): T["element"] - getChannelInputProps(props: ColorChannelInputProps): T["input"] + + getAreaProps(props?: AreaProps): T["element"] + getAreaBackgroundProps(props?: AreaProps): T["element"] + getAreaThumbProps(props?: AreaProps): T["element"] + + getChannelSliderProps(props: ChannelProps): T["element"] + getChannelSliderTrackProps(props: ChannelProps): T["element"] + getChannelSliderThumbProps(props: ChannelProps): T["element"] + getChannelInputProps(props: ChannelInputProps): T["input"] + + getTransparencyGridProps(props: TransparancyGridProps): T["element"] + eyeDropperTriggerProps: T["button"] - getSwatchBackgroundProps(props: ColorSwatchProps): T["element"] - getSwatchProps(props: ColorSwatchProps): T["element"] + + swatchGroupProps: T["element"] + getSwatchTriggerProps(props: SwatchTriggerProps): T["button"] + getSwatchProps(props: SwatchProps): T["element"] } /* ----------------------------------------------------------------------------- diff --git a/packages/machines/color-picker/src/index.ts b/packages/machines/color-picker/src/index.ts index 6f51016b66..8e4c46f6ce 100644 --- a/packages/machines/color-picker/src/index.ts +++ b/packages/machines/color-picker/src/index.ts @@ -1,16 +1,17 @@ export { anatomy } from "./color-picker.anatomy" export { connect } from "./color-picker.connect" export { machine } from "./color-picker.machine" +export { parse } from "./color-picker.parse" export type { + MachineApi as Api, + AreaProps, + ChannelInputProps, + ChannelProps, Color, ColorAxes, - ColorFormat, - ColorAreaProps, ColorChannel, - ColorChannelProps, - ColorChannelInputProps, - ColorSwatchProps, + ColorFormat, ColorType, UserDefinedContext as Context, - MachineApi as Api, + SwatchProps, } from "./color-picker.types" diff --git a/packages/machines/color-picker/src/utils/get-channel-details.ts b/packages/machines/color-picker/src/utils/get-channel-details.ts deleted file mode 100644 index 5519b35df7..0000000000 --- a/packages/machines/color-picker/src/utils/get-channel-details.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Color, ColorChannel, ColorType } from "@zag-js/color-utils" -import { getPercentValue, snapValueToStep } from "@zag-js/numeric-range" - -export function getChannelDetails(color: Color, xChannel: ColorChannel, yChannel: ColorChannel) { - const channels = color.getColorSpaceAxes({ xChannel, yChannel }) - - const xChannelRange = color.getChannelRange(channels.xChannel) - const yChannelRange = color.getChannelRange(channels.yChannel) - - const { minValue: minValueX, maxValue: maxValueX, step: stepX, pageSize: pageSizeX } = xChannelRange - const { minValue: minValueY, maxValue: maxValueY, step: stepY, pageSize: pageSizeY } = yChannelRange - - const xValue = color.getChannelValue(channels.xChannel) - const yValue = color.getChannelValue(channels.yChannel) - - return { - channels, - xChannelStep: stepX, - yChannelStep: stepY, - xChannelPageStep: pageSizeX, - yChannelPageStep: pageSizeY, - xValue, - yValue, - getThumbPosition() { - let x = (xValue - minValueX) / (maxValueX - minValueX) - let y = 1 - (yValue - minValueY) / (maxValueY - minValueY) - return { x, y } - }, - incrementX(stepSize: number) { - return xValue + stepSize > maxValueX ? maxValueX : snapValueToStep(xValue + stepSize, minValueX, maxValueX, stepX) - }, - incrementY(stepSize: number) { - return yValue + stepSize > maxValueY ? maxValueY : snapValueToStep(yValue + stepSize, minValueY, maxValueY, stepY) - }, - decrementX(stepSize: number) { - return snapValueToStep(xValue - stepSize, minValueX, maxValueX, stepX) - }, - decrementY(stepSize: number) { - return snapValueToStep(yValue - stepSize, minValueY, maxValueY, stepY) - }, - getColorFromPoint(x: number, y: number) { - let newXValue = getPercentValue(x, minValueX, maxValueX, stepX) - let newYValue = getPercentValue(1 - y, minValueY, maxValueY, stepY) - - let newColor: ColorType | undefined - - if (newXValue !== xValue) { - newXValue = snapValueToStep(newXValue, minValueX, maxValueX, stepX) - newColor = color.withChannelValue(channels.xChannel, newXValue) - } - - if (newYValue !== yValue) { - newYValue = snapValueToStep(newYValue, minValueY, maxValueY, stepY) - newColor = (newColor || color).withChannelValue(channels.yChannel, newYValue) - } - - return newColor - }, - } -} diff --git a/packages/machines/color-picker/src/utils/get-channel-input-value.ts b/packages/machines/color-picker/src/utils/get-channel-input-value.ts index a303f887c8..40317d269f 100644 --- a/packages/machines/color-picker/src/utils/get-channel-input-value.ts +++ b/packages/machines/color-picker/src/utils/get-channel-input-value.ts @@ -1,44 +1,78 @@ -import type { Color } from "@zag-js/color-utils" +import { parseColor, type Color, type ColorChannelRange } from "@zag-js/color-utils" import type { ExtendedColorChannel } from "../color-picker.types" -export function getChannelInputValue(color: Color, channel: ExtendedColorChannel | null | undefined) { - if (channel == null) return +export function getChannelValue(color: Color, channel: ExtendedColorChannel | null | undefined): string { + 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() === "hsl" switch (channel) { - case "hex": - return color.toString("hex") - case "css": - return color.toString("css") case "hue": + return isHSL + ? color.toFormat("hsla").getChannelValue("hue").toString() + : color.toFormat("hsba").getChannelValue("hue").toString() + case "saturation": + return isHSL + ? color.toFormat("hsl").getChannelValue("saturation").toString() + : color.toFormat("hsb").getChannelValue("saturation").toString() + case "lightness": - return color.toFormat("hsl").getChannelValue("lightness").toString() + return color.toFormat("hsla").getChannelValue("lightness").toString() + case "brightness": - return color.toFormat("hsb").getChannelValue("brightness").toString() + return color.toFormat("hsba").getChannelValue("brightness").toString() + case "red": case "green": case "blue": - return color.toFormat("rgb").getChannelValue(channel).toString() + return color.toFormat("rgba").getChannelValue(channel).toString() + default: return color.getChannelValue(channel).toString() } } -export function getChannelInputRange(color: Color, channel: ExtendedColorChannel) { +export function getChannelRange(color: Color, channel: ExtendedColorChannel): ColorChannelRange | undefined { 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 undefined + case "hue": case "saturation": case "lightness": return color.toFormat("hsl").getChannelRange(channel) + case "brightness": return color.toFormat("hsb").getChannelRange(channel) + case "red": case "green": case "blue": return color.toFormat("rgb").getChannelRange(channel) + default: return color.getChannelRange(channel) } diff --git a/packages/machines/color-picker/src/utils/get-slider-background.ts b/packages/machines/color-picker/src/utils/get-slider-background.ts index dc59279764..59aef0e620 100644 --- a/packages/machines/color-picker/src/utils/get-slider-background.ts +++ b/packages/machines/color-picker/src/utils/get-slider-background.ts @@ -1,6 +1,6 @@ -import type { ColorChannelProps, MachineContext } from "../color-picker.types" +import type { ChannelProps, MachineContext } from "../color-picker.types" -function getSliderBgDirection(orientation: "vertical" | "horizontal", dir: "ltr" | "rtl") { +function getSliderBackgroundDirection(orientation: "vertical" | "horizontal", dir: "ltr" | "rtl") { if (orientation === "vertical") { return "top" } else if (dir === "ltr") { @@ -10,19 +10,19 @@ function getSliderBgDirection(orientation: "vertical" | "horizontal", dir: "ltr" } } -export const getSliderBgImage = (ctx: MachineContext, props: Required) => { +export const getSliderBackground = (ctx: MachineContext, props: Required) => { const { channel } = props - const dir = getSliderBgDirection(props.orientation, ctx.dir!) - const value = ctx.valueAsColor + const dir = getSliderBackgroundDirection(props.orientation, ctx.dir!) + const value = ctx.value - const { minValue, maxValue } = ctx.valueAsColor.getChannelRange(channel) + const { minValue, maxValue } = value.getChannelRange(channel) switch (channel) { case "hue": return `linear-gradient(to ${dir}, 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 = ctx.valueAsColor.withChannelValue(channel, minValue).toString("css") + 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 ${dir}, ${start}, ${middle}, ${end})` diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts index 4371c1c99b..6bd588eb98 100644 --- a/packages/machines/date-picker/src/date-picker.anatomy.ts +++ b/packages/machines/date-picker/src/date-picker.anatomy.ts @@ -2,6 +2,7 @@ import { createAnatomy } from "@zag-js/anatomy" export const anatomy = createAnatomy("date-picker").parts( "root", + "label", "clearTrigger", "content", "control", diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 5dff21a66e..d53029e651 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -267,6 +267,14 @@ export function connect(state: State, send: Send, normalize "data-readonly": dataAttr(readOnly), }), + labelProps: normalize.label({ + ...parts.label.attrs, + htmlFor: dom.getInputId(state.context), + "data-state": isOpen ? "open" : "closed", + "data-disabled": dataAttr(disabled), + "data-readonly": dataAttr(readOnly), + }), + controlProps: normalize.element({ ...parts.control.attrs, id: dom.getControlId(state.context), @@ -355,6 +363,16 @@ export function connect(state: State, send: Send, normalize }) }, + getTableHeadProps(props = {}) { + const { view = "day" } = props + return normalize.element({ + ...parts.tableHeader.attrs, + dir: state.context.dir, + "data-view": view, + "data-disabled": dataAttr(disabled), + }) + }, + getTableHeadProps(props = {}) { const { view = "day" } = props return normalize.element({ diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 01c6e87789..256ef58148 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -74,7 +74,7 @@ export function machine(userContext: UserDefinedContext) { return createMachine( { id: "datepicker", - initial: ctx.inline ? "open" : "idle", + initial: ctx.inline || ctx.open ? "open" : "idle", context: getInitialContext(ctx), computed: { valueText: (ctx) => @@ -109,6 +109,7 @@ export function machine(userContext: UserDefinedContext) { valueText: ["announceValueText"], inputValue: ["syncInputElement"], view: ["focusActiveCell"], + open: ["toggleVisibility"], }, on: { @@ -147,6 +148,10 @@ export function machine(userContext: UserDefinedContext) { target: "open", actions: ["focusFirstSelectedDate", "invokeOnOpen"], }, + OPEN: { + target: "open", + actions: ["invokeOnOpen"], + }, }, }, @@ -170,6 +175,10 @@ export function machine(userContext: UserDefinedContext) { target: "open", actions: ["setView", "invokeOnOpen"], }, + OPEN: { + target: "open", + actions: ["invokeOnOpen"], + }, }, }, @@ -362,6 +371,10 @@ export function machine(userContext: UserDefinedContext) { actions: ["focusTriggerElement", "setStartIndex", "invokeOnClose"], }, ], + CLOSE: { + target: "idle", + actions: ["setStartIndex", "invokeOnClose"], + }, }, }, }, @@ -673,6 +686,9 @@ export function machine(userContext: UserDefinedContext) { invokeOnClose(ctx) { ctx.onOpenChange?.({ open: false }) }, + toggleVisibility(ctx, _evt, { send }) { + send({ type: ctx.open ? "OPEN" : "CLOSE", src: "controlled" }) + }, }, compareFns: { startValue: (a, b) => a.toString() === b.toString(), diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index f2787d5eb0..e7826a1975 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -184,6 +184,10 @@ interface PublicContext extends DirectionProperty, CommonProperties { * The user provided options used to position the date picker content */ positioning: PositioningOptions + /** + * Whether the datepicker is open + */ + open?: boolean } type PrivateContext = Context<{ @@ -530,6 +534,7 @@ export interface MachineApi { getYearTableCellState(props: TableCellProps): TableCellState rootProps: T["element"] + labelProps: T["label"] controlProps: T["element"] contentProps: T["element"] positionerProps: T["element"] diff --git a/packages/machines/popover/src/popover.dom.ts b/packages/machines/popover/src/popover.dom.ts index b01e7fd210..383bb5bb01 100644 --- a/packages/machines/popover/src/popover.dom.ts +++ b/packages/machines/popover/src/popover.dom.ts @@ -1,5 +1,5 @@ import { createScope } from "@zag-js/dom-query" -import { getFirstTabbable, getFocusables, getLastTabbable, getTabbables } from "@zag-js/tabbable" +import { getFocusables } from "@zag-js/tabbable" import { runIfFn } from "@zag-js/utils" import type { MachineContext as Ctx } from "./popover.types" @@ -25,11 +25,6 @@ export const dom = createScope({ getFocusableEls: (ctx: Ctx) => getFocusables(dom.getContentEl(ctx)), getFirstFocusableEl: (ctx: Ctx) => dom.getFocusableEls(ctx)[0], - getDocTabbableEls: (ctx: Ctx) => getTabbables(dom.getDoc(ctx).body), - getTabbableEls: (ctx: Ctx) => getTabbables(dom.getContentEl(ctx), "if-empty"), - getFirstTabbableEl: (ctx: Ctx) => getFirstTabbable(dom.getContentEl(ctx), "if-empty"), - getLastTabbableEl: (ctx: Ctx) => getLastTabbable(dom.getContentEl(ctx), "if-empty"), - getInitialFocusEl: (ctx: Ctx) => { let el: HTMLElement | null = runIfFn(ctx.initialFocusEl) if (!el && ctx.autoFocus) el = dom.getFirstFocusableEl(ctx) diff --git a/packages/utilities/color-utils/package.json b/packages/utilities/color-utils/package.json index ce70d20f2e..2c87f13902 100644 --- a/packages/utilities/color-utils/package.json +++ b/packages/utilities/color-utils/package.json @@ -31,6 +31,9 @@ "url": "https://github.com/chakra-ui/zag/issues" }, "clean-package": "../../../clean-package.config.json", + "dependencies": { + "@zag-js/numeric-range": "workspace:*" + }, "devDependencies": { "clean-package": "2.2.0" } diff --git a/packages/utilities/color-utils/src/area-gradient.ts b/packages/utilities/color-utils/src/area-gradient.ts index 696575b2a6..b8388c93a2 100644 --- a/packages/utilities/color-utils/src/area-gradient.ts +++ b/packages/utilities/color-utils/src/area-gradient.ts @@ -26,7 +26,7 @@ interface GradientStyles { export function getColorAreaGradient(color: Color, options: GradientOptions): GradientStyles { const { xChannel, yChannel, dir: dirProp } = options - const { zChannel } = color.getColorSpaceAxes({ xChannel, yChannel }) + const { zChannel } = color.getColorAxes({ xChannel, yChannel }) const zValue = color.getChannelValue(zChannel) const { minValue: zMin, maxValue: zMax } = color.getChannelRange(zChannel) @@ -37,7 +37,7 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr let background = { areaStyles: {}, areaGradientStyles: {} } let alphaValue = (zValue - zMin) / (zMax - zMin) - let isHSL = color.getColorFormat() === "hsl" + let isHSL = color.getFormat() === "hsl" switch (zChannel) { case "red": { diff --git a/packages/utilities/color-utils/src/color.ts b/packages/utilities/color-utils/src/color.ts index cf57adc03f..e91662c9e9 100644 --- a/packages/utilities/color-utils/src/color.ts +++ b/packages/utilities/color-utils/src/color.ts @@ -1,29 +1,42 @@ -import type { ColorType, ColorFormat, ColorChannel, ColorChannelRange, ColorAxes } from "./types" +import { clampValue, getPercentValue, getValuePercent, snapValueToStep } from "@zag-js/numeric-range" +import type { Color2DAxes, ColorAxes, ColorChannel, ColorChannelRange, ColorFormat, ColorType } from "./types" + +const isEqualObject = (a: Record, b: Record): boolean => { + if (Object.keys(a).length !== Object.keys(b).length) return false + for (let key in a) if (a[key] !== b[key]) return false + return true +} export abstract class Color implements ColorType { abstract toFormat(format: ColorFormat): ColorType + abstract toJSON(): Record abstract toString(format: ColorFormat | "css"): string abstract clone(): ColorType abstract getChannelRange(channel: ColorChannel): ColorChannelRange - abstract getColorFormat(): ColorFormat - abstract getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] - - hasColorChannel(channel: ColorChannel): boolean { - return this.getColorChannels().includes(channel) - } + abstract getFormat(): ColorFormat + abstract getChannels(): [ColorChannel, ColorChannel, ColorChannel] toHexInt(): number { return this.toFormat("rgb").toHexInt() } getChannelValue(channel: ColorChannel): number { - if (channel in this) { - return this[channel] - } - + if (channel in this) return this[channel] throw new Error("Unsupported color channel: " + channel) } + getChannelValuePercent(channel: ColorChannel, valueToCheck?: number): number { + const value = valueToCheck ?? this.getChannelValue(channel) + const { minValue, maxValue } = this.getChannelRange(channel) + return getValuePercent(value, minValue, maxValue) + } + + getChannelPercentValue(channel: ColorChannel, percentToCheck: number): number { + const { minValue, maxValue, step } = this.getChannelRange(channel) + const percentValue = getPercentValue(percentToCheck, minValue, maxValue, step) + return snapValueToStep(percentValue, minValue, maxValue, step) + } + withChannelValue(channel: ColorChannel, value: number): ColorType { if (channel in this) { let clone = this.clone() @@ -34,15 +47,31 @@ export abstract class Color implements ColorType { throw new Error("Unsupported color channel: " + channel) } - getColorSpaceAxes(xyChannels: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes { + getColorAxes(xyChannels: Color2DAxes): ColorAxes { let { xChannel, yChannel } = xyChannels - let xCh = xChannel || this.getColorChannels().find((c) => c !== yChannel) - let yCh = yChannel || this.getColorChannels().find((c) => c !== xCh) - let zCh = this.getColorChannels().find((c) => c !== xCh && c !== yCh) + let xCh = xChannel || this.getChannels().find((c) => c !== yChannel) + let yCh = yChannel || this.getChannels().find((c) => c !== xCh) + let zCh = this.getChannels().find((c) => c !== xCh && c !== yCh) return { xChannel: xCh!, yChannel: yCh!, zChannel: zCh! } } + incrementChannel(channel: ColorChannel, stepSize: number): ColorType { + const { minValue, maxValue, step } = this.getChannelRange(channel) + const value = snapValueToStep( + clampValue(this.getChannelValue(channel) + stepSize, minValue, maxValue), + minValue, + maxValue, + step, + ) + return this.withChannelValue(channel, value) + } + + decrementChannel(channel: ColorChannel, stepSize: number): ColorType { + return this.incrementChannel(channel, -stepSize) + } + isEqual(color: ColorType): boolean { - return this.toHexInt() === color.toHexInt() && this.getChannelValue("alpha") === color.getChannelValue("alpha") + const isSame = isEqualObject(this.toJSON(), color.toJSON()) + return isSame && this.getChannelValue("alpha") === color.getChannelValue("alpha") } } diff --git a/packages/utilities/color-utils/src/hsb-color.ts b/packages/utilities/color-utils/src/hsb-color.ts index 5446a2aae1..6c112fde52 100644 --- a/packages/utilities/color-utils/src/hsb-color.ts +++ b/packages/utilities/color-utils/src/hsb-color.ts @@ -1,8 +1,8 @@ +import { mod, clampValue, toFixedNumber } from "@zag-js/numeric-range" import { Color } from "./color" import { HSLColor } from "./hsl-color" import { RGBColor } from "./rgb-color" import type { ColorChannel, ColorChannelRange, ColorFormat, ColorType } from "./types" -import { clampValue, mod, toFixedNumber } from "./utils" const HSB_REGEX = /hsb\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsba\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/ @@ -75,7 +75,7 @@ export class HSBColor extends Color { toFixedNumber(this.hue, 2), toFixedNumber(saturation * 100, 2), toFixedNumber(lightness * 100, 2), - this.alpha, + toFixedNumber(this.alpha, 2), ) } @@ -92,7 +92,12 @@ export class HSBColor extends Color { let fn = (n: number, k = (n + hue / 60) % 6) => brightness - saturation * brightness * Math.max(Math.min(k, 4 - k, 1), 0) - return new RGBColor(Math.round(fn(5) * 255), Math.round(fn(3) * 255), Math.round(fn(1) * 255), this.alpha) + return new RGBColor( + Math.round(fn(5) * 255), + Math.round(fn(3) * 255), + Math.round(fn(1) * 255), + toFixedNumber(this.alpha, 2), + ) } clone(): ColorType { @@ -113,13 +118,17 @@ export class HSBColor extends Color { } } - getColorFormat(): ColorFormat { + toJSON(): Record { + return { h: this.hue, s: this.saturation, b: this.brightness } + } + + getFormat(): ColorFormat { return "hsb" } private static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ["hue", "saturation", "brightness"] - getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + getChannels(): [ColorChannel, ColorChannel, ColorChannel] { return HSBColor.colorChannels } } diff --git a/packages/utilities/color-utils/src/hsl-color.ts b/packages/utilities/color-utils/src/hsl-color.ts index 4c98535067..ffa69c8f27 100644 --- a/packages/utilities/color-utils/src/hsl-color.ts +++ b/packages/utilities/color-utils/src/hsl-color.ts @@ -1,8 +1,8 @@ +import { clampValue, mod, toFixedNumber } from "@zag-js/numeric-range" import { Color } from "./color" import { HSBColor } from "./hsb-color" import { RGBColor } from "./rgb-color" import type { ColorChannel, ColorChannelRange, ColorFormat, ColorType } from "./types" -import { clampValue, mod, toFixedNumber } from "./utils" export const HSL_REGEX = /hsl\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsla\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/ @@ -72,7 +72,7 @@ export class HSLColor extends Color { toFixedNumber(this.hue, 2), toFixedNumber(saturation * 100, 2), toFixedNumber(brightness * 100, 2), - this.alpha, + toFixedNumber(this.alpha, 2), ) } @@ -87,7 +87,12 @@ export class HSLColor extends Color { let lightness = this.lightness / 100 let a = saturation * Math.min(lightness, 1 - lightness) let fn = (n: number, k = (n + hue / 30) % 12) => lightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) - return new RGBColor(Math.round(fn(0) * 255), Math.round(fn(8) * 255), Math.round(fn(4) * 255), this.alpha) + return new RGBColor( + Math.round(fn(0) * 255), + Math.round(fn(8) * 255), + Math.round(fn(4) * 255), + toFixedNumber(this.alpha, 2), + ) } clone(): ColorType { @@ -108,13 +113,17 @@ export class HSLColor extends Color { } } - getColorFormat(): ColorFormat { + toJSON(): Record { + return { h: this.hue, s: this.saturation, l: this.lightness } + } + + getFormat(): ColorFormat { return "hsl" } private static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ["hue", "saturation", "lightness"] - getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + getChannels(): [ColorChannel, ColorChannel, ColorChannel] { return HSLColor.colorChannels } } diff --git a/packages/utilities/color-utils/src/rgb-color.ts b/packages/utilities/color-utils/src/rgb-color.ts index 8ab1561bb8..a4ebb4b3fe 100644 --- a/packages/utilities/color-utils/src/rgb-color.ts +++ b/packages/utilities/color-utils/src/rgb-color.ts @@ -1,8 +1,8 @@ +import { clampValue, toFixedNumber } from "@zag-js/numeric-range" import { Color } from "./color" import { HSBColor } from "./hsb-color" import { HSLColor } from "./hsl-color" import type { ColorChannel, ColorChannelRange, ColorFormat, ColorType } from "./types" -import { clampValue, toFixedNumber } from "./utils" export class RGBColor extends Color { constructor( @@ -130,7 +130,7 @@ export class RGBColor extends Color { toFixedNumber(hue * 360, 2), toFixedNumber(saturation * 100, 2), toFixedNumber(brightness * 100, 2), - this.alpha, + toFixedNumber(this.alpha, 2), ) } @@ -175,7 +175,7 @@ export class RGBColor extends Color { toFixedNumber(hue * 360, 2), toFixedNumber(saturation * 100, 2), toFixedNumber(lightness * 100, 2), - this.alpha, + toFixedNumber(this.alpha, 2), ) } @@ -196,13 +196,17 @@ export class RGBColor extends Color { } } - getColorFormat(): ColorFormat { + toJSON(): Record { + return { r: this.red, g: this.green, b: this.blue } + } + + getFormat(): ColorFormat { return "rgb" } private static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ["red", "green", "blue"] - getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + getChannels(): [ColorChannel, ColorChannel, ColorChannel] { return RGBColor.colorChannels } } diff --git a/packages/utilities/color-utils/src/types.ts b/packages/utilities/color-utils/src/types.ts index 8de7884737..5d0abb884f 100644 --- a/packages/utilities/color-utils/src/types.ts +++ b/packages/utilities/color-utils/src/types.ts @@ -2,7 +2,14 @@ export type ColorFormat = "hex" | "hexa" | "rgb" | "rgba" | "hsl" | "hsla" | "hs export type ColorChannel = "hue" | "saturation" | "brightness" | "lightness" | "red" | "green" | "blue" | "alpha" -export type ColorAxes = { xChannel: ColorChannel; yChannel: ColorChannel; zChannel: ColorChannel } +export interface Color2DAxes { + xChannel: ColorChannel + yChannel: ColorChannel +} + +export interface ColorAxes extends Color2DAxes { + zChannel: ColorChannel +} export interface ColorChannelRange { /** The minimum value of the color channel. */ @@ -18,6 +25,8 @@ export interface ColorChannelRange { export interface ColorType { /** Converts the color to the given color format, and returns a new Color object. */ toFormat(format: ColorFormat): ColorType + /** Converts the color to a JSON object. */ + toJSON(): Record /** Converts the color to a string in the given format. */ toString(format: ColorFormat | "css"): string /** Converts the color to hex, and returns an integer representation. */ @@ -39,15 +48,15 @@ export interface ColorType { /** * Returns the color space, 'rgb', 'hsb' or 'hsl', for the current color. */ - getColorFormat(): ColorFormat + getFormat(): ColorFormat /** * Returns the color space axes, xChannel, yChannel, zChannel. */ - getColorSpaceAxes(xyChannels: { xChannel?: ColorChannel; yChannel?: ColorChannel }): ColorAxes + getColorAxes(xyChannels: Color2DAxes): ColorAxes /** * Returns an array of the color channels within the current color space space. */ - getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] + getChannels(): [ColorChannel, ColorChannel, ColorChannel] /** * Returns a new Color object with the same values as the current color. */ @@ -56,4 +65,20 @@ export interface ColorType { * Whether the color is equal to another color. */ isEqual(color: ColorType): boolean + /** + * Increments the color channel by the given step size, and returns a new Color object. + */ + incrementChannel(channel: ColorChannel, stepSize: number): ColorType + /** + * Decrements the color channel by the given step size, and returns a new Color object. + */ + decrementChannel(channel: ColorChannel, stepSize: number): ColorType + /** + * Returns the color channel value as a percentage of the channel range. + */ + getChannelValuePercent(channel: ColorChannel, value?: number): number + /** + * Returns the color channel value for a given percentage of the channel range. + */ + getChannelPercentValue(channel: ColorChannel, percent: number): number } diff --git a/packages/utilities/color-utils/src/utils.ts b/packages/utilities/color-utils/src/utils.ts deleted file mode 100644 index 27d6d7528f..0000000000 --- a/packages/utilities/color-utils/src/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function mod(n: number, m: number) { - return ((n % m) + m) % m -} - -export function toFixedNumber(num: number, digits: number) { - return Math.round(Math.pow(10, digits) * num) / Math.pow(10, digits) -} - -export function clampValue(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max) -} diff --git a/packages/utilities/color-utils/tests/color.test.ts b/packages/utilities/color-utils/tests/color.test.ts new file mode 100644 index 0000000000..402e4d6f69 --- /dev/null +++ b/packages/utilities/color-utils/tests/color.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest" +import { parseColor } from "../src" + +describe("color.test.ts", () => { + test("decrement", () => { + const color = parseColor("#361717").toFormat("hsl") + + expect(color).toMatchInlineSnapshot(` + { + "h": 0, + "l": 15.1, + "s": 40.26, + } + `) + + expect(color.decrementChannel("saturation", 1)).toMatchInlineSnapshot(` + { + "h": 0, + "l": 15.1, + "s": 39, + } + `) + + expect(color.decrementChannel("lightness", 1)).toMatchInlineSnapshot(` + { + "h": 0, + "l": 14, + "s": 40.26, + } + `) + }) + + test("hexint", () => { + expect(parseColor("hsl(0, 92%, 13%)")).toMatchInlineSnapshot(` + { + "h": 0, + "l": 13, + "s": 92, + } + `) + + expect(parseColor("hsl(0, 76%, 31%)")).toMatchInlineSnapshot(` + { + "h": 0, + "l": 31, + "s": 76, + } + `) + }) +}) diff --git a/packages/utilities/core/src/functions.ts b/packages/utilities/core/src/functions.ts index 92eff7682b..b4bb4d8f44 100644 --- a/packages/utilities/core/src/functions.ts +++ b/packages/utilities/core/src/functions.ts @@ -1,3 +1,7 @@ +export type MaybeFunction = T | (() => T) + +export type Nullable = T | null | undefined + export const runIfFn = ( v: T | undefined, ...a: T extends (...a: any[]) => void ? Parameters : never diff --git a/packages/utilities/dom-query/src/create-scope.ts b/packages/utilities/dom-query/src/create-scope.ts index f9fd7a2cd3..e4f0afa27e 100644 --- a/packages/utilities/dom-query/src/create-scope.ts +++ b/packages/utilities/dom-query/src/create-scope.ts @@ -12,10 +12,16 @@ export function createScope(methods: T) { getWin: (ctx: Ctx) => screen.getDoc(ctx).defaultView ?? window, getActiveElement: (ctx: Ctx) => screen.getDoc(ctx).activeElement as HTMLElement | null, isActiveElement: (ctx: Ctx, elem: HTMLElement | null) => elem === screen.getActiveElement(ctx), + focus(ctx: Ctx, elem: HTMLElement | null | undefined) { + if (elem == null) return + if (!screen.isActiveElement(ctx, elem)) elem.focus({ preventScroll: true }) + }, getById: (ctx: Ctx, id: string) => screen.getRootNode(ctx).getElementById(id) as T | null, setValue: (elem: T | null, value: string | number | null | undefined) => { if (elem == null || value == null) return + const valueAsString = value.toString() + if (elem.value === valueAsString) return elem.value = value.toString() }, } diff --git a/packages/utilities/interact-outside/src/get-window-frames.ts b/packages/utilities/interact-outside/src/get-window-frames.ts index 96ccbc0cd9..138dc841c3 100644 --- a/packages/utilities/interact-outside/src/get-window-frames.ts +++ b/packages/utilities/interact-outside/src/get-window-frames.ts @@ -10,25 +10,19 @@ export function getWindowFrames(win: Window) { frames.each((frame) => { try { frame.document.addEventListener(event, listener, options) - } catch (err) { - console.warn(err) - } + } catch {} }) return () => { try { frames.removeEventListener(event, listener, options) - } catch (err) { - console.warn(err) - } + } catch {} } }, removeEventListener(event: string, listener: any, options?: any) { frames.each((frame) => { try { frame.document.removeEventListener(event, listener, options) - } catch (err) { - console.warn(err) - } + } catch {} }) }, } diff --git a/packages/utilities/numeric-range/src/index.ts b/packages/utilities/numeric-range/src/index.ts index 923a661b15..472d174fd8 100644 --- a/packages/utilities/numeric-range/src/index.ts +++ b/packages/utilities/numeric-range/src/index.ts @@ -140,3 +140,12 @@ export function getValueTransformer(valueA: number[], valueB: number[]) { return output.min + ratio * (value - input.min) } } + +export function toFixedNumber(value: number, digits: number, base: number = 10): number { + const pow = Math.pow(base, digits) + return Math.round(value * pow) / pow +} + +export function mod(value: number, modulo: number) { + return ((value % modulo) + modulo) % modulo +} diff --git a/packages/utilities/tabbable/src/focusable.ts b/packages/utilities/tabbable/src/focusable.ts index c4245772f4..647adabccc 100644 --- a/packages/utilities/tabbable/src/focusable.ts +++ b/packages/utilities/tabbable/src/focusable.ts @@ -36,7 +36,7 @@ export function isFocusable(element: HTMLElement | null): element is HTMLElement return element.matches(focusableSelector) && isVisible(element) } -export function getFirstFocusable(container: HTMLElement, includeContainer?: IncludeContainerType) { +export function getFirstFocusable(container: HTMLElement | null, includeContainer?: IncludeContainerType) { const [first] = getFocusables(container, includeContainer) return first || null } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04755cab9..b8a1858cb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1519,6 +1519,9 @@ importers: '@zag-js/core': specifier: workspace:* version: link:../../core + '@zag-js/dismissable': + specifier: workspace:* + version: link:../../utilities/dismissable '@zag-js/dom-event': specifier: workspace:* version: link:../../utilities/dom-event @@ -1528,9 +1531,12 @@ importers: '@zag-js/form-utils': specifier: workspace:* version: link:../../utilities/form-utils - '@zag-js/numeric-range': + '@zag-js/popper': specifier: workspace:* - version: link:../../utilities/numeric-range + version: link:../../utilities/popper + '@zag-js/tabbable': + specifier: workspace:* + version: link:../../utilities/tabbable '@zag-js/text-selection': specifier: workspace:* version: link:../../utilities/text-selection @@ -2388,6 +2394,10 @@ importers: version: 2.2.0 packages/utilities/color-utils: + dependencies: + '@zag-js/numeric-range': + specifier: workspace:* + version: link:../numeric-range devDependencies: clean-package: specifier: 2.2.0 diff --git a/shared/src/style.css b/shared/src/style.css index c14ce8b6e2..79986af4d1 100644 --- a/shared/src/style.css +++ b/shared/src/style.css @@ -8,6 +8,11 @@ "Helvetica Neue", sans-serif; } +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; +} + *:focus { outline: 2px solid var(--ring-color); outline-offset: 2px; @@ -1546,14 +1551,30 @@ main [data-testid="scrubber"] { * Color Picker * -----------------------------------------------------------------------------*/ -[data-scope="color-picker"][data-part="content"] { - width: 260px; +[data-scope="color-picker"][data-part="root"] { display: flex; flex-direction: column; + gap: 10px; + margin-bottom: 10px; +} + +[data-scope="color-picker"][data-part="control"] { + display: flex; + gap: 4px; +} + +[data-scope="color-picker"][data-part="content"] { + width: 260px; box-sizing: border-box; - gap: 16px; padding: 24px; border: 1px solid #d5d5d5; + background: white; +} + +[data-scope="color-picker"][data-part="content"] > .content__inner { + display: flex; + flex-direction: column; + gap: 16px; } [data-scope="color-picker"][data-part="area"] { @@ -1562,7 +1583,7 @@ main [data-testid="scrubber"] { border: 1px solid #ebebeb; } -[data-scope="color-picker"][data-part="area-gradient"] { +[data-scope="color-picker"][data-part="area-background"] { background: rgb(142, 142, 142); border-radius: 4px; height: 200px; @@ -1586,29 +1607,19 @@ main [data-testid="scrubber"] { border-radius: 4px; } -[data-scope="color-picker"][data-part="channel-slider-track-bg"] { - border-radius: 4px; -} - [data-scope="color-picker"][data-part="channel-input"] { border-radius: 4px; width: 100%; border: 1px solid #c2c2c2; } -[data-scope="color-picker"][data-part="channel-input"]::-webkit-outer-spin-button, -[data-scope="color-picker"][data-part="channel-input"]::-webkit-inner-spin-button { - -webkit-appearance: none; -} - [data-scope="color-picker"][data-part="swatch"] { - border-radius: 4px; width: 20px; height: 20px; flex-shrink: 0; } -[data-scope="color-picker"][data-part="swatch-bg"] { +[data-scope="color-picker"][data-part="transparency-grid"] { border-radius: 4px; }
{api.value}
{api.valueAsString}
{api().value}
{api().valueAsString}