@nexxtmove/ui 2.2.0 → 2.4.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.
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
- package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
- package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
- package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
- package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
- package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
- package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
- package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
- package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
- package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
- package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
- package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
- package/dist/components/organisms/VideoControls/VideoControls.vue.d.ts +8 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3400 -2083
- package/package.json +1 -1
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
- package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
- package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
- package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
- package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
- package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
- package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
- package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
- package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
- package/src/components/organisms/ColorPicker/gradient.ts +303 -0
- package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
- package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
- package/src/components/organisms/VideoControls/VideoControls.vue +15 -4
- 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
|
-
/**
|
|
56
|
-
|
|
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 (!
|
|
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
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
</
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
368
|
-
<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
<
|
|
401
|
-
class="
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
:
|
|
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
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
<
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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>
|