@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.
- package/dist/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +106 -0
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +49 -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/molecules/ToggleGroup/ToggleGroup.vue.d.ts +7 -0
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +61 -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/index.d.ts +7 -1
- package/dist/index.js +3591 -2182
- package/dist/nuxt.js +1 -0
- package/package.json +1 -1
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +15 -2
- package/src/components/editor/molecules/EditorBlur/EditorBlur.vue +160 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +90 -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 +18 -3
- package/src/components/organisms/ColorPicker/ColorPicker.vue +686 -257
- 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.json +1 -0
- 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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
<
|
|
270
|
-
|
|
271
|
-
/>
|
|
272
|
-
</ColorAreaArea>
|
|
608
|
+
<NexxtIcon name="arrow-right-arrow-left" aria-hidden="true" />
|
|
609
|
+
</button>
|
|
273
610
|
</div>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
<
|
|
374
|
-
class="
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
:
|
|
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
|
-
<
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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>
|