@zag-js/color-picker 0.29.0 → 0.30.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.
@@ -14,13 +14,22 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types"
14
14
  import { visuallyHiddenStyle } from "@zag-js/visually-hidden"
15
15
  import { parts } from "./color-picker.anatomy"
16
16
  import { dom } from "./color-picker.dom"
17
- import type { AreaProps, MachineApi, Send, State } from "./color-picker.types"
17
+ import type {
18
+ AreaProps,
19
+ ColorFormat,
20
+ MachineApi,
21
+ Send,
22
+ State,
23
+ SwatchTriggerProps,
24
+ SwatchTriggerState,
25
+ } from "./color-picker.types"
18
26
  import { getChannelDisplayColor } from "./utils/get-channel-display-color"
19
27
  import { getChannelRange, getChannelValue } from "./utils/get-channel-input-value"
20
28
  import { getSliderBackground } from "./utils/get-slider-background"
21
29
 
22
30
  export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>): MachineApi<T> {
23
31
  const value = state.context.value
32
+ const areaValue = state.context.areaValue
24
33
  const valueAsString = state.context.valueAsString
25
34
 
26
35
  const isDisabled = state.context.isDisabled
@@ -28,9 +37,10 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
28
37
 
29
38
  const isDragging = state.hasTag("dragging")
30
39
  const isOpen = state.hasTag("open")
40
+ const isFocused = state.hasTag("focused")
31
41
 
32
42
  const getAreaChannels = (props: AreaProps) => {
33
- const channels = value.getChannels()
43
+ const channels = areaValue.getChannels()
34
44
  return {
35
45
  xChannel: props.xChannel ?? channels[1],
36
46
  yChannel: props.yChannel ?? channels[2],
@@ -43,11 +53,27 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
43
53
  placement: currentPlacement,
44
54
  })
45
55
 
56
+ function getSwatchTriggerState(props: SwatchTriggerProps): SwatchTriggerState {
57
+ const color = normalizeColor(props.value).toFormat(state.context.format)
58
+ return {
59
+ value: color,
60
+ valueAsString: color.toString("hex"),
61
+ isChecked: color.isEqual(value),
62
+ isDisabled: props.disabled || !isInteractive,
63
+ }
64
+ }
65
+
46
66
  return {
47
67
  isDragging,
48
68
  isOpen,
49
69
  valueAsString,
50
70
  value,
71
+ open() {
72
+ send({ type: "OPEN" })
73
+ },
74
+ close() {
75
+ send({ type: "CLOSE" })
76
+ },
51
77
  setValue(value) {
52
78
  send({ type: "VALUE.SET", value: normalizeColor(value), src: "set-color" })
53
79
  },
@@ -58,7 +84,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
58
84
  const color = value.withChannelValue(channel, channelValue)
59
85
  send({ type: "VALUE.SET", value: color, src: "set-channel" })
60
86
  },
61
- format: value.outputFormat ?? value.getFormat(),
87
+ format: state.context.format,
62
88
  setFormat(format) {
63
89
  const formatValue = value.toFormat(format)
64
90
  send({ type: "VALUE.SET", value: formatValue, src: "set-format" })
@@ -87,6 +113,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
87
113
  htmlFor: dom.getHiddenInputId(state.context),
88
114
  "data-disabled": dataAttr(isDisabled),
89
115
  "data-readonly": dataAttr(state.context.readOnly),
116
+ "data-focus": dataAttr(isFocused),
90
117
  onClick(event) {
91
118
  event.preventDefault()
92
119
  const inputEl = query(dom.getControlEl(state.context), "[data-channel=hex]")
@@ -100,21 +127,33 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
100
127
  dir: state.context.dir,
101
128
  "data-disabled": dataAttr(isDisabled),
102
129
  "data-readonly": dataAttr(state.context.readOnly),
130
+ "data-state": isOpen ? "open" : "closed",
131
+ "data-focus": dataAttr(isFocused),
103
132
  }),
104
133
 
105
134
  triggerProps: normalize.button({
106
135
  ...parts.trigger.attrs,
107
136
  id: dom.getTriggerId(state.context),
108
137
  dir: state.context.dir,
138
+ disabled: isDisabled,
139
+ "aria-label": `select color. current color is ${valueAsString}`,
109
140
  "aria-controls": dom.getContentId(state.context),
110
141
  "aria-labelledby": dom.getLabelId(state.context),
111
142
  "data-disabled": dataAttr(isDisabled),
112
143
  "data-readonly": dataAttr(state.context.readOnly),
113
144
  "data-placement": currentPlacement,
145
+ "aria-expanded": dataAttr(isOpen),
146
+ "data-state": isOpen ? "open" : "closed",
147
+ "data-focus": dataAttr(isFocused),
114
148
  type: "button",
115
149
  onClick() {
150
+ if (!isInteractive) return
116
151
  send({ type: "TRIGGER.CLICK" })
117
152
  },
153
+ onBlur() {
154
+ if (!isInteractive) return
155
+ send({ type: "TRIGGER.BLUR" })
156
+ },
118
157
  style: {
119
158
  position: "relative",
120
159
  },
@@ -132,12 +171,13 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
132
171
  id: dom.getContentId(state.context),
133
172
  dir: state.context.dir,
134
173
  "data-placement": currentPlacement,
174
+ "data-state": isOpen ? "open" : "closed",
135
175
  hidden: !isOpen,
136
176
  }),
137
177
 
138
178
  getAreaProps(props = {}) {
139
179
  const { xChannel, yChannel } = getAreaChannels(props)
140
- const { areaStyles } = getColorAreaGradient(value, {
180
+ const { areaStyles } = getColorAreaGradient(areaValue, {
141
181
  xChannel,
142
182
  yChannel,
143
183
  dir: state.context.dir,
@@ -170,7 +210,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
170
210
 
171
211
  getAreaBackgroundProps(props = {}) {
172
212
  const { xChannel, yChannel } = getAreaChannels(props)
173
- const { areaGradientStyles } = getColorAreaGradient(value, {
213
+ const { areaGradientStyles } = getColorAreaGradient(areaValue, {
174
214
  xChannel,
175
215
  yChannel,
176
216
  dir: state.context.dir,
@@ -192,8 +232,11 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
192
232
  const { xChannel, yChannel } = getAreaChannels(props)
193
233
  const channel = { xChannel, yChannel }
194
234
 
195
- const x = value.getChannelValuePercent(xChannel)
196
- const y = 1 - value.getChannelValuePercent(yChannel)
235
+ const xPercent = areaValue.getChannelValuePercent(xChannel)
236
+ const yPercent = 1 - areaValue.getChannelValuePercent(yChannel)
237
+
238
+ const xValue = areaValue.getChannelValue(xChannel)
239
+ const yValue = areaValue.getChannelValue(yChannel)
197
240
 
198
241
  return normalize.element({
199
242
  ...parts.areaThumb.attrs,
@@ -201,17 +244,24 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
201
244
  dir: state.context.dir,
202
245
  tabIndex: isDisabled ? undefined : 0,
203
246
  "data-disabled": dataAttr(isDisabled),
204
- role: "presentation",
247
+ role: "slider",
248
+ "aria-valuemin": 0,
249
+ "aria-valuemax": 100,
250
+ "aria-valuenow": xValue,
251
+ "aria-label": `${xChannel} and ${yChannel}`,
252
+ "aria-roledescription": "2d slider",
253
+ "aria-valuetext": `${xChannel} ${xValue}, ${yChannel} ${yValue}`,
205
254
  style: {
206
255
  position: "absolute",
207
- left: `${x * 100}%`,
208
- top: `${y * 100}%`,
256
+ left: `${xPercent * 100}%`,
257
+ top: `${yPercent * 100}%`,
209
258
  transform: "translate(-50%, -50%)",
210
259
  touchAction: "none",
211
260
  forcedColorAdjust: "none",
212
- background: value.withChannelValue("alpha", 1).toString("css"),
261
+ background: areaValue.withChannelValue("alpha", 1).toString("css"),
213
262
  },
214
263
  onFocus() {
264
+ if (!isInteractive) return
215
265
  send({ type: "AREA.FOCUS", id: "area", channel })
216
266
  },
217
267
  onKeyDown(event) {
@@ -313,7 +363,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
313
363
  orientation,
314
364
  channel,
315
365
  dir: state.context.dir,
316
- value: value,
366
+ value: areaValue,
317
367
  }),
318
368
  },
319
369
  })
@@ -321,8 +371,8 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
321
371
 
322
372
  getChannelSliderThumbProps(props) {
323
373
  const { orientation = "horizontal", channel } = props
324
- const { minValue, maxValue, step: stepValue } = value.getChannelRange(channel)
325
- const channelValue = value.getChannelValue(channel)
374
+ const { minValue, maxValue, step: stepValue } = areaValue.getChannelRange(channel)
375
+ const channelValue = areaValue.getChannelValue(channel)
326
376
 
327
377
  const offset = (channelValue - minValue) / (maxValue - minValue)
328
378
 
@@ -345,10 +395,11 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
345
395
  "aria-valuemax": maxValue,
346
396
  "aria-valuemin": minValue,
347
397
  "aria-valuenow": channelValue,
398
+ "aria-valuetext": `${channel} ${channelValue}`,
348
399
  style: {
349
400
  forcedColorAdjust: "none",
350
401
  position: "absolute",
351
- background: getChannelDisplayColor(value, channel).toString("css"),
402
+ background: getChannelDisplayColor(areaValue, channel).toString("css"),
352
403
  ...placementStyles,
353
404
  },
354
405
  onFocus() {
@@ -410,6 +461,8 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
410
461
  type: isTextField ? "text" : "number",
411
462
  "data-channel": channel,
412
463
  "aria-label": channel,
464
+ spellCheck: false,
465
+ autoComplete: "off",
413
466
  disabled: isDisabled,
414
467
  "data-disabled": dataAttr(isDisabled),
415
468
  readOnly: state.context.readOnly,
@@ -418,21 +471,24 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
418
471
  max: range?.maxValue,
419
472
  step: range?.step,
420
473
  onBeforeInput(event) {
421
- if (isTextField) return
474
+ if (isTextField || !isInteractive) return
422
475
  const value = event.currentTarget.value
423
476
  if (value.match(/[^0-9.]/g)) {
424
477
  event.preventDefault()
425
478
  }
426
479
  },
427
480
  onFocus(event) {
481
+ if (!isInteractive) return
428
482
  send({ type: "CHANNEL_INPUT.FOCUS", channel })
429
483
  event.target.select()
430
484
  },
431
485
  onBlur(event) {
486
+ if (!isInteractive) return
432
487
  const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber
433
488
  send({ type: "CHANNEL_INPUT.BLUR", channel, value, isTextField })
434
489
  },
435
490
  onKeyDown(event) {
491
+ if (!isInteractive) return
436
492
  if (event.key === "Enter") {
437
493
  const value = isTextField ? event.currentTarget.value : event.currentTarget.valueAsNumber
438
494
  send({ type: "CHANNEL_INPUT.CHANGE", channel, value, isTextField })
@@ -474,18 +530,22 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
474
530
  role: "group",
475
531
  }),
476
532
 
533
+ getSwatchTriggerState,
534
+
477
535
  getSwatchTriggerProps(props) {
478
- const { value: valueProp } = props
479
- const color = normalizeColor(valueProp).toFormat(value.getFormat())
536
+ const triggerState = getSwatchTriggerState(props)
480
537
  return normalize.button({
481
538
  ...parts.swatchTrigger.attrs,
482
- disabled: isDisabled,
539
+ disabled: triggerState.isDisabled,
483
540
  dir: state.context.dir,
484
541
  type: "button",
485
- "data-value": color.toString("hex"),
542
+ "aria-label": `select ${triggerState.valueAsString} as the color`,
543
+ "data-state": triggerState.isChecked ? "checked" : "unchecked",
544
+ "data-value": triggerState.valueAsString,
545
+ "data-disabled": dataAttr(triggerState.isDisabled),
486
546
  onClick() {
487
- if (!isInteractive) return
488
- send({ type: "VALUE.SET", value: color })
547
+ if (triggerState.isDisabled) return
548
+ send({ type: "SWATCH_TRIGGER.CLICK", value: triggerState.value })
489
549
  },
490
550
  style: {
491
551
  position: "relative",
@@ -493,20 +553,66 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
493
553
  })
494
554
  },
495
555
 
556
+ getSwatchIndicatorProps(props) {
557
+ const triggerState = getSwatchTriggerState(props)
558
+ return normalize.element({
559
+ ...parts.swatchIndicator.attrs,
560
+ dir: state.context.dir,
561
+ "aria-hidden": true,
562
+ "data-state": triggerState.isChecked ? "open" : "closed",
563
+ })
564
+ },
565
+
496
566
  getSwatchProps(props) {
497
- const { value: valueProp, respectAlpha = true } = props
498
- const colorValue = normalizeColor(valueProp)
499
- const color = colorValue.toFormat(value.getFormat())
567
+ const { respectAlpha = true } = props
568
+ const triggerState = getSwatchTriggerState(props)
500
569
  return normalize.element({
501
570
  ...parts.swatch.attrs,
502
571
  dir: state.context.dir,
503
- "data-state": colorValue.isEqual(value) ? "selected" : "unselected",
504
- "data-value": color.toString("hex"),
572
+ "data-state": triggerState.isChecked ? "checked" : "unchecked",
573
+ "data-value": triggerState.valueAsString,
505
574
  style: {
506
575
  position: "relative",
507
- background: color.toString(respectAlpha ? "css" : "hex"),
576
+ background: triggerState.value.toString(respectAlpha ? "css" : "hex"),
508
577
  },
509
578
  })
510
579
  },
580
+
581
+ formatTriggerProps: normalize.button({
582
+ ...parts.formatTrigger.attrs,
583
+ dir: state.context.dir,
584
+ type: "button",
585
+ "aria-label": `change color format to ${getNextFormat(state.context.format)}`,
586
+ onClick(event) {
587
+ if (event.currentTarget.disabled) return
588
+ const nextFormat = getNextFormat(state.context.format)
589
+ send({ type: "FORMAT.SET", format: nextFormat, src: "format-trigger" })
590
+ },
591
+ }),
592
+
593
+ formatSelectProps: normalize.select({
594
+ ...parts.formatSelect.attrs,
595
+ "aria-label": "change color format",
596
+ dir: state.context.dir,
597
+ defaultValue: state.context.format,
598
+ disabled: isDisabled,
599
+ onChange(event) {
600
+ const format = assertFormat(event.currentTarget.value)
601
+ send({ type: "FORMAT.SET", format, src: "format-select" })
602
+ },
603
+ }),
511
604
  }
512
605
  }
606
+
607
+ const formats: ColorFormat[] = ["hsba", "hsla", "rgba"]
608
+ const formatRegex = new RegExp(`^(${formats.join("|")})$`)
609
+
610
+ function getNextFormat(format: ColorFormat) {
611
+ const index = formats.indexOf(format)
612
+ return formats[index + 1] ?? formats[0]
613
+ }
614
+
615
+ function assertFormat(format: string) {
616
+ if (formatRegex.test(format)) return format as ColorFormat
617
+ throw new Error(`Unsupported color format: ${format}`)
618
+ }
@@ -13,6 +13,7 @@ export const dom = createScope({
13
13
  getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `color-picker:${ctx.id}:trigger`,
14
14
  getContentId: (ctx: Ctx) => ctx.ids?.content ?? `color-picker:${ctx.id}:content`,
15
15
  getPositionerId: (ctx: Ctx) => `color-picker:${ctx.id}:positioner`,
16
+ getFormatSelectId: (ctx: Ctx) => `color-picker:${ctx.id}:format-select`,
16
17
 
17
18
  getAreaId: (ctx: Ctx) => ctx.ids?.area ?? `color-picker:${ctx.id}:area`,
18
19
  getAreaGradientId: (ctx: Ctx) => ctx.ids?.areaGradient ?? `color-picker:${ctx.id}:area-gradient`,
@@ -33,6 +34,7 @@ export const dom = createScope({
33
34
  ...queryAll<HTMLInputElement>(dom.getControlEl(ctx), `input[data-channel="${channel}"]`),
34
35
  ]
35
36
  },
37
+ getFormatSelectEl: (ctx: Ctx) => dom.getById<HTMLSelectElement>(ctx, dom.getFormatSelectId(ctx)),
36
38
 
37
39
  getHiddenInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getHiddenInputId(ctx)),
38
40
  getAreaEl: (ctx: Ctx) => dom.getById(ctx, dom.getAreaId(ctx)),
@@ -64,8 +66,7 @@ export const dom = createScope({
64
66
  getFirstFocusableEl: (ctx: Ctx) => getFirstFocusable(dom.getContentEl(ctx), "if-empty"),
65
67
  getInitialFocusEl: (ctx: Ctx): HTMLElement | undefined => {
66
68
  let el: any = runIfFn(ctx.initialFocusEl)
67
- if (!el && ctx.autoFocus) el = dom.getFirstFocusableEl(ctx)
68
- if (!el) el = dom.getContentEl(ctx)
69
+ el = dom.getFirstFocusableEl(ctx) ?? dom.getContentEl(ctx)
69
70
  return el
70
71
  },
71
72
  })
@@ -10,6 +10,7 @@ import { compact, tryCatch } from "@zag-js/utils"
10
10
  import { dom } from "./color-picker.dom"
11
11
  import { parse } from "./color-picker.parse"
12
12
  import type {
13
+ ColorFormat,
13
14
  ColorType,
14
15
  ExtendedColorChannel,
15
16
  MachineContext,
@@ -27,13 +28,14 @@ export function machine(userContext: UserDefinedContext) {
27
28
  context: {
28
29
  dir: "ltr",
29
30
  value: parse("#000000"),
31
+ format: "rgba",
30
32
  disabled: false,
33
+ closeOnSelect: false,
31
34
  ...ctx,
32
35
  activeId: null,
33
36
  activeChannel: null,
34
37
  activeOrientation: null,
35
38
  fieldsetDisabled: false,
36
- autoFocus: true,
37
39
  positioning: {
38
40
  ...ctx.positioning,
39
41
  placement: "bottom",
@@ -44,9 +46,10 @@ export function machine(userContext: UserDefinedContext) {
44
46
  isRtl: (ctx) => ctx.dir === "rtl",
45
47
  isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled,
46
48
  isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly),
47
- valueAsString: (ctx) => {
48
- const color = ctx.value.outputFormat ? ctx.value.toFormat(ctx.value.outputFormat) : ctx.value
49
- return color.toString("css")
49
+ valueAsString: (ctx) => ctx.value.toString(ctx.format),
50
+ areaValue: (ctx) => {
51
+ const format = ctx.format.startsWith("hsl") ? "hsla" : "hsba"
52
+ return ctx.value.toFormat(format)
50
53
  },
51
54
  },
52
55
 
@@ -54,6 +57,7 @@ export function machine(userContext: UserDefinedContext) {
54
57
 
55
58
  watch: {
56
59
  value: ["syncInputElements"],
60
+ format: ["syncFormatSelectElement"],
57
61
  open: ["toggleVisibility"],
58
62
  },
59
63
 
@@ -61,9 +65,15 @@ export function machine(userContext: UserDefinedContext) {
61
65
  "VALUE.SET": {
62
66
  actions: ["setValue"],
63
67
  },
68
+ "FORMAT.SET": {
69
+ actions: ["setFormat"],
70
+ },
64
71
  "CHANNEL_INPUT.CHANGE": {
65
72
  actions: ["setChannelColorFromInput"],
66
73
  },
74
+ "EYEDROPPER.CLICK": {
75
+ actions: ["openEyeDropper"],
76
+ },
67
77
  },
68
78
 
69
79
  states: {
@@ -103,6 +113,9 @@ export function machine(userContext: UserDefinedContext) {
103
113
  target: "idle",
104
114
  actions: ["setChannelColorFromInput"],
105
115
  },
116
+ "TRIGGER.BLUR": {
117
+ target: "idle",
118
+ },
106
119
  },
107
120
  },
108
121
 
@@ -114,9 +127,6 @@ export function machine(userContext: UserDefinedContext) {
114
127
  target: "idle",
115
128
  actions: ["invokeOnClose"],
116
129
  },
117
- "EYEDROPPER.CLICK": {
118
- actions: ["openEyeDropper"],
119
- },
120
130
  "AREA.POINTER_DOWN": {
121
131
  target: "open:dragging",
122
132
  actions: ["setActiveChannel", "setAreaColorFromPoint", "focusAreaThumb"],
@@ -191,6 +201,16 @@ export function machine(userContext: UserDefinedContext) {
191
201
  target: "idle",
192
202
  actions: ["invokeOnClose"],
193
203
  },
204
+ "SWATCH_TRIGGER.CLICK": [
205
+ {
206
+ guard: "closeOnSelect",
207
+ target: "focused",
208
+ actions: ["setValue", "setReturnFocus", "invokeOnClose"],
209
+ },
210
+ {
211
+ actions: ["setValue"],
212
+ },
213
+ ],
194
214
  },
195
215
  },
196
216
 
@@ -235,6 +255,7 @@ export function machine(userContext: UserDefinedContext) {
235
255
  {
236
256
  guards: {
237
257
  isTargetFocusable: (_ctx, evt) => evt.restoreFocus,
258
+ closeOnSelect: (ctx) => !!ctx.closeOnSelect,
238
259
  },
239
260
  activities: {
240
261
  trackPositioning(ctx) {
@@ -329,10 +350,10 @@ export function machine(userContext: UserDefinedContext) {
329
350
  const percent = dom.getAreaValueFromPoint(ctx, evt.point)
330
351
  if (!percent) return
331
352
 
332
- const xValue = ctx.value.getChannelPercentValue(xChannel, percent.x)
333
- const yValue = ctx.value.getChannelPercentValue(yChannel, 1 - percent.y)
353
+ const xValue = ctx.areaValue.getChannelPercentValue(xChannel, percent.x)
354
+ const yValue = ctx.areaValue.getChannelPercentValue(yChannel, 1 - percent.y)
334
355
 
335
- const color = ctx.value.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue)
356
+ const color = ctx.areaValue.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue)
336
357
  set.value(ctx, color)
337
358
  },
338
359
  setChannelColorFromPoint(ctx, evt) {
@@ -344,13 +365,16 @@ export function machine(userContext: UserDefinedContext) {
344
365
  const orientation = ctx.activeOrientation || "horizontal"
345
366
  const channelPercent = orientation === "horizontal" ? percent.x : percent.y
346
367
 
347
- const value = ctx.value.getChannelPercentValue(channel, channelPercent)
348
- const color = ctx.value.withChannelValue(channel, value)
368
+ const value = ctx.areaValue.getChannelPercentValue(channel, channelPercent)
369
+ const color = ctx.areaValue.withChannelValue(channel, value)
349
370
  set.value(ctx, color)
350
371
  },
351
372
  setValue(ctx, evt) {
352
373
  set.value(ctx, evt.value)
353
374
  },
375
+ setFormat(ctx, evt) {
376
+ set.format(ctx, evt.format)
377
+ },
354
378
  syncInputElements(ctx) {
355
379
  sync.inputs(ctx)
356
380
  },
@@ -361,34 +385,35 @@ export function machine(userContext: UserDefinedContext) {
361
385
  const { channel, isTextField, value } = evt
362
386
  const currentAlpha = ctx.value.getChannelValue("alpha")
363
387
 
388
+ // handle other text channels
389
+ let color: Color
390
+
364
391
  // handle alpha channel
365
392
  if (channel === "alpha") {
393
+ //
366
394
  let valueAsNumber = parseFloat(value)
367
395
  valueAsNumber = Number.isNaN(valueAsNumber) ? currentAlpha : valueAsNumber
368
- const newColor = ctx.value.withChannelValue("alpha", valueAsNumber)
369
- set.value(ctx, newColor)
370
- return
371
- }
372
-
373
- // handle other text channels
374
- if (isTextField) {
375
- const color = tryCatch(
396
+ color = ctx.value.withChannelValue("alpha", valueAsNumber)
397
+ //
398
+ } else if (isTextField) {
399
+ //
400
+ color = tryCatch(
376
401
  () => parse(value).withChannelValue("alpha", currentAlpha),
377
402
  () => ctx.value,
378
403
  )
379
-
380
- // set channel input value immediately (in event user types native css color, we need to convert it to the current channel format)
381
- const inputEls = dom.getChannelInputEl(ctx, channel)
382
- inputEls.forEach((inputEl) => {
383
- dom.setValue(inputEl, getChannelValue(color, channel))
384
- })
385
-
386
- set.value(ctx, color)
387
- return
404
+ //
405
+ } else {
406
+ //
407
+ const current = ctx.value.toFormat(ctx.format)
408
+ const valueAsNumber = Number.isNaN(value) ? current.getChannelValue(channel) : value
409
+ color = current.withChannelValue(channel, valueAsNumber)
410
+ //
388
411
  }
389
412
 
390
- // handle other channels
391
- const color = ctx.value.toFormat(ctx.value.outputFormat!).withChannelValue(channel, value)
413
+ // sync channel input value immediately (in event user types native css color, we need to convert it to the current channel format)
414
+ sync.inputs(ctx, color)
415
+
416
+ // set new color
392
417
  set.value(ctx, color)
393
418
  },
394
419
  incrementChannel(ctx, evt) {
@@ -401,22 +426,22 @@ export function machine(userContext: UserDefinedContext) {
401
426
  },
402
427
  incrementXChannel(ctx, evt) {
403
428
  const { xChannel } = evt.channel
404
- const color = ctx.value.incrementChannel(xChannel, evt.step)
429
+ const color = ctx.areaValue.incrementChannel(xChannel, evt.step)
405
430
  set.value(ctx, color)
406
431
  },
407
432
  decrementXChannel(ctx, evt) {
408
433
  const { xChannel } = evt.channel
409
- const color = ctx.value.decrementChannel(xChannel, evt.step)
434
+ const color = ctx.areaValue.decrementChannel(xChannel, evt.step)
410
435
  set.value(ctx, color)
411
436
  },
412
437
  incrementYChannel(ctx, evt) {
413
438
  const { yChannel } = evt.channel
414
- const color = ctx.value.incrementChannel(yChannel, evt.step)
439
+ const color = ctx.areaValue.incrementChannel(yChannel, evt.step)
415
440
  set.value(ctx, color)
416
441
  },
417
442
  decrementYChannel(ctx, evt) {
418
443
  const { yChannel } = evt.channel
419
- const color = ctx.value.decrementChannel(yChannel, evt.step)
444
+ const color = ctx.areaValue.decrementChannel(yChannel, evt.step)
420
445
  set.value(ctx, color)
421
446
  },
422
447
  setChannelToMax(ctx, evt) {
@@ -449,6 +474,9 @@ export function machine(userContext: UserDefinedContext) {
449
474
  dom?.focus(ctx, dom.getTriggerEl(ctx))
450
475
  })
451
476
  },
477
+ syncFormatSelectElement(ctx) {
478
+ sync.formatSelect(ctx)
479
+ },
452
480
  invokeOnOpen(ctx) {
453
481
  ctx.onOpenChange?.({ open: true })
454
482
  },
@@ -467,43 +495,56 @@ export function machine(userContext: UserDefinedContext) {
467
495
  }
468
496
 
469
497
  const sync = {
470
- inputs(ctx: MachineContext) {
471
- // sync channel inputs
498
+ // sync channel inputs
499
+ inputs(ctx: MachineContext, color?: Color) {
472
500
  const channelInputs = dom.getChannelInputEls(ctx)
473
- channelInputs.forEach((inputEl) => {
474
- const channel = inputEl.dataset.channel as ExtendedColorChannel | null
475
- dom.setValue(inputEl, getChannelValue(ctx.value, channel))
501
+ raf(() => {
502
+ channelInputs.forEach((inputEl) => {
503
+ const channel = inputEl.dataset.channel as ExtendedColorChannel | null
504
+ dom.setValue(inputEl, getChannelValue(color || ctx.value, channel))
505
+ })
506
+ })
507
+ },
508
+ // sync format select
509
+ formatSelect(ctx: MachineContext) {
510
+ const selectEl = dom.getFormatSelectEl(ctx)
511
+ raf(() => {
512
+ dom.setValue(selectEl, ctx.format)
476
513
  })
477
514
  },
478
515
  }
479
516
 
480
517
  const invoke = {
481
518
  changeEnd(ctx: MachineContext) {
519
+ const value = ctx.value.toFormat(ctx.format)
482
520
  ctx.onValueChangeEnd?.({
483
- value: ctx.value,
521
+ value,
484
522
  valueAsString: ctx.valueAsString,
485
523
  })
486
524
  },
487
525
  change(ctx: MachineContext) {
526
+ const value = ctx.value.toFormat(ctx.format)
488
527
  ctx.onValueChange?.({
489
- value: ctx.value,
528
+ value,
490
529
  valueAsString: ctx.valueAsString,
491
530
  })
492
531
 
493
532
  dispatchInputValueEvent(dom.getHiddenInputEl(ctx), { value: ctx.valueAsString })
494
533
  },
534
+ formatChange(ctx: MachineContext) {
535
+ ctx.onFormatChange?.({ format: ctx.format })
536
+ },
495
537
  }
496
538
 
497
539
  const set = {
498
540
  value(ctx: MachineContext, color: Color | ColorType | undefined) {
499
541
  if (!color || ctx.value.isEqual(color)) return
500
- const currentFormat = ctx.value.getFormat()
501
- const outputColor = color.toFormat(currentFormat)
502
- try {
503
- outputColor.outputFormat = ctx.value.outputFormat
504
- } catch {}
505
-
506
- ctx.value = outputColor
542
+ ctx.value = color
507
543
  invoke.change(ctx)
508
544
  },
545
+ format(ctx: MachineContext, format: ColorFormat) {
546
+ if (ctx.format === format) return
547
+ ctx.format = format
548
+ invoke.formatChange(ctx)
549
+ },
509
550
  }