@zag-js/color-picker 0.22.0 → 0.24.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,12 +1,14 @@
1
1
  import { parseColor, type Color } from "@zag-js/color-utils"
2
- import { createMachine } from "@zag-js/core"
2
+ import { createMachine, guards, ref } from "@zag-js/core"
3
+ import { trackDismissableElement } from "@zag-js/dismissable"
3
4
  import { trackPointerMove } from "@zag-js/dom-event"
4
5
  import { raf } from "@zag-js/dom-query"
5
- import { trackFormControl } from "@zag-js/form-utils"
6
- import { clampValue, getPercentValue, snapValueToStep } from "@zag-js/numeric-range"
6
+ import { dispatchInputValueEvent, trackFormControl } from "@zag-js/form-utils"
7
+ import { getPlacement } from "@zag-js/popper"
7
8
  import { disableTextSelection } from "@zag-js/text-selection"
8
9
  import { compact, tryCatch } from "@zag-js/utils"
9
10
  import { dom } from "./color-picker.dom"
11
+ import { parse } from "./color-picker.parse"
10
12
  import type {
11
13
  ColorFormat,
12
14
  ColorType,
@@ -15,87 +17,129 @@ import type {
15
17
  MachineState,
16
18
  UserDefinedContext,
17
19
  } from "./color-picker.types"
18
- import { getChannelDetails } from "./utils/get-channel-details"
19
- import { getChannelInputValue } from "./utils/get-channel-input-value"
20
+ import { getChannelValue } from "./utils/get-channel-input-value"
21
+
22
+ const { stateIn } = guards
20
23
 
21
24
  export function machine(userContext: UserDefinedContext) {
22
25
  const ctx = compact(userContext)
23
26
  return createMachine<MachineContext, MachineState>(
24
27
  {
25
28
  id: "color-picker",
26
- initial: "idle",
29
+ initial: ctx.open ? "open" : "idle",
27
30
  context: {
28
31
  dir: "ltr",
29
- value: "#D9D9D9",
32
+ value: parse("#000000"),
30
33
  disabled: false,
31
34
  ...ctx,
32
35
  activeId: null,
33
36
  activeChannel: null,
34
37
  activeOrientation: null,
35
38
  fieldsetDisabled: false,
39
+ autoFocus: true,
40
+ positioning: {
41
+ ...ctx.positioning,
42
+ placement: "bottom",
43
+ },
36
44
  },
37
45
 
38
46
  computed: {
39
47
  isRtl: (ctx) => ctx.dir === "rtl",
40
48
  isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled,
41
49
  isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly),
42
- valueAsColor: (ctx) => parseColor(ctx.value) as Color,
50
+ valueAsString: (ctx) => ctx.value.toString("css"),
51
+ },
52
+
53
+ activities: ["trackFormControl"],
54
+
55
+ watch: {
56
+ valueAsString: ["syncInputElements"],
57
+ open: ["toggleVisibility"],
43
58
  },
44
59
 
45
60
  on: {
46
61
  "VALUE.SET": {
47
62
  actions: ["setValue"],
48
63
  },
49
- },
50
-
51
- activities: ["trackFormControl"],
52
-
53
- watch: {
54
- value: ["syncInputElements"],
64
+ "CHANNEL_INPUT.FOCUS": [
65
+ {
66
+ guard: stateIn("idle"),
67
+ target: "focused",
68
+ actions: ["setActiveChannel"],
69
+ },
70
+ {
71
+ actions: ["setActiveChannel"],
72
+ },
73
+ ],
74
+ "CHANNEL_INPUT.BLUR": [
75
+ {
76
+ guard: stateIn("focused"),
77
+ target: "idle",
78
+ actions: ["setChannelColorFromInput"],
79
+ },
80
+ {
81
+ actions: ["setChannelColorFromInput"],
82
+ },
83
+ ],
84
+ "CHANNEL_INPUT.CHANGE": {
85
+ actions: ["setChannelColorFromInput"],
86
+ },
55
87
  },
56
88
 
57
89
  states: {
58
90
  idle: {
91
+ tags: ["closed"],
92
+ on: {
93
+ OPEN: {
94
+ target: "open",
95
+ actions: ["setInitialFocus", "invokeOnOpen"],
96
+ },
97
+ "TRIGGER.CLICK": {
98
+ target: "open",
99
+ actions: ["setInitialFocus", "invokeOnOpen"],
100
+ },
101
+ },
102
+ },
103
+
104
+ focused: {
105
+ tags: ["closed", "focused"],
59
106
  on: {
107
+ OPEN: {
108
+ target: "open",
109
+ actions: ["setInitialFocus", "invokeOnOpen"],
110
+ },
111
+ "TRIGGER.CLICK": {
112
+ target: "open",
113
+ actions: ["setInitialFocus", "invokeOnOpen"],
114
+ },
115
+ },
116
+ },
117
+
118
+ open: {
119
+ tags: ["open"],
120
+ activities: ["trackPositioning", "trackDismissableElement"],
121
+ on: {
122
+ "TRIGGER.CLICK": {
123
+ target: "idle",
124
+ actions: ["invokeOnClose"],
125
+ },
60
126
  "EYEDROPPER.CLICK": {
61
127
  actions: ["openEyeDropper"],
62
128
  },
63
129
  "AREA.POINTER_DOWN": {
64
- target: "dragging",
130
+ target: "open:dragging",
65
131
  actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"],
66
132
  },
67
133
  "AREA.FOCUS": {
68
- target: "focused",
69
134
  actions: ["setActiveChannel"],
70
135
  },
71
136
  "CHANNEL_SLIDER.POINTER_DOWN": {
72
- target: "dragging",
137
+ target: "open:dragging",
73
138
  actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"],
74
139
  },
75
140
  "CHANNEL_SLIDER.FOCUS": {
76
- target: "focused",
77
- actions: ["setActiveChannel"],
78
- },
79
- "CHANNEL_INPUT.FOCUS": {
80
- target: "focused",
81
141
  actions: ["setActiveChannel"],
82
142
  },
83
- "CHANNEL_INPUT.CHANGE": {
84
- actions: ["setChannelColorFromInput"],
85
- },
86
- },
87
- },
88
-
89
- focused: {
90
- on: {
91
- "AREA.POINTER_DOWN": {
92
- target: "dragging",
93
- actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"],
94
- },
95
- "CHANNEL_SLIDER.POINTER_DOWN": {
96
- target: "dragging",
97
- actions: ["setActiveChannel", "setChannelColorFromPoint", "focusChannelThumb"],
98
- },
99
143
  "AREA.ARROW_LEFT": {
100
144
  actions: ["decrementXChannel"],
101
145
  },
@@ -138,45 +182,100 @@ export function machine(userContext: UserDefinedContext) {
138
182
  "CHANNEL_SLIDER.END": {
139
183
  actions: ["setChannelToMax"],
140
184
  },
141
- "CHANNEL_INPUT.FOCUS": {
142
- actions: ["setActiveChannel"],
143
- },
144
- "CHANNEL_INPUT.CHANGE": {
145
- actions: ["setChannelColorFromInput"],
146
- },
147
- "CHANNEL_SLIDER.BLUR": {
148
- target: "idle",
149
- },
150
- "AREA.BLUR": {
185
+ INTERACT_OUTSIDE: [
186
+ {
187
+ guard: "shouldRestoreFocus",
188
+ target: "focused",
189
+ actions: ["setReturnFocus", "invokeOnClose"],
190
+ },
191
+ {
192
+ target: "idle",
193
+ actions: ["invokeOnClose"],
194
+ },
195
+ ],
196
+ CLOSE: {
151
197
  target: "idle",
198
+ actions: ["invokeOnClose"],
152
199
  },
153
200
  },
154
201
  },
155
202
 
156
- dragging: {
203
+ "open:dragging": {
204
+ tags: ["open"],
157
205
  exit: ["clearActiveChannel"],
158
- activities: ["trackPointerMove", "disableTextSelection"],
206
+ activities: ["trackPointerMove", "disableTextSelection", "trackPositioning", "trackDismissableElement"],
159
207
  on: {
160
208
  "AREA.POINTER_MOVE": {
161
- actions: ["setAreaColorFromPoint"],
209
+ actions: ["setAreaColorFromPoint", "focusAreaThumb"],
162
210
  },
163
211
  "AREA.POINTER_UP": {
164
- target: "focused",
212
+ target: "open",
165
213
  actions: ["invokeOnChangeEnd"],
166
214
  },
167
215
  "CHANNEL_SLIDER.POINTER_MOVE": {
168
- actions: ["setChannelColorFromPoint"],
216
+ actions: ["setChannelColorFromPoint", "focusChannelThumb"],
169
217
  },
170
218
  "CHANNEL_SLIDER.POINTER_UP": {
171
- target: "focused",
219
+ target: "open",
172
220
  actions: ["invokeOnChangeEnd"],
173
221
  },
222
+ INTERACT_OUTSIDE: [
223
+ {
224
+ guard: "shouldRestoreFocus",
225
+ target: "focused",
226
+ actions: ["setReturnFocus", "invokeOnClose"],
227
+ },
228
+ {
229
+ target: "idle",
230
+ actions: ["invokeOnClose"],
231
+ },
232
+ ],
233
+ CLOSE: {
234
+ target: "idle",
235
+ actions: ["invokeOnClose"],
236
+ },
174
237
  },
175
238
  },
176
239
  },
177
240
  },
178
241
  {
242
+ guards: {
243
+ isTargetFocusable: (_ctx, evt) => evt.restoreFocus,
244
+ },
179
245
  activities: {
246
+ trackPositioning(ctx) {
247
+ ctx.currentPlacement = ctx.positioning.placement
248
+ const anchorEl = dom.getTriggerEl(ctx)
249
+ const getPositionerEl = () => dom.getPositionerEl(ctx)
250
+ return getPlacement(anchorEl, getPositionerEl, {
251
+ ...ctx.positioning,
252
+ defer: true,
253
+ onComplete(data) {
254
+ ctx.currentPlacement = data.placement
255
+ },
256
+ onCleanup() {
257
+ ctx.currentPlacement = undefined
258
+ },
259
+ })
260
+ },
261
+ trackDismissableElement(ctx, _evt, { send }) {
262
+ const getContentEl = () => dom.getContentEl(ctx)
263
+ let restoreFocus = true
264
+ return trackDismissableElement(getContentEl, {
265
+ exclude: dom.getTriggerEl(ctx),
266
+ defer: true,
267
+ onInteractOutside(event) {
268
+ ctx.onInteractOutside?.(event)
269
+ if (event.defaultPrevented) return
270
+ restoreFocus = !(event.detail.focusable || event.detail.contextmenu)
271
+ },
272
+ onPointerDownOutside: ctx.onPointerDownOutside,
273
+ onFocusOutside: ctx.onFocusOutside,
274
+ onDismiss() {
275
+ send({ type: "INTERACT_OUTSIDE", restoreFocus })
276
+ },
277
+ })
278
+ },
180
279
  trackFormControl(ctx, _evt, { send, initialContext }) {
181
280
  const inputEl = dom.getHiddenInputEl(ctx)
182
281
  return trackFormControl(inputEl, {
@@ -213,21 +312,17 @@ export function machine(userContext: UserDefinedContext) {
213
312
  picker
214
313
  .open()
215
314
  .then(({ sRGBHex }: { sRGBHex: string }) => {
216
- const format = ctx.valueAsColor.getColorFormat()
315
+ const format = ctx.value.getFormat()
217
316
  const color = parseColor(sRGBHex).toFormat(format) as Color
218
317
  set.value(ctx, color)
219
- ctx.onValueChangeEnd?.({ value: ctx.value, valueAsColor: color })
318
+ ctx.onValueChangeEnd?.({ value: ctx.value, valueAsString: ctx.valueAsString })
220
319
  })
221
320
  .catch(() => void 0)
222
321
  },
223
322
  setActiveChannel(ctx, evt) {
224
323
  ctx.activeId = evt.id
225
- if (evt.channel) {
226
- ctx.activeChannel = evt.channel
227
- }
228
- if (evt.orientation) {
229
- ctx.activeOrientation = evt.orientation
230
- }
324
+ if (evt.channel) ctx.activeChannel = evt.channel
325
+ if (evt.orientation) ctx.activeOrientation = evt.orientation
231
326
  },
232
327
  clearActiveChannel(ctx) {
233
328
  ctx.activeChannel = null
@@ -240,10 +335,10 @@ export function machine(userContext: UserDefinedContext) {
240
335
  const percent = dom.getAreaValueFromPoint(ctx, evt.point)
241
336
  if (!percent) return
242
337
 
243
- const { getColorFromPoint } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel)
244
- const color = getColorFromPoint(percent.x, percent.y)
338
+ const xValue = ctx.value.getChannelPercentValue(xChannel, percent.x)
339
+ const yValue = ctx.value.getChannelPercentValue(yChannel, 1 - percent.y)
245
340
 
246
- if (!color) return
341
+ const color = ctx.value.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue)
247
342
  set.value(ctx, color)
248
343
  },
249
344
  setChannelColorFromPoint(ctx, evt) {
@@ -252,30 +347,18 @@ export function machine(userContext: UserDefinedContext) {
252
347
  const percent = dom.getChannelSliderValueFromPoint(ctx, evt.point, channel)
253
348
  if (!percent) return
254
349
 
255
- const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(channel)
256
350
  const orientation = ctx.activeOrientation || "horizontal"
351
+ const channelPercent = orientation === "horizontal" ? percent.x : percent.y
257
352
 
258
- const point = orientation === "horizontal" ? percent.x : percent.y
259
- const channelValue = getPercentValue(point, minValue, maxValue, step)
260
-
261
- const value = snapValueToStep(channelValue - step, minValue, maxValue, step)
262
- const newColor = ctx.valueAsColor.withChannelValue(channel, value)
263
-
264
- set.value(ctx, newColor)
353
+ const value = ctx.value.getChannelPercentValue(channel, channelPercent)
354
+ const color = ctx.value.withChannelValue(channel, value)
355
+ set.value(ctx, color)
265
356
  },
266
357
  setValue(ctx, evt) {
267
358
  set.value(ctx, evt.value)
268
359
  },
269
360
  syncInputElements(ctx) {
270
- // sync channel inputs
271
- const inputs = dom.getChannelInputEls(ctx)
272
- inputs.forEach((input) => {
273
- const channel = input.dataset.channel as ExtendedColorChannel | null
274
- dom.setValue(input, getChannelInputValue(ctx.valueAsColor, channel))
275
- })
276
-
277
- // sync hidden input
278
- dom.setValue(dom.getHiddenInputEl(ctx), ctx.value)
361
+ sync.inputs(ctx)
279
362
  },
280
363
  invokeOnChangeEnd(ctx) {
281
364
  invoke.changeEnd(ctx)
@@ -285,7 +368,7 @@ export function machine(userContext: UserDefinedContext) {
285
368
 
286
369
  // handle alpha channel
287
370
  if (channel === "alpha") {
288
- const newColor = ctx.valueAsColor.withChannelValue("alpha", value)
371
+ const newColor = ctx.value.withChannelValue("alpha", parseFloat(value))
289
372
  set.value(ctx, newColor)
290
373
  return
291
374
  }
@@ -293,111 +376,130 @@ export function machine(userContext: UserDefinedContext) {
293
376
  // handle other text channels
294
377
  if (isTextField) {
295
378
  const format: ColorFormat = "hsl"
296
- const currentAlpha = ctx.valueAsColor.getChannelValue("alpha")
379
+ const currentAlpha = ctx.value.getChannelValue("alpha")
297
380
 
298
- const newColor = tryCatch(
381
+ const color = tryCatch(
299
382
  () => parseColor(value).toFormat(format).withChannelValue("alpha", currentAlpha),
300
- () => ctx.valueAsColor,
383
+ () => ctx.value,
301
384
  )
302
385
 
303
386
  // set channel input value immediately (in event user types native css color, we need to convert it to the current channel format)
304
387
  const inputEl = dom.getChannelInputEl(ctx, channel)
305
- dom.setValue(inputEl, getChannelInputValue(ctx.valueAsColor, channel))
388
+ dom.setValue(inputEl, getChannelValue(color, channel))
306
389
 
307
- set.value(ctx, newColor)
390
+ set.value(ctx, color)
308
391
  return
309
392
  }
310
393
 
311
394
  // handle other channels
312
- const newColor = ctx.valueAsColor.withChannelValue(channel, value)
313
- set.value(ctx, newColor)
395
+ const color = ctx.value.withChannelValue(channel, value)
396
+ set.value(ctx, color)
314
397
  },
315
-
316
398
  incrementChannel(ctx, evt) {
317
- const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel)
318
- const channelValue = ctx.valueAsColor.getChannelValue(evt.channel)
319
- const value = snapValueToStep(channelValue + evt.step, minValue, maxValue, step)
320
- const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue))
399
+ const color = ctx.value.incrementChannel(evt.channel, evt.step)
321
400
  set.value(ctx, color)
322
401
  },
323
402
  decrementChannel(ctx, evt) {
324
- const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel)
325
- const channelValue = ctx.valueAsColor.getChannelValue(evt.channel)
326
- const value = snapValueToStep(channelValue - evt.step, minValue, maxValue, step)
327
- const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue))
403
+ const color = ctx.value.decrementChannel(evt.channel, evt.step)
328
404
  set.value(ctx, color)
329
405
  },
330
-
331
406
  incrementXChannel(ctx, evt) {
332
- const { xChannel, yChannel } = evt.channel
333
- const { incrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel)
334
- const color = ctx.valueAsColor.withChannelValue(xChannel, incrementX(evt.step))
407
+ const { xChannel } = evt.channel
408
+ const color = ctx.value.incrementChannel(xChannel, evt.step)
335
409
  set.value(ctx, color)
336
410
  },
337
411
  decrementXChannel(ctx, evt) {
338
- const { xChannel, yChannel } = evt.channel
339
- const { decrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel)
340
- const color = ctx.valueAsColor.withChannelValue(xChannel, decrementX(evt.step))
412
+ const { xChannel } = evt.channel
413
+ const color = ctx.value.decrementChannel(xChannel, evt.step)
341
414
  set.value(ctx, color)
342
415
  },
343
-
344
416
  incrementYChannel(ctx, evt) {
345
- const { xChannel, yChannel } = evt.channel
346
- const { incrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel)
347
- const color = ctx.valueAsColor.withChannelValue(yChannel, incrementY(evt.step))
417
+ const { yChannel } = evt.channel
418
+ const color = ctx.value.incrementChannel(yChannel, evt.step)
348
419
  set.value(ctx, color)
349
420
  },
350
421
  decrementYChannel(ctx, evt) {
351
- const { xChannel, yChannel } = evt.channel
352
- const { decrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel)
353
- const color = ctx.valueAsColor.withChannelValue(yChannel, decrementY(evt.step))
422
+ const { yChannel } = evt.channel
423
+ const color = ctx.value.decrementChannel(yChannel, evt.step)
354
424
  set.value(ctx, color)
355
425
  },
356
-
357
426
  setChannelToMax(ctx, evt) {
358
- const { maxValue } = ctx.valueAsColor.getChannelRange(evt.channel)
359
- const color = ctx.valueAsColor.withChannelValue(evt.channel, maxValue)
427
+ const range = ctx.value.getChannelRange(evt.channel)
428
+ const color = ctx.value.withChannelValue(evt.channel, range.maxValue)
360
429
  set.value(ctx, color)
361
430
  },
362
431
  setChannelToMin(ctx, evt) {
363
- const { minValue } = ctx.valueAsColor.getChannelRange(evt.channel)
364
- const color = ctx.valueAsColor.withChannelValue(evt.channel, minValue)
432
+ const range = ctx.value.getChannelRange(evt.channel)
433
+ const color = ctx.value.withChannelValue(evt.channel, range.minValue)
365
434
  set.value(ctx, color)
366
435
  },
367
436
  focusAreaThumb(ctx) {
368
437
  raf(() => {
369
- dom.getAreaThumbEl(ctx)?.focus({ preventScroll: true })
438
+ dom?.focus(ctx, dom.getAreaThumbEl(ctx))
370
439
  })
371
440
  },
372
441
  focusChannelThumb(ctx, evt) {
373
442
  raf(() => {
374
- dom.getChannelSliderThumbEl(ctx, evt.channel)?.focus({ preventScroll: true })
443
+ dom?.focus(ctx, dom.getChannelSliderThumbEl(ctx, evt.channel))
375
444
  })
376
445
  },
446
+ setInitialFocus(ctx) {
447
+ raf(() => {
448
+ dom.getInitialFocusEl(ctx)?.focus({ preventScroll: true })
449
+ })
450
+ },
451
+ setReturnFocus(ctx) {
452
+ raf(() => {
453
+ dom?.focus(ctx, dom.getTriggerEl(ctx))
454
+ })
455
+ },
456
+ invokeOnOpen(ctx) {
457
+ ctx.onOpenChange?.({ open: true })
458
+ },
459
+ invokeOnClose(ctx) {
460
+ ctx.onOpenChange?.({ open: false })
461
+ },
462
+ toggleVisibility(ctx, _evt, { send }) {
463
+ send({ type: ctx.open ? "OPEN" : "CLOSE", src: "controlled" })
464
+ },
377
465
  },
378
466
  },
379
467
  )
380
468
  }
381
469
 
470
+ const sync = {
471
+ inputs(ctx: MachineContext) {
472
+ // sync channel inputs
473
+ const channelInputs = dom.getChannelInputEls(ctx)
474
+ channelInputs.forEach((inputEl) => {
475
+ const channel = inputEl.dataset.channel as ExtendedColorChannel | null
476
+ dom.setValue(inputEl, getChannelValue(ctx.value, channel))
477
+ })
478
+ },
479
+ }
480
+
382
481
  const invoke = {
383
482
  changeEnd(ctx: MachineContext) {
384
483
  ctx.onValueChangeEnd?.({
385
484
  value: ctx.value,
386
- valueAsColor: ctx.valueAsColor,
485
+ valueAsString: ctx.valueAsString,
387
486
  })
388
487
  },
389
488
  change(ctx: MachineContext) {
390
489
  ctx.onValueChange?.({
391
490
  value: ctx.value,
392
- valueAsColor: ctx.valueAsColor,
491
+ valueAsString: ctx.valueAsString,
393
492
  })
493
+
494
+ dispatchInputValueEvent(dom.getHiddenInputEl(ctx), { value: ctx.valueAsString })
394
495
  },
395
496
  }
396
497
 
397
498
  const set = {
398
- value(ctx: MachineContext, color: Color | ColorType) {
399
- if (ctx.valueAsColor.isEqual(color)) return
400
- ctx.value = color.toString("css")
499
+ value(ctx: MachineContext, color: Color | ColorType | undefined) {
500
+ sync.inputs(ctx)
501
+ if (!color || ctx.value.isEqual(color)) return
502
+ ctx.value = ref(color) as Color
401
503
  invoke.change(ctx)
402
504
  },
403
505
  }
@@ -0,0 +1,4 @@
1
+ import { parseColor, type Color } from "@zag-js/color-utils"
2
+ import { ref } from "@zag-js/core"
3
+
4
+ export const parse = (color: string): Color => ref(parseColor(color)) as unknown as Color