@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.
Files changed (36) hide show
  1. package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
  2. package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
  3. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
  4. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
  5. package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
  6. package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
  7. package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
  8. package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
  9. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
  10. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
  11. package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
  12. package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
  13. package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
  14. package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
  15. package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
  16. package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
  17. package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
  18. package/dist/components/organisms/VideoControls/VideoControls.vue.d.ts +8 -1
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.js +3400 -2083
  21. package/package.json +1 -1
  22. package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
  23. package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
  24. package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
  25. package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
  26. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
  27. package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
  28. package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
  29. package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
  30. package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
  31. package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
  32. package/src/components/organisms/ColorPicker/gradient.ts +303 -0
  33. package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
  34. package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
  35. package/src/components/organisms/VideoControls/VideoControls.vue +15 -4
  36. package/src/index.ts +25 -0
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ /** The step of Shift (and PageUp/PageDown) on a stop handle, in percent. */
3
+ const STOP_STEP = 10
4
+ </script>
5
+
6
+ <script lang="ts" setup>
7
+ import { computed, nextTick, useId, useTemplateRef } from 'vue'
8
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
9
+ import { CHECKERBOARD, fillMessage, stopBarCss } from './gradient'
10
+ import type { GradientCopy } from './gradient'
11
+ import type { GradientEditor, GradientEditorStop } from './useGradientEditor'
12
+
13
+ export interface NexxtGradientStopsProps {
14
+ /** The gradient state of the picker. */
15
+ editor: GradientEditor
16
+ /** Copy, already merged over the defaults. */
17
+ copy: GradientCopy
18
+ }
19
+
20
+ /**
21
+ * The bar with a handle per stop, and under it the heading of the stop list
22
+ * with the add button. Each handle is a slider of its own (the APG pattern for a slider with
23
+ * several thumbs); reka's multi-thumb slider is not used because it sorts its
24
+ * values, so a handle would swap stops when two pass each other.
25
+ */
26
+ defineOptions({ name: 'NexxtGradientStops' })
27
+
28
+ const { editor, copy } = defineProps<NexxtGradientStopsProps>()
29
+
30
+ const emit = defineEmits<{
31
+ /** The add button was used; the parent adds the stop and focuses its row. */
32
+ add: []
33
+ /** Delete or Backspace on a handle; the parent removes the stop and moves the focus. */
34
+ remove: [id: number]
35
+ }>()
36
+
37
+ const REASON_ID = useId()
38
+
39
+ const bar = useTemplateRef<HTMLElement>('bar')
40
+ const handles = new Map<number, HTMLElement>()
41
+
42
+ const handleRef = (id: number) => (element: unknown) => {
43
+ if (element instanceof HTMLElement) handles.set(id, element)
44
+ else handles.delete(id)
45
+ }
46
+
47
+ /** Focuses the handle of a stop once the DOM has caught up with the state. */
48
+ const focusStop = async (id: number) => {
49
+ await nextTick()
50
+ handles.get(id)?.focus()
51
+ }
52
+
53
+ defineExpose({ focusStop })
54
+
55
+ const barStyle = computed(() => ({
56
+ backgroundImage: `${stopBarCss(editor.snapshot.value)}, ${CHECKERBOARD}`,
57
+ backgroundSize: 'auto, 10px 10px',
58
+ }))
59
+
60
+ const handleStyle = (stop: GradientEditorStop) => ({
61
+ left: `${stop.position}%`,
62
+ backgroundImage: `linear-gradient(${stop.color}, ${stop.color}), ${CHECKERBOARD}`,
63
+ backgroundSize: 'auto, 8px 8px',
64
+ })
65
+
66
+ const nameOf = (stop: GradientEditorStop) =>
67
+ fillMessage(copy.stop, {
68
+ n: editor.sorted.value.indexOf(stop) + 1,
69
+ total: editor.stops.value.length,
70
+ })
71
+
72
+ const valueTextOf = (stop: GradientEditorStop) => {
73
+ if (stop.preset) return `${stop.position} %, ${stop.preset.label}`
74
+ const hex = stop.color.slice(0, 7)
75
+ const alpha = stop.color.length === 9 ? parseInt(stop.color.slice(7), 16) / 255 : 1
76
+ const opacity =
77
+ alpha < 1 ? `, ${fillMessage(copy.stopOpacity, { n: Math.round(alpha * 100) })}` : ''
78
+ return `${stop.position} %, ${hex}${opacity}`
79
+ }
80
+
81
+ const positionAt = (event: PointerEvent) => {
82
+ const rect = bar.value?.getBoundingClientRect()
83
+ if (!rect || rect.width === 0) return 0
84
+ return ((event.clientX - rect.left) / rect.width) * 100
85
+ }
86
+
87
+ /** The stop a pointer is moving, until it is released. */
88
+ let dragged: number | null = null
89
+
90
+ const startDrag = (event: PointerEvent, id: number, capture: HTMLElement) => {
91
+ capture.setPointerCapture?.(event.pointerId)
92
+ dragged = id
93
+ }
94
+
95
+ const onHandlePointerDown = (event: PointerEvent, id: number) => {
96
+ if (event.button !== 0) return
97
+ // No default: it would hand the focus back to whatever the browser picks.
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ editor.select(id)
101
+ handles.get(id)?.focus()
102
+ startDrag(event, id, event.currentTarget as HTMLElement)
103
+ }
104
+
105
+ /** A click on the bar adds a stop there, and keeps dragging it while the button is down. */
106
+ const onBarPointerDown = async (event: PointerEvent) => {
107
+ if (event.button !== 0) return
108
+ event.preventDefault()
109
+ const id = editor.addStop(positionAt(event))
110
+ if (id === null) return
111
+ startDrag(event, id, event.currentTarget as HTMLElement)
112
+ await focusStop(id)
113
+ }
114
+
115
+ const onPointerMove = (event: PointerEvent) => {
116
+ if (dragged !== null) editor.moveStop(dragged, positionAt(event))
117
+ }
118
+
119
+ const onPointerUp = async () => {
120
+ if (dragged === null) return
121
+ const id = dragged
122
+ dragged = null
123
+ editor.settle()
124
+ await focusStop(id)
125
+ }
126
+
127
+ const add = () => {
128
+ if (editor.canAdd.value) emit('add')
129
+ }
130
+
131
+ const KEY_MOVES: Record<string, (position: number, shift: boolean) => number> = {
132
+ ArrowLeft: (p, shift) => p - (shift ? STOP_STEP : 1),
133
+ ArrowDown: (p, shift) => p - (shift ? STOP_STEP : 1),
134
+ ArrowRight: (p, shift) => p + (shift ? STOP_STEP : 1),
135
+ ArrowUp: (p, shift) => p + (shift ? STOP_STEP : 1),
136
+ PageUp: (p) => p + STOP_STEP,
137
+ PageDown: (p) => p - STOP_STEP,
138
+ Home: () => 0,
139
+ End: () => 100,
140
+ }
141
+
142
+ const onHandleKeydown = async (event: KeyboardEvent, stop: GradientEditorStop) => {
143
+ if (event.key === 'Delete' || event.key === 'Backspace') {
144
+ event.preventDefault()
145
+ if (editor.canRemove.value) emit('remove', stop.id)
146
+ return
147
+ }
148
+ const move = KEY_MOVES[event.key]
149
+ if (!move) return
150
+ event.preventDefault()
151
+ editor.moveStop(stop.id, move(stop.position, event.shiftKey))
152
+ editor.settle()
153
+ await focusStop(stop.id)
154
+ }
155
+ </script>
156
+
157
+ <template>
158
+ <!-- `mx-2.5`: half a handle, so a stop at 0 % or 100 % stays inside the column. -->
159
+ <div
160
+ ref="bar"
161
+ data-gradient-bar
162
+ class="relative mx-2.5 h-5 touch-none select-none"
163
+ :class="editor.canAdd.value ? 'cursor-copy' : 'cursor-default'"
164
+ @pointerdown="onBarPointerDown"
165
+ @pointermove="onPointerMove"
166
+ @pointerup="onPointerUp"
167
+ @lostpointercapture="onPointerUp"
168
+ >
169
+ <div class="absolute inset-x-0 inset-y-1 rounded-full" :style="barStyle" />
170
+ <span
171
+ v-for="stop in editor.stops.value"
172
+ :key="stop.id"
173
+ :ref="handleRef(stop.id)"
174
+ role="slider"
175
+ tabindex="0"
176
+ aria-orientation="horizontal"
177
+ aria-valuemin="0"
178
+ aria-valuemax="100"
179
+ :aria-label="nameOf(stop)"
180
+ :aria-valuenow="stop.position"
181
+ :aria-valuetext="valueTextOf(stop)"
182
+ :data-selected="stop.id === editor.selectedId.value || undefined"
183
+ class="absolute top-0 size-5 -translate-x-1/2 cursor-grab rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)] outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 active:cursor-grabbing"
184
+ :class="{ 'z-10 ring-2 ring-cornflower-blue-600': stop.id === editor.selectedId.value }"
185
+ :style="handleStyle(stop)"
186
+ @pointerdown="onHandlePointerDown($event, stop.id)"
187
+ @pointermove="onPointerMove"
188
+ @pointerup="onPointerUp"
189
+ @lostpointercapture="onPointerUp"
190
+ @focus="editor.select(stop.id)"
191
+ @keydown="onHandleKeydown($event, stop)"
192
+ />
193
+ </div>
194
+
195
+ <div class="flex h-6 items-center gap-1">
196
+ <p class="min-w-0 flex-1 extra-small-normal text-gray-900">
197
+ {{ copy.stops }} <span class="text-gray-700">{{ copy.max }}</span>
198
+ </p>
199
+ <!--
200
+ `aria-disabled`, not `disabled`: the button stays focusable, so the reason
201
+ it does nothing can be heard.
202
+ -->
203
+ <button
204
+ type="button"
205
+ data-gradient-add
206
+ :aria-label="copy.addName"
207
+ :aria-disabled="editor.canAdd.value ? undefined : 'true'"
208
+ :aria-describedby="editor.canAdd.value ? undefined : REASON_ID"
209
+ class="flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 extra-small-normal text-gray-900 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cornflower-blue-600"
210
+ :class="!editor.canAdd.value && 'cursor-not-allowed opacity-50 hover:bg-transparent'"
211
+ @click="add"
212
+ >
213
+ <NexxtIcon name="plus" aria-hidden="true" />
214
+ {{ copy.add }}
215
+ </button>
216
+ <span :id="REASON_ID" hidden>{{ copy.maxReason }}</span>
217
+ </div>
218
+ </template>
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The gradient of NexxtColorPicker's gradient mode, as plain data. The picker
3
+ * knows no class names: a wrapper such as NexxtEditorColor turns this into
4
+ * whatever it stores.
5
+ */
6
+
7
+ /** A named colour a stop can be bound to, such as a theme role. Always opaque. */
8
+ export interface ColorPickerPreset {
9
+ /** What the wrapper knows the preset by, such as `'main'`. */
10
+ key: string
11
+ /** Name of the preset as the user reads it, announced on the stop's handle. */
12
+ label: string
13
+ }
14
+
15
+ export interface ColorPickerGradientStop {
16
+ /** The colour as `#rrggbb`, or `#rrggbbaa` below full opacity; lower case. */
17
+ color: string
18
+ /** Whole percentage, 0–100. */
19
+ position: number
20
+ /** Set when the stop is bound to a preset; `color` is then the preset's hex. */
21
+ preset?: ColorPickerPreset
22
+ }
23
+
24
+ export interface ColorPickerGradient {
25
+ type: 'linear' | 'radial'
26
+ /** Whole degrees, 0–359, clockwise from the top. Only used by a linear gradient. */
27
+ angle: number
28
+ /** Centre in whole percentages from the left. Only used by a radial gradient. */
29
+ centerX: number
30
+ /** Centre in whole percentages from the top. Only used by a radial gradient. */
31
+ centerY: number
32
+ /** Two or three stops, in order of position. */
33
+ stops: ColorPickerGradientStop[]
34
+ }
35
+
36
+ export const MIN_STOPS = 2
37
+ export const MAX_STOPS = 3
38
+ export const DEFAULT_ANGLE = 180
39
+ export const DEFAULT_CENTER = 50
40
+
41
+ /** What the gradient mode shows while there is no readable gradient. */
42
+ export const DEFAULT_GRADIENT: ColorPickerGradient = {
43
+ type: 'linear',
44
+ angle: DEFAULT_ANGLE,
45
+ centerX: DEFAULT_CENTER,
46
+ centerY: DEFAULT_CENTER,
47
+ stops: [
48
+ { color: '#00000000', position: 0 },
49
+ { color: '#00000099', position: 100 },
50
+ ],
51
+ }
52
+
53
+ /** Rounds and clamps to a whole percentage. */
54
+ export const clampPercent = (value: number) => Math.min(100, Math.max(0, Math.round(value)))
55
+
56
+ /** Rounds and wraps to a whole angle, 0–359: 360 is 0 and -10 is 350. */
57
+ export const wrapAngle = (value: number) => ((Math.round(value) % 360) + 360) % 360
58
+
59
+ /** A stop as CSS needs it: any CSS colour, and a position in percent. */
60
+ export interface CssGradientStop {
61
+ color: string
62
+ position: number
63
+ }
64
+
65
+ /** The shape of a gradient as CSS needs it. */
66
+ export type CssGradientShape =
67
+ { type: 'linear'; angle: number } | { type: 'radial'; centerX: number; centerY: number }
68
+
69
+ const stopList = (stops: CssGradientStop[]) =>
70
+ stops.map(({ color, position }) => `${color} ${position}%`).join(', ')
71
+
72
+ /**
73
+ * A gradient as a CSS `background-image`, always interpolated in oklab and with
74
+ * every position written. This is how the consuming app draws a gradient class
75
+ * (as inline CSS), so the preview shows exactly what the app renders. For
76
+ * `bg-linear-*` and `bg-radial` it is also what the Tailwind class renders;
77
+ * for `bg-radial-[at_…]` Tailwind would interpolate in sRGB, the app does not.
78
+ * The position comes before the interpolation method, as in
79
+ * `radial-gradient(at 30% 70% in oklab, …)`; a centred radial gradient leaves
80
+ * the position out.
81
+ */
82
+ export const buildGradientCss = (shape: CssGradientShape, stops: CssGradientStop[]) => {
83
+ const list = stopList(stops)
84
+ if (shape.type === 'linear') return `linear-gradient(${shape.angle}deg in oklab, ${list})`
85
+ return shape.centerX === DEFAULT_CENTER && shape.centerY === DEFAULT_CENTER
86
+ ? `radial-gradient(in oklab, ${list})`
87
+ : `radial-gradient(at ${shape.centerX}% ${shape.centerY}% in oklab, ${list})`
88
+ }
89
+
90
+ /** The picker's gradient as CSS; see `buildGradientCss`. */
91
+ export const gradientCss = (gradient: ColorPickerGradient) =>
92
+ buildGradientCss(
93
+ gradient.type === 'linear'
94
+ ? { type: 'linear', angle: gradient.angle }
95
+ : { type: 'radial', centerX: gradient.centerX, centerY: gradient.centerY },
96
+ gradient.stops,
97
+ )
98
+
99
+ /** The stops left to right in oklab, for the stop bar. */
100
+ export const stopBarCss = (gradient: ColorPickerGradient) =>
101
+ `linear-gradient(to right in oklab, ${stopList(gradient.stops)})`
102
+
103
+ /** The checkerboard that shows through a translucent colour. */
104
+ export const CHECKERBOARD = 'repeating-conic-gradient(#e5e7eb 0% 25%, #ffffff 0% 50%)'
105
+
106
+ // ── Colour maths ──────────────────────────────────────────────────────────
107
+
108
+ type Rgba = [r: number, g: number, b: number, a: number]
109
+
110
+ const parseHex = (hex: string): Rgba => {
111
+ const value = hex.replace('#', '')
112
+ const channel = (index: number) => parseInt(value.slice(index, index + 2), 16) / 255
113
+ return [channel(0), channel(2), channel(4), value.length === 8 ? channel(6) : 1]
114
+ }
115
+
116
+ const toHexPair = (value: number) =>
117
+ Math.round(Math.min(1, Math.max(0, value)) * 255)
118
+ .toString(16)
119
+ .padStart(2, '0')
120
+
121
+ /** `#rrggbb`, or `#rrggbbaa` below full opacity, in lower case. */
122
+ export const formatHex = ([r, g, b, a]: Rgba) => {
123
+ const alpha = toHexPair(a)
124
+ return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}${alpha === 'ff' ? '' : alpha}`
125
+ }
126
+
127
+ /** A hex in the one spelling the picker reports: lower case, without an opaque `ff`. */
128
+ export const normalizeHex = (hex: string) => formatHex(parseHex(hex))
129
+
130
+ const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
131
+ const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
132
+
133
+ const srgbToOklab = (r: number, g: number, b: number): [number, number, number] => {
134
+ const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)]
135
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
136
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
137
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
138
+ return [
139
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
140
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
141
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
142
+ ]
143
+ }
144
+
145
+ const oklabToSrgb = (L: number, A: number, B: number): [number, number, number] => {
146
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
147
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
148
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
149
+ return [
150
+ fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
151
+ fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
152
+ fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
153
+ ]
154
+ }
155
+
156
+ /**
157
+ * Mixes two colours the way a CSS gradient does: in the given space, with
158
+ * premultiplied alpha, so a fade from a transparent black to a colour keeps the
159
+ * colour's hue all the way.
160
+ */
161
+ export const mixColors = (from: string, to: string, t: number, space: 'oklab' | 'srgb') => {
162
+ const [r1, g1, b1, a1] = parseHex(from)
163
+ const [r2, g2, b2, a2] = parseHex(to)
164
+ const convert = space === 'oklab' ? srgbToOklab : (r: number, g: number, b: number) => [r, g, b]
165
+ const c1 = convert(r1, g1, b1)
166
+ const c2 = convert(r2, g2, b2)
167
+ const alpha = a1 + (a2 - a1) * t
168
+ const mixed = [0, 1, 2].map((index) => {
169
+ const premultiplied =
170
+ (c1[index] ?? 0) * a1 + ((c2[index] ?? 0) * a2 - (c1[index] ?? 0) * a1) * t
171
+ return alpha === 0 ? 0 : premultiplied / alpha
172
+ })
173
+ const [x = 0, y = 0, z = 0] = mixed
174
+ const [r, g, b] = space === 'oklab' ? oklabToSrgb(x, y, z) : [x, y, z]
175
+ return formatHex([r, g, b, alpha])
176
+ }
177
+
178
+ /**
179
+ * The colour the gradient shows at `position`, as a hex: what a stop added
180
+ * there needs so that the gradient does not change. Interpolated in oklab, as
181
+ * every gradient is drawn. Before the first stop and after the last the
182
+ * gradient is flat.
183
+ */
184
+ export const colorAt = (stops: ColorPickerGradientStop[], position: number) => {
185
+ const first = stops[0]
186
+ const last = stops[stops.length - 1]
187
+ if (!first || !last) return '#000000'
188
+ if (position <= first.position) return normalizeHex(first.color)
189
+ if (position >= last.position) return normalizeHex(last.color)
190
+ const after = stops.findIndex((stop) => stop.position > position)
191
+ const right = stops[after]
192
+ const left = stops[after - 1]
193
+ if (!left || !right) return normalizeHex(last.color)
194
+ const t = (position - left.position) / (right.position - left.position)
195
+ return mixColors(left.color, right.color, t, 'oklab')
196
+ }
197
+
198
+ // ── Copy ──────────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Copy of the gradient mode. Every key is optional and merged over the Dutch
202
+ * defaults. `{n}`, `{total}`, `{x}` and `{y}` are filled in by the picker.
203
+ */
204
+ export interface ColorPickerGradientMessages {
205
+ /** Accessible name of the solid/gradient switch. Defaults to `'Vulling'`. */
206
+ mode?: string
207
+ /** Solid option of the switch. Defaults to `'Effen'`. */
208
+ solid?: string
209
+ /** Gradient option of the switch. Defaults to `'Verloop'`. */
210
+ gradient?: string
211
+ /** Accessible name of the type switch. Defaults to `'Type verloop'`. */
212
+ type?: string
213
+ /** Linear option of the type switch. Defaults to `'Lineair'`. */
214
+ linear?: string
215
+ /** Radial option of the type switch. Defaults to `'Radiaal'`. */
216
+ radial?: string
217
+ /** Accessible name of the angle point in the preview. Defaults to `'Hoek'`. */
218
+ angle?: string
219
+ /** Value text of the angle point. Defaults to `'{n} graden'`. */
220
+ angleValue?: string
221
+ /** Accessible name of the centre point in the preview. Defaults to `'Middelpunt'`. */
222
+ center?: string
223
+ /** Value text of the centre point. Defaults to `'{x} % van links, {y} % van boven'`. */
224
+ centerValue?: string
225
+ /** Accessible name of the reverse button. Defaults to `'Kleurstops omdraaien'`. */
226
+ reverse?: string
227
+ /** Heading above the stop list. Defaults to `'Kleurstops'`. */
228
+ stops?: string
229
+ /** Hint next to the heading. Defaults to `'max. 3'`. */
230
+ max?: string
231
+ /** Why no stop can be added, announced on the add button. Defaults to `'Een verloop heeft maximaal 3 kleurstops.'`. */
232
+ maxReason?: string
233
+ /** Visible text of the add button. Defaults to `'Toevoegen'`. */
234
+ add?: string
235
+ /** Accessible name of the add button. Defaults to `'Kleurstop toevoegen'`. */
236
+ addName?: string
237
+ /** Name of a stop's row and of its handle on the bar. Defaults to `'Kleurstop {n} van {total}'`. */
238
+ stop?: string
239
+ /** Accessible name of a row's position field. Defaults to `'Positie (%)'`. */
240
+ position?: string
241
+ /** Accessible name of a row's colour button. Defaults to `'Kleur kiezen'`. */
242
+ pick?: string
243
+ /** Accessible name of a row's hex field. Defaults to `'Hex'`. */
244
+ hex?: string
245
+ /** Accessible name of a row's remove button. Defaults to `'Verwijderen'`. */
246
+ remove?: string
247
+ /** Accessible name of a stop's colour popover. Defaults to `'Kleur van kleurstop {n}'`. */
248
+ dialog?: string
249
+ /** Opacity part of a handle's value text. Defaults to `'dekking {n} %'`. */
250
+ stopOpacity?: string
251
+ /** Hint under the locked opacity slider of a stop bound to a preset. Defaults to `'Huisstijlkleuren zijn altijd dekkend.'`. */
252
+ roleOpaque?: string
253
+ /** Trigger text for a linear gradient. Defaults to `'Lineair verloop'`. */
254
+ triggerLinear?: string
255
+ /** Trigger text for a radial gradient. Defaults to `'Radiaal verloop'`. */
256
+ triggerRadial?: string
257
+ /** Name of the trigger swatch for a linear gradient. Defaults to `'Geselecteerd verloop: lineair, {n} kleuren'`. */
258
+ swatchLinear?: string
259
+ /** Name of the trigger swatch for a radial gradient. Defaults to `'Geselecteerd verloop: radiaal, {n} kleuren'`. */
260
+ swatchRadial?: string
261
+ /** Name of the trigger swatch while there is no gradient. Defaults to `'Geen kleur geselecteerd'`. */
262
+ swatchEmpty?: string
263
+ }
264
+
265
+ export const DEFAULT_GRADIENT_MESSAGES = {
266
+ mode: 'Vulling',
267
+ solid: 'Effen',
268
+ gradient: 'Verloop',
269
+ type: 'Type verloop',
270
+ linear: 'Lineair',
271
+ radial: 'Radiaal',
272
+ angle: 'Hoek',
273
+ angleValue: '{n} graden',
274
+ center: 'Middelpunt',
275
+ centerValue: '{x} % van links, {y} % van boven',
276
+ reverse: 'Kleurstops omdraaien',
277
+ stops: 'Kleurstops',
278
+ max: 'max. 3',
279
+ maxReason: 'Een verloop heeft maximaal 3 kleurstops.',
280
+ add: 'Toevoegen',
281
+ addName: 'Kleurstop toevoegen',
282
+ stop: 'Kleurstop {n} van {total}',
283
+ position: 'Positie (%)',
284
+ pick: 'Kleur kiezen',
285
+ hex: 'Hex',
286
+ remove: 'Verwijderen',
287
+ dialog: 'Kleur van kleurstop {n}',
288
+ stopOpacity: 'dekking {n} %',
289
+ roleOpaque: 'Huisstijlkleuren zijn altijd dekkend.',
290
+ triggerLinear: 'Lineair verloop',
291
+ triggerRadial: 'Radiaal verloop',
292
+ swatchLinear: 'Geselecteerd verloop: lineair, {n} kleuren',
293
+ swatchRadial: 'Geselecteerd verloop: radiaal, {n} kleuren',
294
+ swatchEmpty: 'Geen kleur geselecteerd',
295
+ } satisfies Required<ColorPickerGradientMessages>
296
+
297
+ export type GradientCopy = typeof DEFAULT_GRADIENT_MESSAGES
298
+
299
+ /** Fills `{key}` placeholders in a message. */
300
+ export const fillMessage = (message: string, values: Record<string, string | number>) =>
301
+ message.replace(/\{(\w+)\}/g, (match, key: string) =>
302
+ key in values ? String(values[key]) : match,
303
+ )