@nexxtmove/ui 2.1.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 (40) hide show
  1. package/dist/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +6 -0
  2. package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
  3. package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +106 -0
  4. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +49 -20
  5. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
  6. package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
  7. package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
  8. package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
  9. package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
  10. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
  11. package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +7 -0
  12. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +61 -2
  13. package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
  14. package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
  15. package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
  16. package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
  17. package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
  18. package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
  19. package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
  20. package/dist/index.d.ts +7 -1
  21. package/dist/index.js +3591 -2182
  22. package/dist/nuxt.js +1 -0
  23. package/package.json +1 -1
  24. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +15 -2
  25. package/src/components/editor/molecules/EditorBlur/EditorBlur.vue +160 -0
  26. package/src/components/editor/molecules/EditorColor/EditorColor.vue +90 -171
  27. package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
  28. package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
  29. package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
  30. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +18 -3
  31. package/src/components/organisms/ColorPicker/ColorPicker.vue +686 -257
  32. package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
  33. package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
  34. package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
  35. package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
  36. package/src/components/organisms/ColorPicker/gradient.ts +303 -0
  37. package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
  38. package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
  39. package/src/components.json +1 -0
  40. package/src/index.ts +32 -0
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" setup>
2
- import { ref, watch, computed } 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[]>>
@@ -40,21 +71,100 @@ export interface NexxtColorPickerProps {
40
71
  suggestions?: ColorSuggestions
41
72
  /** Accessible and visible label for the suggestions. */
42
73
  suggestionsLabel?: string
74
+ /** Visible label of the opacity slider, and the slider's accessible name. */
75
+ alphaLabel?: string
43
76
  /**
44
77
  * Text shown in the trigger in place of the formatted colour, for a value that has a name of
45
78
  * its own, such as a theme role. Only shown while a colour is set.
46
79
  */
47
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
48
103
  }
49
104
 
50
105
  defineOptions({ name: 'NexxtColorPicker' })
51
106
 
52
107
  defineSlots<{
53
- /** Named colours a wrapper offers next to the free picker; rendered above the suggestions. */
54
- 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
118
+ }>()
119
+
120
+ const emit = defineEmits<{
121
+ /** The panel closed. */
122
+ close: []
55
123
  }>()
56
124
 
57
- const PICKER_ID = 'color-picker-' + Math.random().toString(36).slice(2, 10)
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
+
157
+ // `useId` is the same on the server and in the browser; a random id would not hydrate.
158
+ const PICKER_ID = useId()
159
+ const ALPHA_LABEL_ID = useId()
160
+ const ROLE_HINT_ID = useId()
161
+
162
+ /**
163
+ * reka reports the alpha channel as `alpha * 100`, which is a fraction for most
164
+ * stored colours (`#…80` is 50.196) and float noise for some steps (7.000…01).
165
+ * Only what the slider announces is rounded; the colour itself is untouched.
166
+ */
167
+ const roundedChannel = (value: number) => Math.round(value)
58
168
 
59
169
  const props = withDefaults(defineProps<NexxtColorPickerProps>(), {
60
170
  placeholder: 'Kies kleur...',
@@ -64,10 +174,133 @@ const props = withDefaults(defineProps<NexxtColorPickerProps>(), {
64
174
  matchWidth: false,
65
175
  suggestions: () => ({}),
66
176
  suggestionsLabel: 'Gebruikte kleuren',
177
+ alphaLabel: 'Dekking',
178
+ modes: () => ['solid'],
67
179
  })
68
180
 
69
181
  const model = defineModel<string>({ default: '' })
70
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
+
71
304
  const withoutAlpha = (color: Color) =>
72
305
  color.alpha < 1 ? setChannelValue(color, 'alpha', 100) : color
73
306
 
@@ -80,7 +313,7 @@ const normalizeModelColor = (value: string) => {
80
313
  const normalizeInputColor = (value: string) => {
81
314
  const color = normalizeColor(value)
82
315
 
83
- if (!props.showAlpha && color.alpha < 1) {
316
+ if (!alphaOn.value && color.alpha < 1) {
84
317
  throw new Error('Transparantie is niet beschikbaar')
85
318
  }
86
319
 
@@ -99,13 +332,72 @@ const hexInputError = ref('')
99
332
  const channelInputErrors = ref<Partial<Record<ColorChannel, string>>>({})
100
333
 
101
334
  const hexColor = computed(() => colorToString(colorObj.value, 'hex'))
102
- const displayValue = computed(() =>
103
- 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),
104
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
+ })
105
391
  const colorSuggestions = computed(() => {
106
392
  const values = new Set<string>()
107
393
 
108
- 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) => {
109
401
  try {
110
402
  const color = normalizeInputColor(suggestion)
111
403
  values.add(fmt(color, activeFormat.value))
@@ -117,28 +409,34 @@ const colorSuggestions = computed(() => {
117
409
  return [...values]
118
410
  })
119
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
+
120
422
  const selectSuggestion = (suggestion: string) => {
121
- colorObj.value = normalizeInputColor(suggestion)
122
- model.value = fmt(colorObj.value, activeFormat.value)
423
+ commitColor(normalizeInputColor(suggestion))
123
424
  }
124
425
 
125
426
  const cycleFormat = (direction: 1 | -1) => {
126
427
  activeFormatIndex.value =
127
428
  (activeFormatIndex.value + direction + props.formats.length) % props.formats.length
128
- model.value = fmt(colorObj.value, activeFormat.value)
429
+ if (!gradientMode.value) model.value = fmt(colorObj.value, activeFormat.value)
129
430
  }
130
431
 
131
432
  const handleColorUpdate = (newColor: Color) => {
132
- const updated = props.showAlpha ? newColor : withoutAlpha(newColor)
133
- colorObj.value = updated
134
- model.value = fmt(updated, activeFormat.value)
433
+ commitColor(alphaOn.value ? newColor : withoutAlpha(newColor))
135
434
  }
136
435
 
137
436
  const handleHexInput = (event: Event) => {
138
437
  const val = (event.target as HTMLInputElement).value
139
438
  try {
140
- colorObj.value = normalizeInputColor(val)
141
- model.value = fmt(colorObj.value, activeFormat.value)
439
+ commitColor(normalizeInputColor(val))
142
440
  hexInputError.value = ''
143
441
  } catch (e) {
144
442
  hexInputError.value = e instanceof Error ? e.message : 'Ongeldige kleur'
@@ -152,9 +450,7 @@ const handleChannelInput = (event: Event, channel: ColorChannel) => {
152
450
  return
153
451
  }
154
452
  try {
155
- const updated = setChannelValue(colorObj.value, channel, val)
156
- colorObj.value = updated
157
- model.value = fmt(updated, activeFormat.value)
453
+ commitColor(setChannelValue(colorObj.value, channel, val))
158
454
  delete channelInputErrors.value[channel]
159
455
  } catch (e) {
160
456
  channelInputErrors.value[channel] = e instanceof Error ? e.message : 'Ongeldige waarde'
@@ -188,6 +484,8 @@ const currentColor = () => {
188
484
  watch(
189
485
  () => props.showAlpha,
190
486
  (showAlpha) => {
487
+ // The gradient mode always has the slider and never writes the solid model.
488
+ if (gradientMode.value) return
191
489
  const current = currentColor()
192
490
  if (!showAlpha && current.alpha < 1) {
193
491
  const updated = withoutAlpha(current)
@@ -196,6 +494,32 @@ watch(
196
494
  }
197
495
  },
198
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
+ })
199
523
  </script>
200
524
 
201
525
  <template>
@@ -205,7 +529,7 @@ watch(
205
529
  <span v-if="required" class="text-brick-500">*</span>
206
530
  </label>
207
531
 
208
- <NexxtFloatingPanel placement="bottom-start" :match-width="matchWidth">
532
+ <NexxtFloatingPanel placement="bottom-start" :match-width="matchWidth" @close="onPanelClose">
209
533
  <template #trigger="{ isOpen, toggle }">
210
534
  <button
211
535
  :id="PICKER_ID"
@@ -213,13 +537,22 @@ watch(
213
537
  :aria-expanded="isOpen"
214
538
  :aria-haspopup="true"
215
539
  :aria-label="label ? undefined : placeholder"
540
+ :aria-describedby="describedby"
216
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"
217
542
  :class="[error ? 'ring-brick-400' : 'ring-gray-200', fullWidth ? 'w-full' : 'min-w-60']"
218
543
  @click="toggle"
219
544
  >
220
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
+ />
221
553
  <ColorSwatch
222
- :color="hexColor"
554
+ v-else
555
+ :color="triggerHex"
223
556
  :aria-label="
224
557
  displayValue ? `Geselecteerde kleur: ${displayValue}` : 'Geen kleur geselecteerd'
225
558
  "
@@ -245,254 +578,350 @@ watch(
245
578
  </template>
246
579
 
247
580
  <template #content>
248
- <div
249
- class="flex flex-col gap-4 rounded-xl bg-white p-4 ring ring-gray-200"
250
- :class="matchWidth ? 'w-full' : 'w-70'"
251
- >
252
- <!-- 2D Color Area: saturation (x-axis) / lightness (y-axis) -->
253
- <ColorAreaRoot
254
- v-slot="{ style }"
255
- :model-value="colorObj"
256
- color-space="hsl"
257
- x-channel="saturation"
258
- y-channel="lightness"
259
- class="relative"
260
- @update:color="handleColorUpdate"
261
- >
262
- <div
263
- class="overflow-hidden rounded-lg ring ring-gray-200 transition-colors ease-out focus-within:ring-cornflower-blue-500"
264
- >
265
- <ColorAreaArea
266
- class="relative h-40 w-full cursor-crosshair outline-none"
267
- :style="style"
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
589
+ />
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"
601
+ />
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"
268
607
  >
269
- <ColorAreaThumb
270
- class="block size-4 cursor-grab rounded-full border-2 border-white shadow-sm focus:outline-none active:cursor-grabbing"
271
- />
272
- </ColorAreaArea>
608
+ <NexxtIcon name="arrow-right-arrow-left" aria-hidden="true" />
609
+ </button>
273
610
  </div>
274
- </ColorAreaRoot>
275
-
276
- <!-- Hue slider -->
277
- <ColorSliderRoot
278
- :model-value="colorObj"
279
- channel="hue"
280
- color-space="hsl"
281
- class="relative flex h-5 w-full touch-none items-center select-none"
282
- @update:color="handleColorUpdate"
283
- >
284
- <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
285
- <ColorSliderThumb
286
- 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"
611
+ <NexxtGradientStops
612
+ :editor="editor"
613
+ :copy="gradientCopy"
614
+ @add="addStop"
615
+ @remove="removeStop"
287
616
  />
288
- </ColorSliderRoot>
289
-
290
- <!-- Alpha slider (opt-in via showAlpha prop) -->
291
- <!-- The checkerboard wrapper is behind the track; the gradient goes from
292
- rgba(…,0) → rgba(…,1) so its transparent left end reveals it. -->
293
- <div v-if="showAlpha" class="relative flex h-5 items-center">
294
- <div
295
- class="absolute inset-x-0 inset-y-1 rounded-full"
296
- style="
297
- background-image: repeating-conic-gradient(#e5e7eb 0% 25%, #ffffff 0% 50%);
298
- background-size: 10px 10px;
299
- "
300
- />
301
- <ColorSliderRoot
302
- :model-value="colorObj"
303
- channel="alpha"
304
- color-space="hsl"
305
- class="relative z-10 flex size-full touch-none items-center select-none"
306
- @update:color="handleColorUpdate"
307
- >
308
- <ColorSliderTrack class="relative h-3 flex-1 overflow-hidden rounded-full" />
309
- <ColorSliderThumb
310
- 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"
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)"
311
627
  />
312
- </ColorSliderRoot>
313
- </div>
314
-
315
- <!-- Format input fields — only active format shown, cycle with chevrons -->
316
- <div class="flex flex-col gap-1">
317
- <!-- Header: format label + up/down cycle buttons (only when multiple formats) -->
318
- <div class="flex items-center justify-between">
319
- <span class="text-xs text-gray-400">{{ activeFormat.toUpperCase() }}</span>
320
- <div v-if="formats.length > 1" class="flex flex-col">
321
- <button
322
- type="button"
323
- 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"
324
- aria-label="Vorig formaat"
325
- @click="cycleFormat(-1)"
326
- >
327
- <NexxtIcon name="chevron-up" class="text-[12px]" aria-hidden="true" />
328
- </button>
329
- <button
330
- type="button"
331
- 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"
332
- aria-label="Volgend formaat"
333
- @click="cycleFormat(1)"
334
- >
335
- <NexxtIcon name="chevron-down" class="text-[12px]" aria-hidden="true" />
336
- </button>
337
- </div>
338
628
  </div>
339
-
340
- <!-- Hex -->
341
- <ColorFieldRoot
342
- v-if="activeFormat === 'hex'"
343
- :model-value="hexColor"
344
- class="w-full"
345
- @update:color="handleColorUpdate"
346
- >
347
- <div
348
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
349
- :class="hexInputError ? 'ring-brick-400' : 'ring-gray-200'"
350
- >
351
- <ColorFieldInput
352
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
353
- placeholder="#000000"
354
- @input="handleHexInput"
355
- />
356
- </div>
357
- <p v-if="hexInputError" class="mt-1 text-xs text-brick-400">{{ hexInputError }}</p>
358
- </ColorFieldRoot>
359
-
360
- <!-- HSL -->
361
- <div v-else-if="activeFormat === 'hsl'" class="flex gap-2">
362
- <ColorFieldRoot
363
- :model-value="colorObj"
364
- channel="hue"
365
- color-space="hsl"
366
- class="flex-1"
367
- @update:color="handleColorUpdate"
368
- >
369
- <div
370
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
371
- :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"
372
642
  >
373
- <ColorFieldInput
374
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
375
- placeholder="H"
376
- @input="(e: Event) => handleChannelInput(e, 'hue')"
377
- />
378
- </div>
379
- </ColorFieldRoot>
380
- <ColorFieldRoot
381
- :model-value="colorObj"
382
- channel="saturation"
383
- color-space="hsl"
384
- class="flex-1"
385
- @update:color="handleColorUpdate"
386
- >
387
- <div
388
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
389
- :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"
390
664
  >
391
- <ColorFieldInput
392
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
393
- placeholder="S"
394
- @input="(e: Event) => handleChannelInput(e, 'saturation')"
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"
395
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>
396
727
  </div>
397
- </ColorFieldRoot>
398
- <ColorFieldRoot
399
- :model-value="colorObj"
400
- channel="lightness"
401
- color-space="hsl"
402
- class="flex-1"
403
- @update:color="handleColorUpdate"
404
- >
405
- <div
406
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
407
- :class="channelInputErrors.lightness ? 'ring-brick-400' : 'ring-gray-200'"
408
- >
409
- <ColorFieldInput
410
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
411
- placeholder="L"
412
- @input="(e: Event) => handleChannelInput(e, 'lightness')"
413
- />
414
- </div>
415
- </ColorFieldRoot>
416
- </div>
417
728
 
418
- <!-- RGB -->
419
- <div v-else-if="activeFormat === 'rgb'" class="flex gap-2">
420
- <ColorFieldRoot
421
- :model-value="colorObj"
422
- channel="red"
423
- color-space="rgb"
424
- class="flex-1"
425
- @update:color="handleColorUpdate"
426
- >
427
- <div
428
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
429
- :class="channelInputErrors.red ? 'ring-brick-400' : 'ring-gray-200'"
430
- >
431
- <ColorFieldInput
432
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
433
- placeholder="R"
434
- @input="(e: Event) => handleChannelInput(e, 'red')"
435
- />
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>
436
897
  </div>
437
- </ColorFieldRoot>
438
- <ColorFieldRoot
439
- :model-value="colorObj"
440
- channel="green"
441
- color-space="rgb"
442
- class="flex-1"
443
- @update:color="handleColorUpdate"
444
- >
445
- <div
446
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
447
- :class="channelInputErrors.green ? 'ring-brick-400' : 'ring-gray-200'"
448
- >
449
- <ColorFieldInput
450
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
451
- placeholder="G"
452
- @input="(e: Event) => handleChannelInput(e, 'green')"
453
- />
454
- </div>
455
- </ColorFieldRoot>
456
- <ColorFieldRoot
457
- :model-value="colorObj"
458
- channel="blue"
459
- color-space="rgb"
460
- class="flex-1"
461
- @update:color="handleColorUpdate"
462
- >
463
- <div
464
- class="flex h-10 items-center overflow-hidden rounded-lg px-3 ring transition-colors ease-out focus-within:ring-cornflower-blue-500"
465
- :class="channelInputErrors.blue ? 'ring-brick-400' : 'ring-gray-200'"
466
- >
467
- <ColorFieldInput
468
- class="w-full bg-transparent small-normal-mono text-gray-900 placeholder-gray-400 focus:outline-none"
469
- placeholder="B"
470
- @input="(e: Event) => handleChannelInput(e, 'blue')"
471
- />
472
- </div>
473
- </ColorFieldRoot>
474
- </div>
475
- </div>
476
- <slot name="presets" />
477
- <div v-if="colorSuggestions.length" class="flex flex-col gap-1">
478
- <span class="extra-small-normal text-gray-500">{{ suggestionsLabel }}</span>
479
- <div class="flex flex-wrap gap-1.5">
480
- <button
481
- v-for="suggestion in colorSuggestions"
482
- :key="suggestion"
483
- type="button"
484
- 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"
485
- :aria-label="`Kies kleur ${suggestion}`"
486
- @click="selectSuggestion(suggestion)"
487
- >
488
- <ColorSwatch
489
- :color="suggestion"
490
- class="size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
491
- :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"
492
902
  />
493
- </button>
494
- </div>
495
- </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>
496
925
  </div>
497
926
  </template>
498
927
  </NexxtFloatingPanel>