@zag-js/color-picker 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ import { Color, ColorChannel, ColorFormat, normalizeColor } from "@zag-js/color-utils"
2
+ import {
3
+ EventKeyMap,
4
+ getEventKey,
5
+ getEventPoint,
6
+ getEventStep,
7
+ getNativeEvent,
8
+ isLeftClick,
9
+ isModifiedEvent,
10
+ } from "@zag-js/dom-event"
11
+ import { dataAttr } from "@zag-js/dom-query"
12
+ import { NormalizeProps, type PropTypes } from "@zag-js/types"
13
+ import { parts } from "./color-picker.anatomy"
14
+ import { dom } from "./color-picker.dom"
15
+ import {
16
+ ColorAreaProps,
17
+ ColorChannelInputProps,
18
+ ColorChannelProps,
19
+ ColorSwatchProps,
20
+ Send,
21
+ State,
22
+ } from "./color-picker.types"
23
+ import { getChannelDetails } from "./utils/get-channel-details"
24
+ import { getChannelDisplayColor } from "./utils/get-channel-display-color"
25
+ import { getChannelInputRange, getChannelInputValue } from "./utils/get-channel-input-value"
26
+ import { getColorAreaGradient } from "./utils/get-color-area-gradient"
27
+ import { getSliderBgImage } from "./utils/get-slider-background"
28
+
29
+ export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
30
+ const valueAsColor = state.context.valueAsColor
31
+ const isDisabled = state.context.disabled
32
+ const isInteractive = state.context.isInteractive
33
+ const isDragging = state.matches("dragging")
34
+
35
+ const channels = valueAsColor.getColorChannels()
36
+
37
+ return {
38
+ /**
39
+ * Whether the color picker is being dragged
40
+ */
41
+ isDragging,
42
+ /**
43
+ * The current color value (as a string)
44
+ */
45
+ value: state.context.value,
46
+ /**
47
+ * The current color value (as a Color object)
48
+ */
49
+ valueAsColor,
50
+ /**
51
+ * The current color channels of the color
52
+ */
53
+ channels,
54
+ /**
55
+ * Function to set the color value
56
+ */
57
+ setColor(value: string | Color) {
58
+ send({ type: "VALUE.SET", value: normalizeColor(value), src: "set-color" })
59
+ },
60
+ /**
61
+ * Function to set the color value of a specific channel
62
+ */
63
+ setChannelValue(channel: ColorChannel, value: number) {
64
+ const color = valueAsColor.withChannelValue(channel, value)
65
+ send({ type: "VALUE.SET", value: color, src: "set-channel" })
66
+ },
67
+ /**
68
+ * Function to set the color format
69
+ */
70
+ setFormat(format: ColorFormat) {
71
+ const value = valueAsColor.toFormat(format)
72
+ send({ type: "VALUE.SET", value, src: "set-format" })
73
+ },
74
+
75
+ contentProps: normalize.element({
76
+ ...parts.content.attrs,
77
+ id: dom.getContentId(state.context),
78
+ }),
79
+
80
+ getAreaProps(props: ColorAreaProps) {
81
+ const { xChannel, yChannel } = props
82
+ const { areaStyles } = getColorAreaGradient(state.context, xChannel, yChannel)
83
+
84
+ return normalize.element({
85
+ ...parts.area.attrs,
86
+ id: dom.getAreaId(state.context),
87
+ role: "group",
88
+ onPointerDown(event) {
89
+ if (!isInteractive) return
90
+
91
+ const evt = getNativeEvent(event)
92
+ if (!isLeftClick(evt) || isModifiedEvent(evt)) return
93
+
94
+ const point = getEventPoint(evt)
95
+ const channel = { xChannel, yChannel }
96
+
97
+ send({ type: "AREA.POINTER_DOWN", point, channel, id: "area" })
98
+ },
99
+ style: {
100
+ position: "relative",
101
+ touchAction: "none",
102
+ forcedColorAdjust: "none",
103
+ ...areaStyles,
104
+ },
105
+ })
106
+ },
107
+
108
+ getAreaGradientProps(props: ColorAreaProps) {
109
+ const { xChannel, yChannel } = props
110
+ const { areaGradientStyles } = getColorAreaGradient(state.context, xChannel, yChannel)
111
+
112
+ return normalize.element({
113
+ ...parts.areaGradient.attrs,
114
+ id: dom.getAreaGradientId(state.context),
115
+ style: {
116
+ position: "relative",
117
+ touchAction: "none",
118
+ forcedColorAdjust: "none",
119
+ ...areaGradientStyles,
120
+ },
121
+ })
122
+ },
123
+
124
+ getAreaThumbProps(props: ColorAreaProps) {
125
+ const { xChannel, yChannel } = props
126
+ const { getThumbPosition } = getChannelDetails(valueAsColor, xChannel, yChannel)
127
+ const { x, y } = getThumbPosition()
128
+
129
+ const channel = { xChannel, yChannel }
130
+
131
+ return normalize.element({
132
+ ...parts.areaThumb.attrs,
133
+ id: dom.getAreaThumbId(state.context),
134
+ tabIndex: isDisabled ? undefined : 0,
135
+ "data-disabled": dataAttr(isDisabled),
136
+ role: "presentation",
137
+ style: {
138
+ position: "absolute",
139
+ left: `${x * 100}%`,
140
+ top: `${y * 100}%`,
141
+ transform: "translate(-50%, -50%)",
142
+ touchAction: "none",
143
+ forcedColorAdjust: "none",
144
+ background: valueAsColor.withChannelValue("alpha", 1).toString("css"),
145
+ },
146
+ onBlur() {
147
+ send("AREA.BLUR")
148
+ },
149
+ onKeyDown(event) {
150
+ if (!isInteractive) return
151
+
152
+ const step = getEventStep(event)
153
+
154
+ const keyMap: EventKeyMap = {
155
+ ArrowUp() {
156
+ send({ type: "AREA.ARROW_UP", channel, step })
157
+ },
158
+ ArrowDown() {
159
+ send({ type: "AREA.ARROW_DOWN", channel, step })
160
+ },
161
+ ArrowLeft() {
162
+ send({ type: "AREA.ARROW_LEFT", channel, step })
163
+ },
164
+ ArrowRight() {
165
+ send({ type: "AREA.ARROW_RIGHT", channel, step })
166
+ },
167
+ PageUp() {
168
+ send({ type: "AREA.PAGE_UP", channel, step })
169
+ },
170
+ PageDown() {
171
+ send({ type: "AREA.PAGE_DOWN", channel, step })
172
+ },
173
+ }
174
+
175
+ const exec = keyMap[getEventKey(event, state.context)]
176
+
177
+ if (exec) {
178
+ exec(event)
179
+ event.preventDefault()
180
+ }
181
+ },
182
+ })
183
+ },
184
+
185
+ getChannelSliderTrackProps(props: ColorChannelProps) {
186
+ const { orientation = "horizontal", channel } = props
187
+
188
+ return normalize.element({
189
+ ...parts.channelSliderTrack.attrs,
190
+ id: dom.getChannelSliderTrackId(state.context, channel),
191
+ role: "group",
192
+ "data-channel": channel,
193
+ "data-orientation": orientation,
194
+ onPointerDown(event) {
195
+ if (!isInteractive) return
196
+
197
+ const evt = getNativeEvent(event)
198
+ if (!isLeftClick(evt) || isModifiedEvent(evt)) return
199
+
200
+ const point = getEventPoint(evt)
201
+ send({ type: "CHANNEL_SLIDER.POINTER_DOWN", channel, point, id: channel, orientation })
202
+ },
203
+ style: {
204
+ position: "relative",
205
+ touchAction: "none",
206
+ forcedColorAdjust: "none",
207
+ backgroundImage: getSliderBgImage(state.context, { orientation, channel }),
208
+ },
209
+ })
210
+ },
211
+
212
+ getChannelSliderBackgroundProps(props: ColorChannelProps) {
213
+ const { orientation = "horizontal", channel } = props
214
+ return normalize.element({
215
+ ...parts.channelSliderTrackBg.attrs,
216
+ "data-orientation": orientation,
217
+ "data-channel": channel,
218
+ style: {
219
+ position: "absolute",
220
+ backgroundColor: "#fff",
221
+ backgroundImage: [
222
+ "linear-gradient(-45deg,#0000 75.5%,#bcbcbc 75.5%)",
223
+ "linear-gradient(45deg,#0000 75.5%,#bcbcbc 75.5%)",
224
+ "linear-gradient(-45deg,#bcbcbc 25.5%,#0000 25.5%)",
225
+ "linear-gradient(45deg,#bcbcbc 25.5%,#0000 25.5%)",
226
+ ].join(","),
227
+ backgroundSize: "16px 16px",
228
+ backgroundPosition: "-2px -2px,-2px 6px,6px -10px,-10px -2px",
229
+ inset: 0,
230
+ zIndex: -1,
231
+ },
232
+ })
233
+ },
234
+
235
+ getChannelSliderThumbProps(props: ColorChannelProps) {
236
+ const { orientation = "horizontal", channel } = props
237
+ const { minValue, maxValue, step: stepValue } = valueAsColor.getChannelRange(channel)
238
+ const channelValue = valueAsColor.getChannelValue(channel)
239
+
240
+ const offset = (channelValue - minValue) / (maxValue - minValue)
241
+
242
+ const placementStyles =
243
+ orientation === "horizontal"
244
+ ? { left: `${offset * 100}%`, top: "50%" }
245
+ : { top: `${offset * 100}%`, left: "50%" }
246
+
247
+ return normalize.element({
248
+ ...parts.channelSliderThumb.attrs,
249
+ id: dom.getChannelSliderThumbId(state.context, channel),
250
+ role: "slider",
251
+ "aria-label": channel,
252
+ tabIndex: isDisabled ? undefined : 0,
253
+ "data-channel": channel,
254
+ "data-disabled": dataAttr(isDisabled),
255
+ "data-orientation": orientation,
256
+ "aria-disabled": dataAttr(isDisabled),
257
+ "aria-orientation": orientation,
258
+ "aria-valuemax": maxValue,
259
+ "aria-valuemin": minValue,
260
+ "aria-valuenow": channelValue,
261
+ style: {
262
+ forcedColorAdjust: "none",
263
+ position: "absolute",
264
+ background: getChannelDisplayColor(valueAsColor, channel).toString("css"),
265
+ ...placementStyles,
266
+ },
267
+ onFocus() {
268
+ if (!isInteractive) return
269
+ send({ type: "CHANNEL_SLIDER.FOCUS", channel })
270
+ },
271
+ onBlur() {
272
+ if (!isInteractive) return
273
+ send({ type: "CHANNEL_SLIDER.BLUR", channel })
274
+ },
275
+ onKeyDown(event) {
276
+ if (!isInteractive) return
277
+ const step = getEventStep(event) * stepValue
278
+
279
+ const keyMap: EventKeyMap = {
280
+ ArrowUp() {
281
+ send({ type: "CHANNEL_SLIDER.ARROW_UP", channel, step })
282
+ },
283
+ ArrowDown() {
284
+ send({ type: "CHANNEL_SLIDER.ARROW_DOWN", channel, step })
285
+ },
286
+ ArrowLeft() {
287
+ send({ type: "CHANNEL_SLIDER.ARROW_LEFT", channel, step })
288
+ },
289
+ ArrowRight() {
290
+ send({ type: "CHANNEL_SLIDER.ARROW_RIGHT", channel, step })
291
+ },
292
+ PageUp() {
293
+ send({ type: "CHANNEL_SLIDER.PAGE_UP", channel })
294
+ },
295
+ PageDown() {
296
+ send({ type: "CHANNEL_SLIDER.PAGE_DOWN", channel })
297
+ },
298
+ Home() {
299
+ send({ type: "CHANNEL_SLIDER.HOME", channel })
300
+ },
301
+ End() {
302
+ send({ type: "CHANNEL_SLIDER.END", channel })
303
+ },
304
+ }
305
+
306
+ const exec = keyMap[getEventKey(event, state.context)]
307
+
308
+ if (exec) {
309
+ exec(event)
310
+ event.preventDefault()
311
+ }
312
+ },
313
+ })
314
+ },
315
+
316
+ getChannelInputProps(props: ColorChannelInputProps) {
317
+ const { channel } = props
318
+ const isTextField = channel === "hex" || channel === "css"
319
+ const range = getChannelInputRange(valueAsColor, channel)
320
+
321
+ return normalize.input({
322
+ ...parts.channelInput.attrs,
323
+ type: isTextField ? "text" : "number",
324
+ "data-channel": channel,
325
+ "aria-label": channel,
326
+ disabled: isDisabled,
327
+ "data-disabled": dataAttr(isDisabled),
328
+ readOnly: state.context.readOnly,
329
+ id: dom.getChannelInputId(state.context, channel),
330
+ defaultValue: getChannelInputValue(valueAsColor, channel),
331
+ min: range?.minValue,
332
+ max: range?.maxValue,
333
+ step: range?.step,
334
+ onFocus() {
335
+ send({ type: "CHANNEL_INPUT.FOCUS", channel })
336
+ },
337
+ onChange(event) {
338
+ if (isTextField) return
339
+ const value = event.currentTarget.value
340
+ send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField })
341
+ },
342
+ onBlur(event) {
343
+ const value = event.currentTarget.value
344
+ send({ type: "CHANNEL_INPUT.BLUR", channel, value, isTextField })
345
+ },
346
+ onKeyDown(event) {
347
+ if (!isTextField) return
348
+ if (event.key === "Enter") {
349
+ const value = event.currentTarget.value
350
+ send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField })
351
+ }
352
+ },
353
+ style: {
354
+ appearance: "none",
355
+ WebkitAppearance: "none",
356
+ MozAppearance: "textfield",
357
+ },
358
+ })
359
+ },
360
+
361
+ eyeDropperTriggerProps: normalize.button({
362
+ ...parts.eyeDropTrigger.attrs,
363
+ onClick() {
364
+ send("EYEDROPPER.CLICK")
365
+ },
366
+ }),
367
+
368
+ getSwatchBackgroundProps(props: ColorSwatchProps) {
369
+ const { value } = props
370
+ const alpha = normalizeColor(value).getChannelValue("alpha")
371
+ return normalize.element({
372
+ ...parts.swatchBg.attrs,
373
+ "data-alpha": alpha,
374
+ style: {
375
+ width: "100%",
376
+ height: "100%",
377
+ background: "#fff",
378
+ backgroundImage: [
379
+ "linear-gradient(-45deg,#0000 75.5%,#bcbcbc 75.5%)",
380
+ "linear-gradient(45deg,#0000 75.5%,#bcbcbc 75.5%)",
381
+ "linear-gradient(-45deg,#bcbcbc 25.5%,#0000 25.5%)",
382
+ "linear-gradient(45deg,#bcbcbc 25.5%,#0000 25.5%)",
383
+ ].join(","),
384
+ backgroundPosition: "-2px -2px,-2px 6px,6px -10px,-10px -2px",
385
+ backgroundSize: "16px 16px",
386
+ position: "absolute",
387
+ inset: "0px",
388
+ zIndex: -1,
389
+ },
390
+ })
391
+ },
392
+
393
+ getSwatchProps(props: ColorSwatchProps) {
394
+ const { value, readOnly } = props
395
+ const color = normalizeColor(value).toFormat(valueAsColor.getColorSpace())
396
+ return normalize.element({
397
+ ...parts.swatch.attrs,
398
+ onClick() {
399
+ if (readOnly) return
400
+ send({ type: "VALUE.SET", value: color })
401
+ },
402
+ style: {
403
+ position: "relative",
404
+ background: color.toString("css"),
405
+ },
406
+ })
407
+ },
408
+ }
409
+ }
@@ -0,0 +1,41 @@
1
+ import { ColorChannel } from "@zag-js/color-utils"
2
+ import { getRelativePoint, type Point } from "@zag-js/dom-event"
3
+ import { createScope, queryAll } from "@zag-js/dom-query"
4
+ import type { MachineContext as Ctx } from "./color-picker.types"
5
+
6
+ export const dom = createScope({
7
+ getContentId: (ctx: Ctx) => ctx.ids?.content ?? `color-picker:${ctx.id}:content`,
8
+ getAreaId: (ctx: Ctx) => ctx.ids?.area ?? `color-picker:${ctx.id}:area`,
9
+ getAreaGradientId: (ctx: Ctx) => ctx.ids?.areaGradient ?? `color-picker:${ctx.id}:area-gradient`,
10
+ getAreaThumbId: (ctx: Ctx) => ctx.ids?.areaThumb ?? `color-picker:${ctx.id}:area-thumb`,
11
+ getChannelSliderTrackId: (ctx: Ctx, channel: ColorChannel) =>
12
+ ctx.ids?.channelSliderTrack?.(channel) ?? `color-picker:${ctx.id}:slider-track:${channel}`,
13
+ getChannelInputId: (ctx: Ctx, channel: string) =>
14
+ ctx.ids?.channelInput?.(channel) ?? `color-picker:${ctx.id}:input:${channel}`,
15
+ getChannelSliderThumbId: (ctx: Ctx, channel: ColorChannel) =>
16
+ ctx.ids?.channelSliderThumb?.(channel) ?? `color-picker:${ctx.id}:slider-thumb:${channel}`,
17
+
18
+ getContentEl: (ctx: Ctx) => dom.queryById(ctx, dom.getContentId(ctx)),
19
+ getAreaThumbEl: (ctx: Ctx) => dom.queryById(ctx, dom.getAreaThumbId(ctx)),
20
+ getChannelSliderThumbEl: (ctx: Ctx, channel: ColorChannel) =>
21
+ dom.queryById(ctx, dom.getChannelSliderThumbId(ctx, channel)),
22
+ getChannelInputEl: (ctx: Ctx, channel: string) =>
23
+ dom.queryById<HTMLInputElement>(ctx, dom.getChannelInputId(ctx, channel)),
24
+
25
+ getAreaEl: (ctx: Ctx) => dom.queryById(ctx, dom.getAreaId(ctx)),
26
+ getAreaValueFromPoint(ctx: Ctx, point: Point) {
27
+ const { percent } = getRelativePoint(point, dom.getAreaEl(ctx))
28
+ return percent
29
+ },
30
+
31
+ getChannelSliderTrackEl: (ctx: Ctx, channel: ColorChannel) => {
32
+ return dom.queryById(ctx, dom.getChannelSliderTrackId(ctx, channel))
33
+ },
34
+ getChannelSliderValueFromPoint(ctx: Ctx, point: Point, channel: ColorChannel) {
35
+ const { percent } = getRelativePoint(point, dom.getChannelSliderTrackEl(ctx, channel))
36
+ return percent
37
+ },
38
+ getChannelInputEls: (ctx: Ctx) => {
39
+ return queryAll<HTMLInputElement>(dom.getContentEl(ctx), "input[data-channel]")
40
+ },
41
+ })