@zag-js/color-picker 0.69.0 → 0.71.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.
@@ -1,680 +0,0 @@
1
- import { getColorAreaGradient, normalizeColor } from "@zag-js/color-utils"
2
- import {
3
- getEventKey,
4
- getEventPoint,
5
- getEventStep,
6
- isLeftClick,
7
- isModifierKey,
8
- type EventKeyMap,
9
- } from "@zag-js/dom-event"
10
- import { dataAttr, query, visuallyHiddenStyle } from "@zag-js/dom-query"
11
- import { getPlacementStyles } from "@zag-js/popper"
12
- import type { NormalizeProps, PropTypes } from "@zag-js/types"
13
- import { parts } from "./color-picker.anatomy"
14
- import { dom } from "./color-picker.dom"
15
- import type {
16
- AreaProps,
17
- ColorFormat,
18
- MachineApi,
19
- Send,
20
- State,
21
- SwatchTriggerProps,
22
- SwatchTriggerState,
23
- } from "./color-picker.types"
24
- import { getChannelDisplayColor } from "./utils/get-channel-display-color"
25
- import { getChannelRange, getChannelValue } from "./utils/get-channel-input-value"
26
- import { getSliderBackground } from "./utils/get-slider-background"
27
-
28
- export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>): MachineApi<T> {
29
- const value = state.context.value
30
- const areaValue = state.context.areaValue
31
- const valueAsString = state.context.valueAsString
32
-
33
- const disabled = state.context.isDisabled
34
- const interactive = state.context.isInteractive
35
-
36
- const dragging = state.hasTag("dragging")
37
- const open = state.hasTag("open")
38
- const focused = state.hasTag("focused")
39
-
40
- const getAreaChannels = (props: AreaProps) => {
41
- const channels = areaValue.getChannels()
42
- return {
43
- xChannel: props.xChannel ?? channels[1],
44
- yChannel: props.yChannel ?? channels[2],
45
- }
46
- }
47
-
48
- const currentPlacement = state.context.currentPlacement
49
- const popperStyles = getPlacementStyles({
50
- ...state.context.positioning,
51
- placement: currentPlacement,
52
- })
53
-
54
- function getSwatchTriggerState(props: SwatchTriggerProps): SwatchTriggerState {
55
- const color = normalizeColor(props.value).toFormat(state.context.format)
56
- return {
57
- value: color,
58
- valueAsString: color.toString("hex"),
59
- checked: color.isEqual(value),
60
- disabled: props.disabled || !interactive,
61
- }
62
- }
63
-
64
- return {
65
- dragging,
66
- open,
67
- valueAsString,
68
- value,
69
- setOpen(nextOpen) {
70
- if (nextOpen === open) return
71
- send({ type: nextOpen ? "OPEN" : "CLOSE" })
72
- },
73
- setValue(value) {
74
- send({ type: "VALUE.SET", value: normalizeColor(value), src: "set-color" })
75
- },
76
- getChannelValue(channel) {
77
- return getChannelValue(value, channel)
78
- },
79
- getChannelValueText(channel, locale) {
80
- return value.formatChannelValue(channel, locale)
81
- },
82
- setChannelValue(channel, channelValue) {
83
- const color = value.withChannelValue(channel, channelValue)
84
- send({ type: "VALUE.SET", value: color, src: "set-channel" })
85
- },
86
- format: state.context.format,
87
- setFormat(format) {
88
- const formatValue = value.toFormat(format)
89
- send({ type: "VALUE.SET", value: formatValue, src: "set-format" })
90
- },
91
- alpha: value.getChannelValue("alpha"),
92
- setAlpha(alphaValue) {
93
- const color = value.withChannelValue("alpha", alphaValue)
94
- send({ type: "VALUE.SET", value: color, src: "set-alpha" })
95
- },
96
-
97
- getRootProps() {
98
- return normalize.element({
99
- ...parts.root.attrs,
100
- dir: state.context.dir,
101
- id: dom.getRootId(state.context),
102
- "data-disabled": dataAttr(disabled),
103
- "data-readonly": dataAttr(state.context.readOnly),
104
- style: {
105
- "--value": value.toString("css"),
106
- },
107
- })
108
- },
109
-
110
- getLabelProps() {
111
- return normalize.element({
112
- ...parts.label.attrs,
113
- dir: state.context.dir,
114
- id: dom.getLabelId(state.context),
115
- htmlFor: dom.getHiddenInputId(state.context),
116
- "data-disabled": dataAttr(disabled),
117
- "data-readonly": dataAttr(state.context.readOnly),
118
- "data-focus": dataAttr(focused),
119
- onClick(event) {
120
- event.preventDefault()
121
- const inputEl = query(dom.getControlEl(state.context), "[data-channel=hex]")
122
- inputEl?.focus({ preventScroll: true })
123
- },
124
- })
125
- },
126
-
127
- getControlProps() {
128
- return normalize.element({
129
- ...parts.control.attrs,
130
- id: dom.getControlId(state.context),
131
- dir: state.context.dir,
132
- "data-disabled": dataAttr(disabled),
133
- "data-readonly": dataAttr(state.context.readOnly),
134
- "data-state": open ? "open" : "closed",
135
- "data-focus": dataAttr(focused),
136
- })
137
- },
138
-
139
- getTriggerProps() {
140
- return normalize.button({
141
- ...parts.trigger.attrs,
142
- id: dom.getTriggerId(state.context),
143
- dir: state.context.dir,
144
- disabled: disabled,
145
- "aria-label": `select color. current color is ${valueAsString}`,
146
- "aria-controls": dom.getContentId(state.context),
147
- "aria-labelledby": dom.getLabelId(state.context),
148
- "data-disabled": dataAttr(disabled),
149
- "data-readonly": dataAttr(state.context.readOnly),
150
- "data-placement": currentPlacement,
151
- "aria-expanded": dataAttr(open),
152
- "data-state": open ? "open" : "closed",
153
- "data-focus": dataAttr(focused),
154
- type: "button",
155
- onClick() {
156
- if (!interactive) return
157
- send({ type: "TRIGGER.CLICK" })
158
- },
159
- onBlur() {
160
- if (!interactive) return
161
- send({ type: "TRIGGER.BLUR" })
162
- },
163
- style: {
164
- position: "relative",
165
- },
166
- })
167
- },
168
-
169
- getPositionerProps() {
170
- return normalize.element({
171
- ...parts.positioner.attrs,
172
- id: dom.getPositionerId(state.context),
173
- dir: state.context.dir,
174
- style: popperStyles.floating,
175
- })
176
- },
177
-
178
- getContentProps() {
179
- return normalize.element({
180
- ...parts.content.attrs,
181
- id: dom.getContentId(state.context),
182
- dir: state.context.dir,
183
- "data-placement": currentPlacement,
184
- "data-state": open ? "open" : "closed",
185
- hidden: !open,
186
- })
187
- },
188
-
189
- getValueTextProps() {
190
- return normalize.element({
191
- ...parts.valueText.attrs,
192
- dir: state.context.dir,
193
- "data-disabled": dataAttr(disabled),
194
- "data-focus": dataAttr(focused),
195
- })
196
- },
197
-
198
- getAreaProps(props = {}) {
199
- const { xChannel, yChannel } = getAreaChannels(props)
200
- const { areaStyles } = getColorAreaGradient(areaValue, {
201
- xChannel,
202
- yChannel,
203
- dir: state.context.dir,
204
- })
205
-
206
- return normalize.element({
207
- ...parts.area.attrs,
208
- id: dom.getAreaId(state.context),
209
- role: "group",
210
- onPointerDown(event) {
211
- if (!interactive) return
212
- if (!isLeftClick(event)) return
213
- if (isModifierKey(event)) return
214
-
215
- const point = getEventPoint(event)
216
- const channel = { xChannel, yChannel }
217
-
218
- send({ type: "AREA.POINTER_DOWN", point, channel, id: "area" })
219
- event.preventDefault()
220
- },
221
- style: {
222
- position: "relative",
223
- touchAction: "none",
224
- forcedColorAdjust: "none",
225
- ...areaStyles,
226
- },
227
- })
228
- },
229
-
230
- getAreaBackgroundProps(props = {}) {
231
- const { xChannel, yChannel } = getAreaChannels(props)
232
- const { areaGradientStyles } = getColorAreaGradient(areaValue, {
233
- xChannel,
234
- yChannel,
235
- dir: state.context.dir,
236
- })
237
-
238
- return normalize.element({
239
- ...parts.areaBackground.attrs,
240
- id: dom.getAreaGradientId(state.context),
241
- style: {
242
- position: "relative",
243
- touchAction: "none",
244
- forcedColorAdjust: "none",
245
- ...areaGradientStyles,
246
- },
247
- })
248
- },
249
-
250
- getAreaThumbProps(props = {}) {
251
- const { xChannel, yChannel } = getAreaChannels(props)
252
- const channel = { xChannel, yChannel }
253
-
254
- const xPercent = areaValue.getChannelValuePercent(xChannel)
255
- const yPercent = 1 - areaValue.getChannelValuePercent(yChannel)
256
-
257
- const xValue = areaValue.getChannelValue(xChannel)
258
- const yValue = areaValue.getChannelValue(yChannel)
259
-
260
- return normalize.element({
261
- ...parts.areaThumb.attrs,
262
- id: dom.getAreaThumbId(state.context),
263
- dir: state.context.dir,
264
- tabIndex: disabled ? undefined : 0,
265
- "data-disabled": dataAttr(disabled),
266
- role: "slider",
267
- "aria-valuemin": 0,
268
- "aria-valuemax": 100,
269
- "aria-valuenow": xValue,
270
- "aria-label": `${xChannel} and ${yChannel}`,
271
- "aria-roledescription": "2d slider",
272
- "aria-valuetext": `${xChannel} ${xValue}, ${yChannel} ${yValue}`,
273
- style: {
274
- position: "absolute",
275
- left: `${xPercent * 100}%`,
276
- top: `${yPercent * 100}%`,
277
- transform: "translate(-50%, -50%)",
278
- touchAction: "none",
279
- forcedColorAdjust: "none",
280
- background: areaValue.withChannelValue("alpha", 1).toString("css"),
281
- },
282
- onFocus() {
283
- if (!interactive) return
284
- send({ type: "AREA.FOCUS", id: "area", channel })
285
- },
286
- onKeyDown(event) {
287
- if (event.defaultPrevented) return
288
- if (!interactive) return
289
-
290
- const step = getEventStep(event)
291
-
292
- const keyMap: EventKeyMap = {
293
- ArrowUp() {
294
- send({ type: "AREA.ARROW_UP", channel, step })
295
- },
296
- ArrowDown() {
297
- send({ type: "AREA.ARROW_DOWN", channel, step })
298
- },
299
- ArrowLeft() {
300
- send({ type: "AREA.ARROW_LEFT", channel, step })
301
- },
302
- ArrowRight() {
303
- send({ type: "AREA.ARROW_RIGHT", channel, step })
304
- },
305
- PageUp() {
306
- send({ type: "AREA.PAGE_UP", channel, step })
307
- },
308
- PageDown() {
309
- send({ type: "AREA.PAGE_DOWN", channel, step })
310
- },
311
- Escape(event) {
312
- event.stopPropagation()
313
- },
314
- }
315
-
316
- const exec = keyMap[getEventKey(event, state.context)]
317
-
318
- if (exec) {
319
- exec(event)
320
- event.preventDefault()
321
- }
322
- },
323
- })
324
- },
325
-
326
- getTransparencyGridProps(props = {}) {
327
- const { size = "12px" } = props
328
- return normalize.element({
329
- ...parts.transparencyGrid.attrs,
330
- style: {
331
- "--size": size,
332
- width: "100%",
333
- height: "100%",
334
- position: "absolute",
335
- backgroundColor: "#fff",
336
- backgroundImage: "conic-gradient(#eeeeee 0 25%, transparent 0 50%, #eeeeee 0 75%, transparent 0)",
337
- backgroundSize: "var(--size) var(--size)",
338
- inset: "0px",
339
- zIndex: "auto",
340
- pointerEvents: "none",
341
- },
342
- })
343
- },
344
-
345
- getChannelSliderProps(props) {
346
- const { orientation = "horizontal", channel, format } = props
347
- return normalize.element({
348
- ...parts.channelSlider.attrs,
349
- "data-channel": channel,
350
- "data-orientation": orientation,
351
- role: "presentation",
352
- onPointerDown(event) {
353
- if (!interactive) return
354
- if (!isLeftClick(event)) return
355
- if (isModifierKey(event)) return
356
-
357
- const point = getEventPoint(event)
358
- send({ type: "CHANNEL_SLIDER.POINTER_DOWN", channel, format, point, id: channel, orientation })
359
-
360
- event.preventDefault()
361
- },
362
- style: {
363
- position: "relative",
364
- touchAction: "none",
365
- },
366
- })
367
- },
368
-
369
- getChannelSliderTrackProps(props) {
370
- const { orientation = "horizontal", channel, format } = props
371
- const normalizedValue = format ? value.toFormat(format) : areaValue
372
-
373
- return normalize.element({
374
- ...parts.channelSliderTrack.attrs,
375
- id: dom.getChannelSliderTrackId(state.context, channel),
376
- role: "group",
377
- "data-channel": channel,
378
- "data-orientation": orientation,
379
- style: {
380
- position: "relative",
381
- forcedColorAdjust: "none",
382
- backgroundImage: getSliderBackground({
383
- orientation,
384
- channel,
385
- dir: state.context.dir,
386
- value: normalizedValue,
387
- }),
388
- },
389
- })
390
- },
391
-
392
- getChannelSliderLabelProps(props) {
393
- const { channel } = props
394
- return normalize.element({
395
- ...parts.channelSliderLabel.attrs,
396
- "data-channel": channel,
397
- onClick(event) {
398
- if (!interactive) return
399
- event.preventDefault()
400
- const thumbId = dom.getChannelSliderThumbId(state.context, channel)
401
- dom.getById(state.context, thumbId)?.focus({ preventScroll: true })
402
- },
403
- style: {
404
- userSelect: "none",
405
- WebkitUserSelect: "none",
406
- },
407
- })
408
- },
409
-
410
- getChannelSliderValueTextProps(props) {
411
- return normalize.element({
412
- ...parts.channelSliderValueText.attrs,
413
- "data-channel": props.channel,
414
- })
415
- },
416
-
417
- getChannelSliderThumbProps(props) {
418
- const { orientation = "horizontal", channel, format } = props
419
-
420
- const normalizedValue = format ? value.toFormat(format) : areaValue
421
- const channelRange = normalizedValue.getChannelRange(channel)
422
- const channelValue = normalizedValue.getChannelValue(channel)
423
-
424
- const offset = (channelValue - channelRange.minValue) / (channelRange.maxValue - channelRange.minValue)
425
-
426
- const placementStyles =
427
- orientation === "horizontal"
428
- ? { left: `${offset * 100}%`, top: "50%" }
429
- : { top: `${offset * 100}%`, left: "50%" }
430
-
431
- return normalize.element({
432
- ...parts.channelSliderThumb.attrs,
433
- id: dom.getChannelSliderThumbId(state.context, channel),
434
- role: "slider",
435
- "aria-label": channel,
436
- tabIndex: disabled ? undefined : 0,
437
- "data-channel": channel,
438
- "data-disabled": dataAttr(disabled),
439
- "data-orientation": orientation,
440
- "aria-disabled": dataAttr(disabled),
441
- "aria-orientation": orientation,
442
- "aria-valuemax": channelRange.maxValue,
443
- "aria-valuemin": channelRange.minValue,
444
- "aria-valuenow": channelValue,
445
- "aria-valuetext": `${channel} ${channelValue}`,
446
- style: {
447
- forcedColorAdjust: "none",
448
- position: "absolute",
449
- background: getChannelDisplayColor(areaValue, channel).toString("css"),
450
- ...placementStyles,
451
- },
452
- onFocus() {
453
- if (!interactive) return
454
- send({ type: "CHANNEL_SLIDER.FOCUS", channel })
455
- },
456
- onKeyDown(event) {
457
- if (event.defaultPrevented) return
458
- if (!interactive) return
459
-
460
- const step = getEventStep(event) * channelRange.step
461
-
462
- const keyMap: EventKeyMap = {
463
- ArrowUp() {
464
- send({ type: "CHANNEL_SLIDER.ARROW_UP", channel, step })
465
- },
466
- ArrowDown() {
467
- send({ type: "CHANNEL_SLIDER.ARROW_DOWN", channel, step })
468
- },
469
- ArrowLeft() {
470
- send({ type: "CHANNEL_SLIDER.ARROW_LEFT", channel, step })
471
- },
472
- ArrowRight() {
473
- send({ type: "CHANNEL_SLIDER.ARROW_RIGHT", channel, step })
474
- },
475
- PageUp() {
476
- send({ type: "CHANNEL_SLIDER.PAGE_UP", channel })
477
- },
478
- PageDown() {
479
- send({ type: "CHANNEL_SLIDER.PAGE_DOWN", channel })
480
- },
481
- Home() {
482
- send({ type: "CHANNEL_SLIDER.HOME", channel })
483
- },
484
- End() {
485
- send({ type: "CHANNEL_SLIDER.END", channel })
486
- },
487
- Escape(event) {
488
- event.stopPropagation()
489
- },
490
- }
491
-
492
- const exec = keyMap[getEventKey(event, state.context)]
493
-
494
- if (exec) {
495
- exec(event)
496
- event.preventDefault()
497
- }
498
- },
499
- })
500
- },
501
-
502
- getChannelInputProps(props) {
503
- const { channel } = props
504
-
505
- const isTextField = channel === "hex" || channel === "css"
506
- const channelRange = getChannelRange(value, channel)
507
-
508
- return normalize.input({
509
- ...parts.channelInput.attrs,
510
- dir: state.context.dir,
511
- type: isTextField ? "text" : "number",
512
- "data-channel": channel,
513
- "aria-label": channel,
514
- spellCheck: false,
515
- autoComplete: "off",
516
- disabled: disabled,
517
- "data-disabled": dataAttr(disabled),
518
- readOnly: state.context.readOnly,
519
- defaultValue: getChannelValue(value, channel),
520
- min: channelRange?.minValue,
521
- max: channelRange?.maxValue,
522
- step: channelRange?.step,
523
- onBeforeInput(event) {
524
- if (isTextField || !interactive) return
525
- const value = event.currentTarget.value
526
- if (value.match(/[^0-9.]/g)) {
527
- event.preventDefault()
528
- }
529
- },
530
- onFocus(event) {
531
- if (!interactive) return
532
- send({ type: "CHANNEL_INPUT.FOCUS", channel })
533
- event.target.select()
534
- },
535
- onBlur(event) {
536
- if (!interactive) return
537
- const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber
538
- send({ type: "CHANNEL_INPUT.BLUR", channel, value, isTextField })
539
- },
540
- onKeyDown(event) {
541
- if (event.defaultPrevented) return
542
- if (!interactive) return
543
- if (event.key === "Enter") {
544
- const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber
545
- send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField })
546
- event.preventDefault()
547
- }
548
- },
549
- style: {
550
- appearance: "none",
551
- WebkitAppearance: "none",
552
- MozAppearance: "textfield",
553
- },
554
- })
555
- },
556
-
557
- getHiddenInputProps() {
558
- return normalize.input({
559
- type: "text",
560
- disabled,
561
- name: state.context.name,
562
- readOnly: state.context.readOnly,
563
- required: state.context.required,
564
- id: dom.getHiddenInputId(state.context),
565
- style: visuallyHiddenStyle,
566
- defaultValue: valueAsString,
567
- })
568
- },
569
-
570
- getEyeDropperTriggerProps() {
571
- return normalize.button({
572
- ...parts.eyeDropperTrigger.attrs,
573
- type: "button",
574
- dir: state.context.dir,
575
- disabled: disabled,
576
- "data-disabled": dataAttr(disabled),
577
- "aria-label": "Pick a color from the screen",
578
- onClick() {
579
- if (!interactive) return
580
- send("EYEDROPPER.CLICK")
581
- },
582
- })
583
- },
584
-
585
- getSwatchGroupProps() {
586
- return normalize.element({
587
- ...parts.swatchGroup.attrs,
588
- role: "group",
589
- })
590
- },
591
-
592
- getSwatchTriggerState,
593
-
594
- getSwatchTriggerProps(props) {
595
- const swatchState = getSwatchTriggerState(props)
596
- return normalize.button({
597
- ...parts.swatchTrigger.attrs,
598
- disabled: swatchState.disabled,
599
- dir: state.context.dir,
600
- type: "button",
601
- "aria-label": `select ${swatchState.valueAsString} as the color`,
602
- "data-state": swatchState.checked ? "checked" : "unchecked",
603
- "data-value": swatchState.valueAsString,
604
- "data-disabled": dataAttr(swatchState.disabled),
605
- onClick() {
606
- if (swatchState.disabled) return
607
- send({ type: "SWATCH_TRIGGER.CLICK", value: swatchState.value })
608
- },
609
- style: {
610
- position: "relative",
611
- },
612
- })
613
- },
614
-
615
- getSwatchIndicatorProps(props) {
616
- const swatchState = getSwatchTriggerState(props)
617
- return normalize.element({
618
- ...parts.swatchIndicator.attrs,
619
- dir: state.context.dir,
620
- hidden: !swatchState.checked,
621
- })
622
- },
623
-
624
- getSwatchProps(props) {
625
- const { respectAlpha = true } = props
626
- const swatchState = getSwatchTriggerState(props)
627
- return normalize.element({
628
- ...parts.swatch.attrs,
629
- dir: state.context.dir,
630
- "data-state": swatchState.checked ? "checked" : "unchecked",
631
- "data-value": swatchState.valueAsString,
632
- style: {
633
- position: "relative",
634
- background: swatchState.value.toString(respectAlpha ? "css" : "hex"),
635
- },
636
- })
637
- },
638
-
639
- getFormatTriggerProps() {
640
- return normalize.button({
641
- ...parts.formatTrigger.attrs,
642
- dir: state.context.dir,
643
- type: "button",
644
- "aria-label": `change color format to ${getNextFormat(state.context.format)}`,
645
- onClick(event) {
646
- if (event.currentTarget.disabled) return
647
- const nextFormat = getNextFormat(state.context.format)
648
- send({ type: "FORMAT.SET", format: nextFormat, src: "format-trigger" })
649
- },
650
- })
651
- },
652
-
653
- getFormatSelectProps() {
654
- return normalize.select({
655
- ...parts.formatSelect.attrs,
656
- "aria-label": "change color format",
657
- dir: state.context.dir,
658
- defaultValue: state.context.format,
659
- disabled: disabled,
660
- onChange(event) {
661
- const format = assertFormat(event.currentTarget.value)
662
- send({ type: "FORMAT.SET", format, src: "format-select" })
663
- },
664
- })
665
- },
666
- }
667
- }
668
-
669
- const formats: ColorFormat[] = ["hsba", "hsla", "rgba"]
670
- const formatRegex = new RegExp(`^(${formats.join("|")})$`)
671
-
672
- function getNextFormat(format: ColorFormat) {
673
- const index = formats.indexOf(format)
674
- return formats[index + 1] ?? formats[0]
675
- }
676
-
677
- function assertFormat(format: string) {
678
- if (formatRegex.test(format)) return format as ColorFormat
679
- throw new Error(`Unsupported color format: ${format}`)
680
- }