@nexxtmove/ui 2.2.0 → 2.3.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.
Files changed (34) hide show
  1. package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
  2. package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
  3. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
  4. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
  5. package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
  6. package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
  7. package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
  8. package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
  9. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
  10. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
  11. package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
  12. package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
  13. package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
  14. package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
  15. package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
  16. package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
  17. package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +3392 -2082
  20. package/package.json +1 -1
  21. package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
  22. package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
  23. package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
  24. package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
  25. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
  26. package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
  27. package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
  28. package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
  29. package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
  30. package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
  31. package/src/components/organisms/ColorPicker/gradient.ts +303 -0
  32. package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
  33. package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
  34. package/src/index.ts +25 -0
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" setup>
2
- import { ref, watch, computed, useId } from 'vue'
2
+ import { ref, watch, computed, useId, h, nextTick, shallowRef, Teleport } from 'vue'
3
+ import type { FunctionalComponent } from 'vue'
3
4
  import type { Color, ColorChannel } from 'reka-ui'
4
5
  import {
5
6
  ColorAreaArea,
@@ -17,6 +18,36 @@ import {
17
18
  } from 'reka-ui'
18
19
  import NexxtFloatingPanel from '../../atoms/FloatingPanel/FloatingPanel.vue'
19
20
  import NexxtIcon from '../../atoms/Icon/Icon.vue'
21
+ import NexxtToggleGroup from '../../molecules/ToggleGroup/ToggleGroup.vue'
22
+ import NexxtGradientPreview from './_GradientPreview.vue'
23
+ import NexxtGradientStopRow from './_GradientStopRow.vue'
24
+ import NexxtGradientStops from './_GradientStops.vue'
25
+ import {
26
+ CHECKERBOARD,
27
+ DEFAULT_GRADIENT_MESSAGES,
28
+ fillMessage,
29
+ gradientCss,
30
+ normalizeHex,
31
+ } from './gradient'
32
+ import type {
33
+ ColorPickerGradient,
34
+ ColorPickerGradientMessages,
35
+ ColorPickerPreset,
36
+ } from './gradient'
37
+ import { useGradientEditor } from './useGradientEditor'
38
+
39
+ export type {
40
+ ColorPickerGradient,
41
+ ColorPickerGradientMessages,
42
+ ColorPickerGradientStop,
43
+ ColorPickerPreset,
44
+ } from './gradient'
45
+
46
+ /**
47
+ * A fill the picker can edit: `'solid'`, one colour, or `'gradient'`, a linear
48
+ * or radial gradient of two or three stops.
49
+ */
50
+ export type ColorPickerFillMode = 'solid' | 'gradient'
20
51
 
21
52
  export type ColorFormat = 'hex' | 'hsl' | 'rgb'
22
53
  export type ColorSuggestions = Partial<Record<ColorFormat, string[]>>
@@ -47,18 +78,86 @@ export interface NexxtColorPickerProps {
47
78
  * its own, such as a theme role. Only shown while a colour is set.
48
79
  */
49
80
  valueLabel?: string
81
+ /**
82
+ * Id(s) of an element that describes the field, set as `aria-describedby` on
83
+ * the trigger. Without it no attribute is rendered.
84
+ */
85
+ describedby?: string
86
+ /**
87
+ * The fills the picker offers. `['solid']` (the default) is the plain colour
88
+ * picker, exactly as without this prop. `['solid', 'gradient']`, in any
89
+ * order, adds an "Effen | Verloop" switch at the top of the panel;
90
+ * `['gradient']` offers only the gradient, without a switch. The gradient is
91
+ * edited through the `gradientValue` model; `mode` tells which fill is in
92
+ * use, and stays on the one fill when only one is offered.
93
+ */
94
+ modes?: ColorPickerFillMode[]
95
+ /**
96
+ * The gradient the gradient mode shows while `gradientValue` is unset. It is
97
+ * not written until the first change. Defaults to a fade from `#00000000` to
98
+ * `#00000099`.
99
+ */
100
+ gradientStart?: ColorPickerGradient
101
+ /** Copy of the gradient mode, merged per key over the Dutch defaults. */
102
+ gradientMessages?: ColorPickerGradientMessages
50
103
  }
51
104
 
52
105
  defineOptions({ name: 'NexxtColorPicker' })
53
106
 
54
107
  defineSlots<{
55
- /** Named colours a wrapper offers next to the free picker; rendered above the suggestions. */
56
- presets?(): unknown
108
+ /**
109
+ * Named colours a wrapper offers next to the free picker; rendered above the
110
+ * suggestions. In the gradient mode a preset is applied to the selected stop
111
+ * through `selectPreset`, and `preset` is the key of the selected stop's
112
+ * preset, if any.
113
+ */
114
+ presets?(props: {
115
+ preset: string | undefined
116
+ selectPreset: (preset: ColorPickerPreset, hex: string) => void
117
+ }): unknown
57
118
  }>()
58
119
 
120
+ const emit = defineEmits<{
121
+ /** The panel closed. */
122
+ close: []
123
+ }>()
124
+
125
+ /**
126
+ * Renders its slot inside `tag`, or the slot alone without one. Without a tag
127
+ * no element and no comment is added, so the solid markup stays the same (only
128
+ * the hex field gained `aria-label="Hex"`).
129
+ */
130
+ const Wrap: FunctionalComponent<{ tag?: string }> = (props, { slots, attrs }) =>
131
+ props.tag ? h(props.tag, attrs, slots.default?.()) : (slots.default?.() ?? [])
132
+ Wrap.props = ['tag']
133
+ Wrap.inheritAttrs = false
134
+
135
+ // The gradient mode adds its own blocks, and moves the colour editor into a
136
+ // popover per stop. Without the gradient in `modes` these wrappers render nothing of their
137
+ // own -- no element, no comment -- so the solid markup stays the same, apart
138
+ // from `aria-label="Hex"` on the hex field.
139
+ // Template comments are avoided in those spots for the same reason: dev builds
140
+ // keep them.
141
+
142
+ /** Renders its slot only `when` it is set; otherwise nothing at all, not even a comment. */
143
+ const Gate: FunctionalComponent<{ when: boolean }> = (props, { slots }) =>
144
+ props.when ? (slots.default?.() ?? []) : []
145
+ Gate.props = ['when']
146
+
147
+ /**
148
+ * Renders its single slot child in place, or teleported into `to`. In place it
149
+ * returns the child itself, not a disabled Teleport, which would leave anchors
150
+ * in the solid markup. The gradient mode teleports the colour editor into the
151
+ * popover of the stop that is being edited.
152
+ */
153
+ const MaybeTeleport: FunctionalComponent<{ to?: HTMLElement | null }> = (props, { slots }) =>
154
+ props.to ? h(Teleport, { to: props.to }, slots.default?.() ?? []) : slots.default?.()[0]
155
+ MaybeTeleport.props = ['to']
156
+
59
157
  // `useId` is the same on the server and in the browser; a random id would not hydrate.
60
158
  const PICKER_ID = useId()
61
159
  const ALPHA_LABEL_ID = useId()
160
+ const ROLE_HINT_ID = useId()
62
161
 
63
162
  /**
64
163
  * reka reports the alpha channel as `alpha * 100`, which is a fraction for most
@@ -76,10 +175,132 @@ const props = withDefaults(defineProps<NexxtColorPickerProps>(), {
76
175
  suggestions: () => ({}),
77
176
  suggestionsLabel: 'Gebruikte kleuren',
78
177
  alphaLabel: 'Dekking',
178
+ modes: () => ['solid'],
79
179
  })
80
180
 
81
181
  const model = defineModel<string>({ default: '' })
82
182
 
183
+ /** Which fill is edited. Only used when `modes` offers both; otherwise it is ignored. */
184
+ const mode = defineModel<ColorPickerFillMode>('mode', { default: 'solid' })
185
+
186
+ /**
187
+ * The gradient, with its stops in order of position. Unset while there is no
188
+ * readable gradient: the gradient mode then shows a default and writes nothing
189
+ * until the first change.
190
+ */
191
+ const gradientValue = defineModel<ColorPickerGradient | undefined>('gradientValue', {
192
+ default: undefined,
193
+ })
194
+
195
+ /** The selected stop, as its index in order of position. */
196
+ const selectedStop = defineModel<number>('selectedStop', { default: 0 })
197
+
198
+ /** The gradient is offered at all. */
199
+ const gradient = computed(() => props.modes.includes('gradient'))
200
+
201
+ /** Both fills are offered, so the panel starts with the switch between them. */
202
+ const bothModes = computed(() => gradient.value && props.modes.includes('solid'))
203
+
204
+ const gradientMode = computed(
205
+ () => gradient.value && (!bothModes.value || mode.value === 'gradient'),
206
+ )
207
+
208
+ // With one fill offered, `mode` is that fill. A bound value that says otherwise
209
+ // is corrected once -- the only time this emits -- so a consumer reads the fill
210
+ // that is actually shown. Matching values (the default `'solid'` with
211
+ // `['solid']`) emit nothing.
212
+ watch(
213
+ () => [props.modes, mode.value] as const,
214
+ () => {
215
+ if (bothModes.value) return
216
+ const only: ColorPickerFillMode = gradient.value ? 'gradient' : 'solid'
217
+ if (mode.value !== only) mode.value = only
218
+ },
219
+ { immediate: true },
220
+ )
221
+ const gradientCopy = computed(() => ({ ...DEFAULT_GRADIENT_MESSAGES, ...props.gradientMessages }))
222
+
223
+ const editor = useGradientEditor({
224
+ value: gradientValue,
225
+ selectedIndex: selectedStop,
226
+ start: () => props.gradientStart,
227
+ })
228
+
229
+ /** The rows of the stop list, by stop id, to move the focus into after add and remove. */
230
+ const rows = new Map<number, { focusPosition: () => void }>()
231
+ const rowRef = (id: number) => (instance: unknown) => {
232
+ if (instance && typeof instance === 'object' && 'focusPosition' in instance) {
233
+ rows.set(id, instance as { focusPosition: () => void })
234
+ } else rows.delete(id)
235
+ }
236
+
237
+ /** The popover of a stop that the colour editor is teleported into. */
238
+ const editorTarget = shallowRef<HTMLElement | null>(null)
239
+ const onTarget = (element: HTMLElement | null) => {
240
+ // A closing popover is not let go of: its content would vanish while the
241
+ // popover still fades out, leaving an empty box. A new popover replaces it,
242
+ // and the outer panel closing (or leaving the gradient mode) clears it.
243
+ if (element) editorTarget.value = element
244
+ }
245
+ watch(gradientMode, () => {
246
+ editorTarget.value = null
247
+ })
248
+
249
+ const focusRow = async (id: number | null) => {
250
+ if (id === null) return
251
+ await nextTick()
252
+ rows.get(id)?.focusPosition()
253
+ }
254
+
255
+ /** Toevoegen in the heading: a stop halfway, and the focus in its position field. */
256
+ const addStop = () => focusRow(editor.addStop())
257
+
258
+ /** A removed stop hands the focus to the position field of its neighbour. */
259
+ const removeStop = (id: number) => focusRow(editor.removeStop(id))
260
+
261
+ const typeOptions = computed(() => [
262
+ { value: 'linear', label: gradientCopy.value.linear },
263
+ { value: 'radial', label: gradientCopy.value.radial },
264
+ ])
265
+
266
+ const typeModel = computed({
267
+ get: () => editor.type.value,
268
+ set: (value: string) => editor.setType(value === 'radial' ? 'radial' : 'linear'),
269
+ })
270
+
271
+ const modeOptions = computed(() => [
272
+ { value: 'solid', label: gradientCopy.value.solid },
273
+ { value: 'gradient', label: gradientCopy.value.gradient },
274
+ ])
275
+
276
+ const modeModel = computed({
277
+ get: () => mode.value,
278
+ set: (value: string) => {
279
+ mode.value = value === 'gradient' ? 'gradient' : 'solid'
280
+ },
281
+ })
282
+
283
+ /** The opacity slider is always there in the gradient mode. */
284
+ const alphaOn = computed(() => props.showAlpha || gradientMode.value)
285
+
286
+ /** A stop bound to a preset is always opaque: its slider stays, at 100 and disabled. */
287
+ const alphaLocked = computed(() => gradientMode.value && !!editor.selected.value?.preset)
288
+
289
+ const panelClass = computed(
290
+ () =>
291
+ `flex flex-col ${gradientMode.value ? 'gap-3' : 'gap-4'} rounded-xl bg-white p-4 ring ring-gray-200`,
292
+ )
293
+
294
+ const selectPreset = (preset: ColorPickerPreset, hex: string) => {
295
+ editor.setStopColor(hex.slice(0, 7), preset)
296
+ }
297
+
298
+ const onPanelClose = () => {
299
+ editorTarget.value = null
300
+ editor.resetSession()
301
+ emit('close')
302
+ }
303
+
83
304
  const withoutAlpha = (color: Color) =>
84
305
  color.alpha < 1 ? setChannelValue(color, 'alpha', 100) : color
85
306
 
@@ -92,7 +313,7 @@ const normalizeModelColor = (value: string) => {
92
313
  const normalizeInputColor = (value: string) => {
93
314
  const color = normalizeColor(value)
94
315
 
95
- if (!props.showAlpha && color.alpha < 1) {
316
+ if (!alphaOn.value && color.alpha < 1) {
96
317
  throw new Error('Transparantie is niet beschikbaar')
97
318
  }
98
319
 
@@ -111,13 +332,72 @@ const hexInputError = ref('')
111
332
  const channelInputErrors = ref<Partial<Record<ColorChannel, string>>>({})
112
333
 
113
334
  const hexColor = computed(() => colorToString(colorObj.value, 'hex'))
114
- const displayValue = computed(() =>
115
- model.value ? props.valueLabel || fmt(colorObj.value, activeFormat.value) : '',
335
+ const gradientLabel = computed(() => {
336
+ if (!gradientValue.value) return ''
337
+ return gradientValue.value.type === 'linear'
338
+ ? gradientCopy.value.triggerLinear
339
+ : gradientCopy.value.triggerRadial
340
+ })
341
+ /**
342
+ * The solid colour in the model. In the gradient mode the editor follows the
343
+ * selected stop, so the trigger reads the model itself.
344
+ */
345
+ const modelColor = computed(() => {
346
+ if (!model.value) return null
347
+ try {
348
+ return normalizeModelColor(model.value)
349
+ } catch {
350
+ return null
351
+ }
352
+ })
353
+
354
+ /**
355
+ * The trigger shows a gradient once there is one. Before that -- only the
356
+ * gradient offered, and a solid colour stored -- it shows that colour, as the
357
+ * solid trigger would; an empty model shows the placeholder.
358
+ */
359
+ const gradientTrigger = computed(
360
+ () => gradientMode.value && (!!gradientValue.value || !modelColor.value),
116
361
  )
362
+ const triggerHex = computed(() =>
363
+ gradientMode.value && modelColor.value ? colorToString(modelColor.value, 'hex') : hexColor.value,
364
+ )
365
+ const displayValue = computed(() => {
366
+ if (gradientTrigger.value) return gradientLabel.value
367
+ if (gradientMode.value && modelColor.value) {
368
+ return props.valueLabel || fmt(modelColor.value, activeFormat.value)
369
+ }
370
+ return model.value ? props.valueLabel || fmt(colorObj.value, activeFormat.value) : ''
371
+ })
372
+ const gradientSwatch = computed(() => {
373
+ const value = gradientValue.value
374
+ const label = value
375
+ ? fillMessage(
376
+ value.type === 'linear' ? gradientCopy.value.swatchLinear : gradientCopy.value.swatchRadial,
377
+ { n: value.stops.length },
378
+ )
379
+ : gradientCopy.value.swatchEmpty
380
+ // Without a readable gradient the swatch stays empty, like the placeholder next to it.
381
+ return {
382
+ label,
383
+ style: value
384
+ ? {
385
+ backgroundImage: `${gradientCss(value)}, ${CHECKERBOARD}`,
386
+ backgroundSize: 'auto, 8px 8px',
387
+ }
388
+ : {},
389
+ }
390
+ })
117
391
  const colorSuggestions = computed(() => {
118
392
  const values = new Set<string>()
119
393
 
120
- props.suggestions[activeFormat.value]?.forEach((suggestion) => {
394
+ // In the gradient mode the neighbouring stop comes first, so a one-colour fade is two clicks.
395
+ const neighbour = gradientMode.value ? editor.neighbourColor.value : undefined
396
+ const offered = neighbour
397
+ ? [neighbour, ...(props.suggestions[activeFormat.value] ?? [])]
398
+ : props.suggestions[activeFormat.value]
399
+
400
+ offered?.forEach((suggestion) => {
121
401
  try {
122
402
  const color = normalizeInputColor(suggestion)
123
403
  values.add(fmt(color, activeFormat.value))
@@ -129,28 +409,34 @@ const colorSuggestions = computed(() => {
129
409
  return [...values]
130
410
  })
131
411
 
412
+ /**
413
+ * Applies a colour from the editor: to the model, or in the gradient mode to the
414
+ * selected stop, which also unbinds it from a preset.
415
+ */
416
+ const commitColor = (color: Color) => {
417
+ colorObj.value = color
418
+ if (gradientMode.value) editor.setStopColor(colorToString(color, 'hex'))
419
+ else model.value = fmt(color, activeFormat.value)
420
+ }
421
+
132
422
  const selectSuggestion = (suggestion: string) => {
133
- colorObj.value = normalizeInputColor(suggestion)
134
- model.value = fmt(colorObj.value, activeFormat.value)
423
+ commitColor(normalizeInputColor(suggestion))
135
424
  }
136
425
 
137
426
  const cycleFormat = (direction: 1 | -1) => {
138
427
  activeFormatIndex.value =
139
428
  (activeFormatIndex.value + direction + props.formats.length) % props.formats.length
140
- model.value = fmt(colorObj.value, activeFormat.value)
429
+ if (!gradientMode.value) model.value = fmt(colorObj.value, activeFormat.value)
141
430
  }
142
431
 
143
432
  const handleColorUpdate = (newColor: Color) => {
144
- const updated = props.showAlpha ? newColor : withoutAlpha(newColor)
145
- colorObj.value = updated
146
- model.value = fmt(updated, activeFormat.value)
433
+ commitColor(alphaOn.value ? newColor : withoutAlpha(newColor))
147
434
  }
148
435
 
149
436
  const handleHexInput = (event: Event) => {
150
437
  const val = (event.target as HTMLInputElement).value
151
438
  try {
152
- colorObj.value = normalizeInputColor(val)
153
- model.value = fmt(colorObj.value, activeFormat.value)
439
+ commitColor(normalizeInputColor(val))
154
440
  hexInputError.value = ''
155
441
  } catch (e) {
156
442
  hexInputError.value = e instanceof Error ? e.message : 'Ongeldige kleur'
@@ -164,9 +450,7 @@ const handleChannelInput = (event: Event, channel: ColorChannel) => {
164
450
  return
165
451
  }
166
452
  try {
167
- const updated = setChannelValue(colorObj.value, channel, val)
168
- colorObj.value = updated
169
- model.value = fmt(updated, activeFormat.value)
453
+ commitColor(setChannelValue(colorObj.value, channel, val))
170
454
  delete channelInputErrors.value[channel]
171
455
  } catch (e) {
172
456
  channelInputErrors.value[channel] = e instanceof Error ? e.message : 'Ongeldige waarde'
@@ -200,6 +484,8 @@ const currentColor = () => {
200
484
  watch(
201
485
  () => props.showAlpha,
202
486
  (showAlpha) => {
487
+ // The gradient mode always has the slider and never writes the solid model.
488
+ if (gradientMode.value) return
203
489
  const current = currentColor()
204
490
  if (!showAlpha && current.alpha < 1) {
205
491
  const updated = withoutAlpha(current)
@@ -208,6 +494,32 @@ watch(
208
494
  }
209
495
  },
210
496
  )
497
+
498
+ // The editor follows the selected stop. Its own changes are already in
499
+ // `colorObj`, which keeps the hue of a grey; only another stop, or a change
500
+ // from outside, replaces it.
501
+ watch(
502
+ () => (gradientMode.value ? editor.selected.value?.color : undefined),
503
+ (color) => {
504
+ if (!color || normalizeHex(colorToString(colorObj.value, 'hex')) === color) return
505
+ try {
506
+ colorObj.value = normalizeColor(color)
507
+ } catch {
508
+ // A stop colour is always a valid hex; nothing to recover from.
509
+ }
510
+ },
511
+ { immediate: true },
512
+ )
513
+
514
+ // Back in the solid mode the editor shows the model again.
515
+ watch(gradientMode, (on) => {
516
+ if (on || !model.value) return
517
+ try {
518
+ colorObj.value = normalizeModelColor(model.value)
519
+ } catch {
520
+ // Ignore invalid color strings
521
+ }
522
+ })
211
523
  </script>
212
524
 
213
525
  <template>
@@ -217,7 +529,7 @@ watch(
217
529
  <span v-if="required" class="text-brick-500">*</span>
218
530
  </label>
219
531
 
220
- <NexxtFloatingPanel placement="bottom-start" :match-width="matchWidth">
532
+ <NexxtFloatingPanel placement="bottom-start" :match-width="matchWidth" @close="onPanelClose">
221
533
  <template #trigger="{ isOpen, toggle }">
222
534
  <button
223
535
  :id="PICKER_ID"
@@ -225,13 +537,22 @@ watch(
225
537
  :aria-expanded="isOpen"
226
538
  :aria-haspopup="true"
227
539
  :aria-label="label ? undefined : placeholder"
540
+ :aria-describedby="describedby"
228
541
  class="group flex h-10 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus:ring-cornflower-blue-500 focus:outline-none"
229
542
  :class="[error ? 'ring-brick-400' : 'ring-gray-200', fullWidth ? 'w-full' : 'min-w-60']"
230
543
  @click="toggle"
231
544
  >
232
545
  <!-- Color swatch preview -->
546
+ <span
547
+ v-if="gradientTrigger"
548
+ role="img"
549
+ :aria-label="gradientSwatch.label"
550
+ class="size-5 shrink-0 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
551
+ :style="gradientSwatch.style"
552
+ />
233
553
  <ColorSwatch
234
- :color="hexColor"
554
+ v-else
555
+ :color="triggerHex"
235
556
  :aria-label="
236
557
  displayValue ? `Geselecteerde kleur: ${displayValue}` : 'Geen kleur geselecteerd'
237
558
  "
@@ -257,269 +578,350 @@ watch(
257
578
  </template>
258
579
 
259
580
  <template #content>
260
- <div
261
- class="flex flex-col gap-4 rounded-xl bg-white p-4 ring ring-gray-200"
262
- :class="matchWidth ? 'w-full' : 'w-70'"
263
- >
264
- <!-- 2D Color Area: saturation (x-axis) / lightness (y-axis) -->
265
- <ColorAreaRoot
266
- v-slot="{ style }"
267
- :model-value="colorObj"
268
- color-space="hsl"
269
- x-channel="saturation"
270
- y-channel="lightness"
271
- class="relative"
272
- @update:color="handleColorUpdate"
273
- >
274
- <div
275
- class="overflow-hidden rounded-lg ring ring-gray-200 transition-colors ease-out focus-within:ring-cornflower-blue-500"
276
- >
277
- <ColorAreaArea
278
- class="relative h-40 w-full cursor-crosshair outline-none"
279
- :style="style"
280
- >
281
- <ColorAreaThumb
282
- class="block size-4 cursor-grab rounded-full border-2 border-white shadow-sm focus:outline-none active:cursor-grabbing"
283
- />
284
- </ColorAreaArea>
285
- </div>
286
- </ColorAreaRoot>
287
-
288
- <!-- Hue slider -->
289
- <ColorSliderRoot
290
- :model-value="colorObj"
291
- channel="hue"
292
- color-space="hsl"
293
- class="relative flex h-5 w-full touch-none items-center select-none"
294
- @update:color="handleColorUpdate"
295
- >
296
- <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
297
- <ColorSliderThumb
298
- class="block size-5 cursor-grab rounded-full border-2 border-white bg-white shadow-sm focus:ring-2 focus:ring-cornflower-blue-500 focus:outline-none active:cursor-grabbing"
581
+ <div :class="[panelClass, matchWidth ? 'w-full' : 'w-70']">
582
+ <Gate :when="bothModes">
583
+ <NexxtToggleGroup
584
+ v-model="modeModel"
585
+ :options="modeOptions"
586
+ :label="gradientCopy.mode"
587
+ label-hidden
588
+ equal-width
299
589
  />
300
- </ColorSliderRoot>
301
-
302
- <!-- Alpha slider (opt-in via showAlpha prop) -->
303
- <div v-if="showAlpha" class="flex flex-col gap-1">
304
- <span :id="ALPHA_LABEL_ID" class="extra-small-normal text-gray-500">{{
305
- alphaLabel
306
- }}</span>
307
- <!-- The checkerboard wrapper is behind the track; the gradient goes from
308
- rgba(…,0) → rgba(…,1) so its transparent left end reveals it. -->
309
- <div class="relative flex h-5 items-center">
310
- <div
311
- class="absolute inset-x-0 inset-y-1 rounded-full"
312
- style="
313
- background-image: repeating-conic-gradient(#e5e7eb 0% 25%, #ffffff 0% 50%);
314
- background-size: 10px 10px;
315
- "
590
+ </Gate>
591
+ <Gate :when="gradientMode">
592
+ <NexxtGradientPreview :editor="editor" :copy="gradientCopy" />
593
+ <div class="flex items-center gap-2">
594
+ <NexxtToggleGroup
595
+ v-model="typeModel"
596
+ :options="typeOptions"
597
+ :label="gradientCopy.type"
598
+ label-hidden
599
+ equal-width
600
+ class="min-w-0 flex-1"
316
601
  />
317
- <ColorSliderRoot
318
- :model-value="colorObj"
319
- channel="alpha"
320
- color-space="hsl"
321
- class="relative z-10 flex size-full touch-none items-center select-none"
322
- @update:color="handleColorUpdate"
602
+ <button
603
+ type="button"
604
+ :aria-label="gradientCopy.reverse"
605
+ class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-900 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cornflower-blue-600"
606
+ @click="editor.reverse"
323
607
  >
324
- <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
325
- <!--
326
- With `as-child` reka merges its own attributes under the child's,
327
- so the child's win: the name comes from the visible label instead
328
- of reka's English "Alpha", and the value is announced whole.
329
- -->
330
- <ColorSliderThumb v-slot="{ channelValue }" as-child>
331
- <span
332
- :aria-label="undefined"
333
- :aria-labelledby="ALPHA_LABEL_ID"
334
- :aria-valuenow="roundedChannel(channelValue)"
335
- class="block size-5 cursor-grab rounded-full border-2 border-white bg-white shadow-sm focus:ring-2 focus:ring-cornflower-blue-500 focus:outline-none active:cursor-grabbing"
336
- />
337
- </ColorSliderThumb>
338
- </ColorSliderRoot>
608
+ <NexxtIcon name="arrow-right-arrow-left" aria-hidden="true" />
609
+ </button>
339
610
  </div>
340
- </div>
341
-
342
- <!-- Format input fields — only active format shown, cycle with chevrons -->
343
- <div class="flex flex-col gap-1">
344
- <!-- Header: format label + up/down cycle buttons (only when multiple formats) -->
345
- <div class="flex items-center justify-between">
346
- <span class="text-xs text-gray-400">{{ activeFormat.toUpperCase() }}</span>
347
- <div v-if="formats.length > 1" class="flex flex-col">
348
- <button
349
- type="button"
350
- class="flex h-4 w-6 cursor-pointer items-center justify-center text-gray-400 transition-colors hover:text-cornflower-blue-600 focus:outline-none"
351
- aria-label="Vorig formaat"
352
- @click="cycleFormat(-1)"
353
- >
354
- <NexxtIcon name="chevron-up" class="text-[12px]" aria-hidden="true" />
355
- </button>
356
- <button
357
- type="button"
358
- class="flex h-4 w-6 cursor-pointer items-center justify-center text-gray-400 transition-colors hover:text-cornflower-blue-600 focus:outline-none"
359
- aria-label="Volgend formaat"
360
- @click="cycleFormat(1)"
361
- >
362
- <NexxtIcon name="chevron-down" class="text-[12px]" aria-hidden="true" />
363
- </button>
364
- </div>
611
+ <NexxtGradientStops
612
+ :editor="editor"
613
+ :copy="gradientCopy"
614
+ @add="addStop"
615
+ @remove="removeStop"
616
+ />
617
+ <div class="flex flex-col gap-1">
618
+ <NexxtGradientStopRow
619
+ v-for="stop in editor.stops.value"
620
+ :key="stop.id"
621
+ :ref="rowRef(stop.id)"
622
+ :editor="editor"
623
+ :stop="stop"
624
+ :copy="gradientCopy"
625
+ @target="onTarget"
626
+ @remove="removeStop(stop.id)"
627
+ />
365
628
  </div>
366
-
367
- <!-- Hex -->
368
- <ColorFieldRoot
369
- v-if="activeFormat === 'hex'"
370
- :model-value="hexColor"
371
- class="w-full"
372
- @update:color="handleColorUpdate"
373
- >
374
- <div
375
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
376
- :class="hexInputError ? 'ring-brick-400' : 'ring-gray-200'"
377
- >
378
- <ColorFieldInput
379
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
380
- placeholder="#000000"
381
- @input="handleHexInput"
382
- />
383
- </div>
384
- <p v-if="hexInputError" class="mt-1 text-xs text-brick-400">{{ hexInputError }}</p>
385
- </ColorFieldRoot>
386
-
387
- <!-- HSL -->
388
- <div v-else-if="activeFormat === 'hsl'" class="flex gap-2">
389
- <ColorFieldRoot
390
- :model-value="colorObj"
391
- channel="hue"
392
- color-space="hsl"
393
- class="flex-1"
394
- @update:color="handleColorUpdate"
395
- >
396
- <div
397
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
398
- :class="channelInputErrors.hue ? 'ring-brick-400' : 'ring-gray-200'"
629
+ </Gate>
630
+ <Gate :when="!gradientMode || !!editorTarget">
631
+ <MaybeTeleport :to="gradientMode ? editorTarget : undefined">
632
+ <Wrap :tag="gradientMode ? 'div' : undefined" class="flex flex-col gap-4">
633
+ <!-- 2D Color Area: saturation (x-axis) / lightness (y-axis) -->
634
+ <ColorAreaRoot
635
+ v-slot="{ style }"
636
+ :model-value="colorObj"
637
+ color-space="hsl"
638
+ x-channel="saturation"
639
+ y-channel="lightness"
640
+ class="relative"
641
+ @update:color="handleColorUpdate"
399
642
  >
400
- <ColorFieldInput
401
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
402
- placeholder="H"
403
- @input="(e: Event) => handleChannelInput(e, 'hue')"
404
- />
405
- </div>
406
- </ColorFieldRoot>
407
- <ColorFieldRoot
408
- :model-value="colorObj"
409
- channel="saturation"
410
- color-space="hsl"
411
- class="flex-1"
412
- @update:color="handleColorUpdate"
413
- >
414
- <div
415
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
416
- :class="channelInputErrors.saturation ? 'ring-brick-400' : 'ring-gray-200'"
643
+ <div
644
+ class="overflow-hidden rounded-lg ring ring-gray-200 transition-colors ease-out focus-within:ring-cornflower-blue-500"
645
+ >
646
+ <ColorAreaArea
647
+ class="relative h-40 w-full cursor-crosshair outline-none"
648
+ :style="style"
649
+ >
650
+ <ColorAreaThumb
651
+ class="block size-4 cursor-grab rounded-full border-2 border-white shadow-sm focus:outline-none active:cursor-grabbing"
652
+ />
653
+ </ColorAreaArea>
654
+ </div>
655
+ </ColorAreaRoot>
656
+
657
+ <!-- Hue slider -->
658
+ <ColorSliderRoot
659
+ :model-value="colorObj"
660
+ channel="hue"
661
+ color-space="hsl"
662
+ class="relative flex h-5 w-full touch-none items-center select-none"
663
+ @update:color="handleColorUpdate"
417
664
  >
418
- <ColorFieldInput
419
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
420
- placeholder="S"
421
- @input="(e: Event) => handleChannelInput(e, 'saturation')"
422
- />
423
- </div>
424
- </ColorFieldRoot>
425
- <ColorFieldRoot
426
- :model-value="colorObj"
427
- channel="lightness"
428
- color-space="hsl"
429
- class="flex-1"
430
- @update:color="handleColorUpdate"
431
- >
432
- <div
433
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
434
- :class="channelInputErrors.lightness ? 'ring-brick-400' : 'ring-gray-200'"
435
- >
436
- <ColorFieldInput
437
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
438
- placeholder="L"
439
- @input="(e: Event) => handleChannelInput(e, 'lightness')"
665
+ <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
666
+ <ColorSliderThumb
667
+ class="block size-5 cursor-grab rounded-full border-2 border-white bg-white shadow-sm focus:ring-2 focus:ring-cornflower-blue-500 focus:outline-none active:cursor-grabbing"
440
668
  />
669
+ </ColorSliderRoot>
670
+
671
+ <!-- Alpha slider (opt-in via showAlpha prop) -->
672
+ <div v-if="alphaOn" class="flex flex-col gap-1">
673
+ <span :id="ALPHA_LABEL_ID" class="extra-small-normal text-gray-500">{{
674
+ alphaLabel
675
+ }}</span>
676
+ <!-- The checkerboard wrapper is behind the track; the gradient goes from
677
+ rgba(…,0) → rgba(…,1) so its transparent left end reveals it. -->
678
+ <div
679
+ class="relative flex h-5 items-center"
680
+ :class="alphaLocked ? 'cursor-not-allowed opacity-50' : undefined"
681
+ >
682
+ <div
683
+ class="absolute inset-x-0 inset-y-1 rounded-full"
684
+ style="
685
+ background-image: repeating-conic-gradient(#e5e7eb 0% 25%, #ffffff 0% 50%);
686
+ background-size: 10px 10px;
687
+ "
688
+ />
689
+ <ColorSliderRoot
690
+ :model-value="colorObj"
691
+ channel="alpha"
692
+ color-space="hsl"
693
+ :disabled="alphaLocked"
694
+ class="relative z-10 flex size-full touch-none items-center select-none"
695
+ @update:color="handleColorUpdate"
696
+ >
697
+ <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
698
+ <!--
699
+ With `as-child` reka merges its own attributes under the child's,
700
+ so the child's win: the name comes from the visible label instead
701
+ of reka's English "Alpha", and the value is announced whole.
702
+ -->
703
+ <ColorSliderThumb v-slot="{ channelValue }" as-child>
704
+ <span
705
+ :aria-label="undefined"
706
+ :aria-labelledby="ALPHA_LABEL_ID"
707
+ :aria-valuenow="roundedChannel(channelValue)"
708
+ v-bind="
709
+ alphaLocked
710
+ ? {
711
+ 'aria-disabled': 'true',
712
+ 'aria-describedby': ROLE_HINT_ID,
713
+ tabindex: 0,
714
+ }
715
+ : {}
716
+ "
717
+ class="block size-5 cursor-grab rounded-full border-2 border-white bg-white shadow-sm focus:ring-2 focus:ring-cornflower-blue-500 focus:outline-none active:cursor-grabbing"
718
+ />
719
+ </ColorSliderThumb>
720
+ </ColorSliderRoot>
721
+ </div>
722
+ <Gate :when="alphaLocked">
723
+ <p :id="ROLE_HINT_ID" class="extra-small-normal text-gray-700">
724
+ {{ gradientCopy.roleOpaque }}
725
+ </p>
726
+ </Gate>
441
727
  </div>
442
- </ColorFieldRoot>
443
- </div>
444
728
 
445
- <!-- RGB -->
446
- <div v-else-if="activeFormat === 'rgb'" class="flex gap-2">
447
- <ColorFieldRoot
448
- :model-value="colorObj"
449
- channel="red"
450
- color-space="rgb"
451
- class="flex-1"
452
- @update:color="handleColorUpdate"
453
- >
454
- <div
455
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
456
- :class="channelInputErrors.red ? 'ring-brick-400' : 'ring-gray-200'"
457
- >
458
- <ColorFieldInput
459
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
460
- placeholder="R"
461
- @input="(e: Event) => handleChannelInput(e, 'red')"
462
- />
463
- </div>
464
- </ColorFieldRoot>
465
- <ColorFieldRoot
466
- :model-value="colorObj"
467
- channel="green"
468
- color-space="rgb"
469
- class="flex-1"
470
- @update:color="handleColorUpdate"
471
- >
472
- <div
473
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
474
- :class="channelInputErrors.green ? 'ring-brick-400' : 'ring-gray-200'"
475
- >
476
- <ColorFieldInput
477
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
478
- placeholder="G"
479
- @input="(e: Event) => handleChannelInput(e, 'green')"
480
- />
481
- </div>
482
- </ColorFieldRoot>
483
- <ColorFieldRoot
484
- :model-value="colorObj"
485
- channel="blue"
486
- color-space="rgb"
487
- class="flex-1"
488
- @update:color="handleColorUpdate"
489
- >
490
- <div
491
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
492
- :class="channelInputErrors.blue ? 'ring-brick-400' : 'ring-gray-200'"
493
- >
494
- <ColorFieldInput
495
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
496
- placeholder="B"
497
- @input="(e: Event) => handleChannelInput(e, 'blue')"
498
- />
729
+ <!-- Format input fields — only active format shown, cycle with chevrons -->
730
+ <div class="flex flex-col gap-1">
731
+ <!-- Header: format label + up/down cycle buttons (only when multiple formats) -->
732
+ <div
733
+ v-if="!gradientMode || formats.length > 1"
734
+ class="flex items-center justify-between"
735
+ >
736
+ <span v-if="!gradientMode" class="text-xs text-gray-400">{{
737
+ activeFormat.toUpperCase()
738
+ }}</span>
739
+ <div v-if="formats.length > 1" class="flex flex-col">
740
+ <button
741
+ type="button"
742
+ class="flex h-4 w-6 cursor-pointer items-center justify-center text-gray-400 transition-colors hover:text-cornflower-blue-600 focus:outline-none"
743
+ aria-label="Vorig formaat"
744
+ @click="cycleFormat(-1)"
745
+ >
746
+ <NexxtIcon name="chevron-up" class="text-[12px]" aria-hidden="true" />
747
+ </button>
748
+ <button
749
+ type="button"
750
+ class="flex h-4 w-6 cursor-pointer items-center justify-center text-gray-400 transition-colors hover:text-cornflower-blue-600 focus:outline-none"
751
+ aria-label="Volgend formaat"
752
+ @click="cycleFormat(1)"
753
+ >
754
+ <NexxtIcon name="chevron-down" class="text-[12px]" aria-hidden="true" />
755
+ </button>
756
+ </div>
757
+ </div>
758
+
759
+ <!-- Hex -->
760
+ <ColorFieldRoot
761
+ v-if="activeFormat === 'hex'"
762
+ :model-value="hexColor"
763
+ class="w-full"
764
+ @update:color="handleColorUpdate"
765
+ >
766
+ <div
767
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
768
+ :class="hexInputError ? 'ring-brick-400' : 'ring-gray-200'"
769
+ >
770
+ <ColorFieldInput
771
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
772
+ placeholder="#000000"
773
+ aria-label="Hex"
774
+ @input="handleHexInput"
775
+ />
776
+ </div>
777
+ <p v-if="hexInputError" class="mt-1 text-xs text-brick-400">
778
+ {{ hexInputError }}
779
+ </p>
780
+ </ColorFieldRoot>
781
+
782
+ <!-- HSL -->
783
+ <div v-else-if="activeFormat === 'hsl'" class="flex gap-2">
784
+ <ColorFieldRoot
785
+ :model-value="colorObj"
786
+ channel="hue"
787
+ color-space="hsl"
788
+ class="flex-1"
789
+ @update:color="handleColorUpdate"
790
+ >
791
+ <div
792
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
793
+ :class="channelInputErrors.hue ? 'ring-brick-400' : 'ring-gray-200'"
794
+ >
795
+ <ColorFieldInput
796
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
797
+ placeholder="H"
798
+ @input="(e: Event) => handleChannelInput(e, 'hue')"
799
+ />
800
+ </div>
801
+ </ColorFieldRoot>
802
+ <ColorFieldRoot
803
+ :model-value="colorObj"
804
+ channel="saturation"
805
+ color-space="hsl"
806
+ class="flex-1"
807
+ @update:color="handleColorUpdate"
808
+ >
809
+ <div
810
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
811
+ :class="channelInputErrors.saturation ? 'ring-brick-400' : 'ring-gray-200'"
812
+ >
813
+ <ColorFieldInput
814
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
815
+ placeholder="S"
816
+ @input="(e: Event) => handleChannelInput(e, 'saturation')"
817
+ />
818
+ </div>
819
+ </ColorFieldRoot>
820
+ <ColorFieldRoot
821
+ :model-value="colorObj"
822
+ channel="lightness"
823
+ color-space="hsl"
824
+ class="flex-1"
825
+ @update:color="handleColorUpdate"
826
+ >
827
+ <div
828
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
829
+ :class="channelInputErrors.lightness ? 'ring-brick-400' : 'ring-gray-200'"
830
+ >
831
+ <ColorFieldInput
832
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
833
+ placeholder="L"
834
+ @input="(e: Event) => handleChannelInput(e, 'lightness')"
835
+ />
836
+ </div>
837
+ </ColorFieldRoot>
838
+ </div>
839
+
840
+ <!-- RGB -->
841
+ <div v-else-if="activeFormat === 'rgb'" class="flex gap-2">
842
+ <ColorFieldRoot
843
+ :model-value="colorObj"
844
+ channel="red"
845
+ color-space="rgb"
846
+ class="flex-1"
847
+ @update:color="handleColorUpdate"
848
+ >
849
+ <div
850
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
851
+ :class="channelInputErrors.red ? 'ring-brick-400' : 'ring-gray-200'"
852
+ >
853
+ <ColorFieldInput
854
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
855
+ placeholder="R"
856
+ @input="(e: Event) => handleChannelInput(e, 'red')"
857
+ />
858
+ </div>
859
+ </ColorFieldRoot>
860
+ <ColorFieldRoot
861
+ :model-value="colorObj"
862
+ channel="green"
863
+ color-space="rgb"
864
+ class="flex-1"
865
+ @update:color="handleColorUpdate"
866
+ >
867
+ <div
868
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
869
+ :class="channelInputErrors.green ? 'ring-brick-400' : 'ring-gray-200'"
870
+ >
871
+ <ColorFieldInput
872
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
873
+ placeholder="G"
874
+ @input="(e: Event) => handleChannelInput(e, 'green')"
875
+ />
876
+ </div>
877
+ </ColorFieldRoot>
878
+ <ColorFieldRoot
879
+ :model-value="colorObj"
880
+ channel="blue"
881
+ color-space="rgb"
882
+ class="flex-1"
883
+ @update:color="handleColorUpdate"
884
+ >
885
+ <div
886
+ class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
887
+ :class="channelInputErrors.blue ? 'ring-brick-400' : 'ring-gray-200'"
888
+ >
889
+ <ColorFieldInput
890
+ class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
891
+ placeholder="B"
892
+ @input="(e: Event) => handleChannelInput(e, 'blue')"
893
+ />
894
+ </div>
895
+ </ColorFieldRoot>
896
+ </div>
499
897
  </div>
500
- </ColorFieldRoot>
501
- </div>
502
- </div>
503
- <slot name="presets" />
504
- <div v-if="colorSuggestions.length" class="flex flex-col gap-1">
505
- <span class="extra-small-normal text-gray-500">{{ suggestionsLabel }}</span>
506
- <div class="flex flex-wrap gap-1.5">
507
- <button
508
- v-for="suggestion in colorSuggestions"
509
- :key="suggestion"
510
- type="button"
511
- class="rounded-md p-0.5 ring-gray-200 transition-shadow hover:ring-2 hover:ring-cornflower-blue-400 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none"
512
- :aria-label="`Kies kleur ${suggestion}`"
513
- @click="selectSuggestion(suggestion)"
514
- >
515
- <ColorSwatch
516
- :color="suggestion"
517
- class="size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
518
- :style="{ backgroundColor: 'var(--reka-color-swatch-color)' }"
898
+ <slot
899
+ name="presets"
900
+ :preset="gradientMode ? editor.selected.value?.preset?.key : undefined"
901
+ :select-preset="selectPreset"
519
902
  />
520
- </button>
521
- </div>
522
- </div>
903
+ <div v-if="colorSuggestions.length" class="flex flex-col gap-1">
904
+ <span class="extra-small-normal text-gray-500">{{ suggestionsLabel }}</span>
905
+ <div class="flex flex-wrap gap-1.5">
906
+ <button
907
+ v-for="suggestion in colorSuggestions"
908
+ :key="suggestion"
909
+ type="button"
910
+ class="rounded-md p-0.5 ring-gray-200 transition-shadow hover:ring-2 hover:ring-cornflower-blue-400 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none"
911
+ :aria-label="`Kies kleur ${suggestion}`"
912
+ @click="selectSuggestion(suggestion)"
913
+ >
914
+ <ColorSwatch
915
+ :color="suggestion"
916
+ class="size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
917
+ :style="{ backgroundColor: 'var(--reka-color-swatch-color)' }"
918
+ />
919
+ </button>
920
+ </div>
921
+ </div>
922
+ </Wrap>
923
+ </MaybeTeleport>
924
+ </Gate>
523
925
  </div>
524
926
  </template>
525
927
  </NexxtFloatingPanel>