@nexxtmove/ui 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +6 -0
  2. package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
  3. package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +106 -0
  4. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +49 -20
  5. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
  6. package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
  7. package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
  8. package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
  9. package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
  10. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
  11. package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +7 -0
  12. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +61 -2
  13. package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
  14. package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
  15. package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
  16. package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
  17. package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
  18. package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
  19. package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
  20. package/dist/index.d.ts +7 -1
  21. package/dist/index.js +3591 -2182
  22. package/dist/nuxt.js +1 -0
  23. package/package.json +1 -1
  24. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +15 -2
  25. package/src/components/editor/molecules/EditorBlur/EditorBlur.vue +160 -0
  26. package/src/components/editor/molecules/EditorColor/EditorColor.vue +90 -171
  27. package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
  28. package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
  29. package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
  30. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +18 -3
  31. package/src/components/organisms/ColorPicker/ColorPicker.vue +686 -257
  32. package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
  33. package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
  34. package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
  35. package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
  36. package/src/components/organisms/ColorPicker/gradient.ts +303 -0
  37. package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
  38. package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
  39. package/src/components.json +1 -0
  40. package/src/index.ts +32 -0
@@ -0,0 +1,93 @@
1
+ <script lang="ts" setup>
2
+ import { ref, useTemplateRef, watch } from 'vue'
3
+
4
+ export interface NexxtGradientFieldProps {
5
+ /** The applied value, shown while the field is not being edited. */
6
+ value: string
7
+ /** Accessible name of the input. */
8
+ name: string
9
+ /** Whether typed text can be applied; text that cannot is never committed. */
10
+ validate: (text: string) => boolean
11
+ /** Ask for the numeric keypad. */
12
+ numeric?: boolean
13
+ /** Show the value without editing it, such as a role name in place of a hex. */
14
+ readonly?: boolean
15
+ }
16
+
17
+ /**
18
+ * A text input of the stop list that applies its value on blur or Enter, not
19
+ * per keystroke. Escape with unapplied typing puts the value back first; only
20
+ * then does it reach the panel. Invalid text is put back on blur; on Enter it
21
+ * stays, and `invalid` reports it so the parent can mark the field.
22
+ */
23
+ defineOptions({ name: 'NexxtGradientField', inheritAttrs: false })
24
+
25
+ const { value, validate } = defineProps<NexxtGradientFieldProps>()
26
+
27
+ const emit = defineEmits<{
28
+ /** The typed text, valid, on blur or Enter. */
29
+ commit: [text: string]
30
+ /** Whether the text that was last tried could not be applied. */
31
+ invalid: [invalid: boolean]
32
+ }>()
33
+
34
+ const input = useTemplateRef<HTMLInputElement>('input')
35
+ const draft = ref(value)
36
+
37
+ // A new value from outside replaces the draft, unless the user is typing in
38
+ // this field: then only a draft that still shows the old value is replaced.
39
+ watch(
40
+ () => value,
41
+ (next, previous) => {
42
+ if (document.activeElement !== input.value || draft.value === previous) draft.value = next
43
+ },
44
+ )
45
+
46
+ const apply = (keepInvalid: boolean) => {
47
+ const typed = draft.value.trim()
48
+ if (typed === value) {
49
+ emit('invalid', false)
50
+ return
51
+ }
52
+ if (validate(typed)) {
53
+ emit('invalid', false)
54
+ emit('commit', typed)
55
+ draft.value = value
56
+ return
57
+ }
58
+ emit('invalid', keepInvalid)
59
+ if (!keepInvalid) draft.value = value
60
+ }
61
+
62
+ const onKeydown = (event: KeyboardEvent) => {
63
+ if (event.key === 'Enter') {
64
+ event.preventDefault()
65
+ apply(true)
66
+ return
67
+ }
68
+ if (event.key === 'Escape' && draft.value !== value) {
69
+ event.stopPropagation()
70
+ draft.value = value
71
+ emit('invalid', false)
72
+ }
73
+ }
74
+
75
+ defineExpose({ focus: () => input.value?.focus() })
76
+ </script>
77
+
78
+ <template>
79
+ <input
80
+ ref="input"
81
+ v-bind="$attrs"
82
+ type="text"
83
+ :inputmode="numeric ? 'numeric' : 'text'"
84
+ autocomplete="off"
85
+ spellcheck="false"
86
+ :readonly="readonly"
87
+ :aria-label="name"
88
+ :value="draft"
89
+ @input="draft = ($event.target as HTMLInputElement).value"
90
+ @blur="apply(false)"
91
+ @keydown="onKeydown"
92
+ />
93
+ </template>
@@ -0,0 +1,401 @@
1
+ <script lang="ts">
2
+ /** The step of Shift (and PageUp/PageDown) on the angle point, in degrees. */
3
+ const ANGLE_STEP = 15
4
+ /** The step of Shift on the centre point, in percent. */
5
+ const CENTER_STEP = 10
6
+ /** Two linear end points closer than this (in px) keep the previous angle. */
7
+ const MIN_DISTANCE = 8
8
+ /** A pointer that moves less than this (in px) between down and up is a click. */
9
+ const CLICK_SLOP = 4
10
+
11
+ const POINT_CLASS =
12
+ 'absolute size-5 -translate-1/2 touch-none 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'
13
+ </script>
14
+
15
+ <script lang="ts" setup>
16
+ import { computed, ref, useTemplateRef } from 'vue'
17
+ import { CHECKERBOARD, fillMessage, gradientCss } from './gradient'
18
+ import type { GradientCopy } from './gradient'
19
+ import {
20
+ clampToSquare,
21
+ isInSquare,
22
+ lerpPoint,
23
+ linearHandlePoint,
24
+ linearFromPoints,
25
+ linearPoint,
26
+ projectOnSegment,
27
+ radialAngle,
28
+ radialEllipse,
29
+ radialPoint,
30
+ radialPosition,
31
+ radialRay,
32
+ viaFraction,
33
+ } from './gradientGeometry'
34
+ import type { Point } from './gradientGeometry'
35
+ import type { GradientEditor, GradientEditorStop } from './useGradientEditor'
36
+
37
+ export interface NexxtGradientPreviewProps {
38
+ /** The gradient state of the picker. */
39
+ editor: GradientEditor
40
+ /** Copy, already merged over the defaults. */
41
+ copy: GradientCopy
42
+ }
43
+
44
+ /**
45
+ * The preview: a square that renders the same CSS as the class, with a point
46
+ * per stop. Linear: the points lie on the gradient line, and dragging the
47
+ * from- or to-point sets the angle and the positions at once. Radial: a centre
48
+ * point, and the stops on one ray from it. The square is the full content
49
+ * width. A dragged point is clamped onto it, and a linear handle whose canonical
50
+ * point lies outside (0/100 % at angles off the axes) is drawn where the
51
+ * gradient line leaves the square. Geometry is computed with a side of 1 (`gradientGeometry`).
52
+ */
53
+ defineOptions({ name: 'NexxtGradientPreview' })
54
+
55
+ const { editor, copy } = defineProps<NexxtGradientPreviewProps>()
56
+
57
+ const square = useTemplateRef<HTMLElement>('square')
58
+
59
+ /** The side of the square in px, for the thresholds; 224 until measured. */
60
+ const sideInPx = () => square.value?.getBoundingClientRect().width || 224
61
+
62
+ /** What the model holds, as far as the points are concerned. */
63
+ const geometryKey = computed(() =>
64
+ JSON.stringify([
65
+ editor.type.value,
66
+ editor.angle.value,
67
+ editor.centerX.value,
68
+ editor.centerY.value,
69
+ editor.sorted.value.map((stop) => [stop.id, stop.position]),
70
+ ]),
71
+ )
72
+
73
+ /**
74
+ * Points left where a drag released them. They stand for the same positions as
75
+ * the canonical ones, so they are only replaced once the gradient changes in
76
+ * another way (`geometryKey`).
77
+ */
78
+ const released = ref<{ key: string; points: Map<number, Point> } | null>(null)
79
+
80
+ /**
81
+ * The ray of a radial gradient for this session, as dragged; unset points at
82
+ * the farthest corner. It only holds while the farthest stop stays inside the
83
+ * square (`radialRay`).
84
+ */
85
+ const psi = ref<number | null>(null)
86
+
87
+ const ellipse = computed(() => radialEllipse(editor.centerX.value, editor.centerY.value, 1))
88
+ const ray = computed(() =>
89
+ radialRay(
90
+ ellipse.value,
91
+ psi.value,
92
+ Math.max(0, ...editor.stops.value.map((stop) => stop.position)),
93
+ 1,
94
+ ),
95
+ )
96
+
97
+ const canonicalPoint = (stop: GradientEditorStop): Point =>
98
+ editor.type.value === 'linear'
99
+ ? linearPoint(editor.angle.value, stop.position, 1)
100
+ : radialPoint(ellipse.value, ray.value, stop.position)
101
+
102
+ /** The point a stop stands for: where a drag left it, or its canonical point. */
103
+ const pointOf = (stop: GradientEditorStop) => {
104
+ const kept = released.value
105
+ return (kept?.key === geometryKey.value && kept.points.get(stop.id)) || canonicalPoint(stop)
106
+ }
107
+
108
+ /**
109
+ * Where a stop's handle is drawn: always inside the square. A released point
110
+ * already is; a linear canonical point outside goes onto the square's edge,
111
+ * along its own line. Radial points stay inside through `radialRay`.
112
+ */
113
+ const handlePoint = (stop: GradientEditorStop) => {
114
+ const kept = released.value
115
+ const point = kept?.key === geometryKey.value ? kept.points.get(stop.id) : undefined
116
+ if (point) return point
117
+ return editor.type.value === 'linear'
118
+ ? linearHandlePoint(editor.angle.value, stop.position, 1)
119
+ : canonicalPoint(stop)
120
+ }
121
+
122
+ const styleAt = (point: Point) => ({ left: `${point.x * 100}%`, top: `${point.y * 100}%` })
123
+
124
+ const previewStyle = computed(() => ({ backgroundImage: gradientCss(editor.snapshot.value) }))
125
+
126
+ /** from, via and to, in order of position. */
127
+ const roles = computed(() => {
128
+ const order = editor.sorted.value
129
+ return {
130
+ from: order[0],
131
+ via: order.length === 3 ? order[1] : undefined,
132
+ to: order[order.length - 1],
133
+ }
134
+ })
135
+
136
+ const points = computed(() =>
137
+ editor.stops.value.map((stop) => ({
138
+ stop,
139
+ style: {
140
+ ...styleAt(handlePoint(stop)),
141
+ backgroundImage: `linear-gradient(${stop.color}, ${stop.color}), ${CHECKERBOARD}`,
142
+ backgroundSize: 'auto, 8px 8px',
143
+ },
144
+ angle: editor.type.value === 'linear' && stop === roles.value.to,
145
+ selected: !editor.centerSelected.value && stop.id === editor.selectedId.value,
146
+ })),
147
+ )
148
+
149
+ const centerStyle = computed(() =>
150
+ styleAt({ x: editor.centerX.value / 100, y: editor.centerY.value / 100 }),
151
+ )
152
+
153
+ const angleText = computed(() => fillMessage(copy.angleValue, { n: editor.angle.value }))
154
+ const centerText = computed(() =>
155
+ fillMessage(copy.centerValue, { x: editor.centerX.value, y: editor.centerY.value }),
156
+ )
157
+
158
+ /** A pointer position in the square's own units (side 1), clamped onto the square. */
159
+ const toSquare = (event: PointerEvent): Point | null => {
160
+ const rect = square.value?.getBoundingClientRect()
161
+ if (!rect || rect.width === 0) return null
162
+ return clampToSquare(
163
+ { x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height },
164
+ 1,
165
+ )
166
+ }
167
+
168
+ /** Puts a stop's point at `point`, and writes what that means for the gradient. */
169
+ const placeStop = (stop: GradientEditorStop, point: Point, viaShare: number) => {
170
+ const { from, via, to } = roles.value
171
+ if (!from || !to) return
172
+
173
+ if (editor.type.value === 'radial') {
174
+ psi.value = radialAngle(ellipse.value, point)
175
+ const position = radialPosition(ellipse.value, point)
176
+ const positions = new Map<number, number>()
177
+ if (stop === via) {
178
+ positions.set(stop.id, Math.min(to.position, Math.max(from.position, position)))
179
+ } else {
180
+ positions.set(stop.id, position)
181
+ const start = stop === from ? position : from.position
182
+ const end = stop === to ? position : to.position
183
+ if (via) positions.set(via.id, Math.round(start + viaShare * (end - start)))
184
+ }
185
+ editor.setGeometry(positions)
186
+ released.value = null
187
+ return
188
+ }
189
+
190
+ let start = pointOf(from)
191
+ let end = pointOf(to)
192
+ let share = viaShare
193
+ if (stop === from) start = point
194
+ else if (stop === to) end = point
195
+ else share = projectOnSegment(point, start, end)
196
+
197
+ const result = linearFromPoints({
198
+ from: start,
199
+ to: end,
200
+ viaFraction: via ? share : undefined,
201
+ previousAngle: editor.angle.value,
202
+ size: 1,
203
+ minDistance: MIN_DISTANCE / sideInPx(),
204
+ })
205
+ const positions = new Map([
206
+ [from.id, result.from],
207
+ [to.id, result.to],
208
+ ])
209
+ if (via && result.via !== undefined) positions.set(via.id, result.via)
210
+ editor.setGeometry(positions, result.angle)
211
+
212
+ // Only points inside the square stay where they were left; one that is not
213
+ // (the canonical point of the end that was not dragged) is drawn on the edge.
214
+ const kept = new Map<number, Point>()
215
+ const candidates: [number, Point][] = [
216
+ [from.id, start],
217
+ [to.id, end],
218
+ ]
219
+ if (via) candidates.push([via.id, lerpPoint(start, end, share)])
220
+ candidates.forEach(([id, candidate]) => {
221
+ if (isInSquare(candidate, 1)) kept.set(id, candidate)
222
+ })
223
+ released.value = { key: geometryKey.value, points: kept }
224
+ }
225
+
226
+ const placeCenter = (point: Point) => editor.setCenter(point.x * 100, point.y * 100)
227
+
228
+ /** What a pointer is doing, from down to up. */
229
+ let gesture: {
230
+ target: 'zone' | 'center' | number
231
+ start: { x: number; y: number }
232
+ viaShare: number
233
+ moved: boolean
234
+ } | null = null
235
+
236
+ const currentViaShare = () => {
237
+ const { from, via, to } = roles.value
238
+ return via && from && to ? viaFraction(from.position, via.position, to.position) : 0.5
239
+ }
240
+
241
+ const onPointerDown = (event: PointerEvent, target: 'zone' | 'center' | number) => {
242
+ if (event.button !== 0) return
243
+ event.preventDefault()
244
+ event.stopPropagation()
245
+ const element = event.currentTarget as HTMLElement
246
+ element.setPointerCapture?.(event.pointerId)
247
+ if (target === 'center') {
248
+ editor.selectCenter()
249
+ element.focus()
250
+ } else if (typeof target === 'number') {
251
+ editor.select(target)
252
+ // Only the angle point takes the focus; the others are hidden from the keyboard.
253
+ if (element.tabIndex === 0) element.focus()
254
+ }
255
+ gesture = {
256
+ target,
257
+ start: { x: event.clientX, y: event.clientY },
258
+ viaShare: currentViaShare(),
259
+ moved: false,
260
+ }
261
+ }
262
+
263
+ const applyGesture = (event: PointerEvent) => {
264
+ if (!gesture) return
265
+ const point = toSquare(event)
266
+ if (!point) return
267
+ const { target, viaShare } = gesture
268
+ if (target === 'center') placeCenter(point)
269
+ else if (typeof target === 'number') {
270
+ const stop = editor.stops.value.find((candidate) => candidate.id === target)
271
+ if (stop) placeStop(stop, point, viaShare)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * A pointer that is cancelled or loses its capture ends the gesture. A
277
+ * function, not an inline `gesture = null`: outside inline-template mode the
278
+ * template writes to `$setup`, which a plain `let` never sees.
279
+ */
280
+ const endGesture = () => {
281
+ gesture = null
282
+ }
283
+
284
+ const onPointerMove = (event: PointerEvent) => {
285
+ if (!gesture) return
286
+ const distance = Math.hypot(event.clientX - gesture.start.x, event.clientY - gesture.start.y)
287
+ if (distance >= CLICK_SLOP) gesture.moved = true
288
+ if (gesture.target !== 'zone' && gesture.moved) applyGesture(event)
289
+ }
290
+
291
+ /**
292
+ * A click without a drag on an empty spot puts the selected point there: the
293
+ * single-pointer alternative to dragging (WCAG 2.5.7).
294
+ */
295
+ const onPointerUp = (event: PointerEvent) => {
296
+ if (!gesture) return
297
+ const { target, moved, viaShare } = gesture
298
+ gesture = null
299
+ if (target !== 'zone' || moved) return
300
+ const point = toSquare(event)
301
+ if (!point) return
302
+ if (editor.type.value === 'radial' && editor.centerSelected.value) placeCenter(point)
303
+ else if (editor.selected.value) placeStop(editor.selected.value, point, viaShare)
304
+ }
305
+
306
+ const onAngleKeydown = (event: KeyboardEvent) => {
307
+ const step = event.shiftKey ? ANGLE_STEP : 1
308
+ const moves: Record<string, () => number> = {
309
+ ArrowLeft: () => editor.angle.value - step,
310
+ ArrowDown: () => editor.angle.value - step,
311
+ ArrowRight: () => editor.angle.value + step,
312
+ ArrowUp: () => editor.angle.value + step,
313
+ PageUp: () => editor.angle.value + ANGLE_STEP,
314
+ PageDown: () => editor.angle.value - ANGLE_STEP,
315
+ Home: () => 0,
316
+ }
317
+ const move = moves[event.key]
318
+ if (!move) return
319
+ event.preventDefault()
320
+ editor.setAngle(move())
321
+ }
322
+
323
+ const onCenterKeydown = (event: KeyboardEvent) => {
324
+ const step = event.shiftKey ? CENTER_STEP : 1
325
+ const x = editor.centerX.value
326
+ const y = editor.centerY.value
327
+ const moves: Record<string, [number, number]> = {
328
+ ArrowLeft: [x - step, y],
329
+ ArrowRight: [x + step, y],
330
+ ArrowUp: [x, y - step],
331
+ ArrowDown: [x, y + step],
332
+ }
333
+ const move = moves[event.key]
334
+ if (!move) return
335
+ event.preventDefault()
336
+ editor.setCenter(...move)
337
+ }
338
+ </script>
339
+
340
+ <template>
341
+ <div
342
+ ref="square"
343
+ data-gradient-zone
344
+ data-gradient-preview
345
+ class="relative aspect-square w-full touch-none rounded-lg ring ring-gray-200 select-none"
346
+ :style="{ backgroundImage: CHECKERBOARD, backgroundSize: '10px 10px' }"
347
+ @pointerdown="onPointerDown($event, 'zone')"
348
+ @pointermove="onPointerMove"
349
+ @pointerup="onPointerUp"
350
+ >
351
+ <div class="absolute inset-0 rounded-lg" :style="previewStyle" />
352
+
353
+ <span
354
+ v-for="point in points"
355
+ :key="point.stop.id"
356
+ :role="point.angle ? 'slider' : undefined"
357
+ :tabindex="point.angle ? 0 : -1"
358
+ :aria-hidden="point.angle ? undefined : 'true'"
359
+ :aria-label="point.angle ? copy.angle : undefined"
360
+ :aria-valuemin="point.angle ? 0 : undefined"
361
+ :aria-valuemax="point.angle ? 359 : undefined"
362
+ :aria-valuenow="point.angle ? editor.angle.value : undefined"
363
+ :aria-valuetext="point.angle ? angleText : undefined"
364
+ :data-angle-end="point.angle || undefined"
365
+ :data-stop-point="point.stop.id"
366
+ :class="[
367
+ POINT_CLASS,
368
+ 'cursor-grab active:cursor-grabbing',
369
+ point.selected && 'z-10 ring-2 ring-cornflower-blue-600',
370
+ ]"
371
+ :style="point.style"
372
+ @pointerdown="onPointerDown($event, point.stop.id)"
373
+ @pointermove="onPointerMove"
374
+ @pointerup="onPointerUp"
375
+ @lostpointercapture="endGesture"
376
+ @keydown="point.angle && onAngleKeydown($event)"
377
+ />
378
+
379
+ <!-- The centre is a diamond, so it reads apart from the round stops. -->
380
+ <span
381
+ v-if="editor.type.value === 'radial'"
382
+ role="slider"
383
+ tabindex="0"
384
+ :aria-label="copy.center"
385
+ aria-valuemin="0"
386
+ aria-valuemax="100"
387
+ :aria-valuenow="editor.centerX.value"
388
+ :aria-valuetext="centerText"
389
+ data-gradient-center
390
+ class="absolute z-20 size-3.5 -translate-1/2 rotate-45 cursor-move touch-none border-2 border-white bg-gray-900 shadow-[0_0_0_1px_rgba(0,0,0,0.5)] outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2"
391
+ :class="editor.centerSelected.value && 'ring-2 ring-cornflower-blue-600'"
392
+ :style="centerStyle"
393
+ @pointerdown="onPointerDown($event, 'center')"
394
+ @pointermove="onPointerMove"
395
+ @pointerup="onPointerUp"
396
+ @lostpointercapture="endGesture"
397
+ @focus="editor.selectCenter()"
398
+ @keydown="onCenterKeydown"
399
+ />
400
+ </div>
401
+ </template>
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ const PERCENT = /^\d{1,3}$/
3
+ // Six digits, or eight with an alpha pair below full opacity -- the same hex as
4
+ // in the picker's own field.
5
+ const HEX = /^#?([0-9a-f]{6}(?:[0-9a-f]{2})?)$/i
6
+
7
+ const isPercent = (text: string) => PERCENT.test(text) && Number(text) <= 100
8
+ </script>
9
+
10
+ <script lang="ts" setup>
11
+ import { computed, nextTick, ref, useTemplateRef } from 'vue'
12
+ import NexxtFloatingPanel from '../../atoms/FloatingPanel/FloatingPanel.vue'
13
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
14
+ import NexxtGradientField from './_GradientField.vue'
15
+ import { CHECKERBOARD, fillMessage } from './gradient'
16
+ import type { GradientCopy } from './gradient'
17
+ import type { GradientEditor, GradientEditorStop } from './useGradientEditor'
18
+
19
+ export interface NexxtGradientStopRowProps {
20
+ /** The gradient state of the picker. */
21
+ editor: GradientEditor
22
+ /** The stop this row edits. */
23
+ stop: GradientEditorStop
24
+ /** Copy, already merged over the defaults. */
25
+ copy: GradientCopy
26
+ }
27
+
28
+ /**
29
+ * One stop in the list: its position, a colour button that opens the picker as
30
+ * a second popover, its hex (eight digits below full opacity; or the name of its
31
+ * preset), and a remove button. The opacity is edited in the popover. The popover is not teleported, so it stays in the outer
32
+ * panel's DOM: a click inside it does not close the outer panel, and Tab walks
33
+ * on. Its content is the picker's own editor, which the picker teleports into
34
+ * the element this row reports through `target`.
35
+ */
36
+ defineOptions({ name: 'NexxtGradientStopRow' })
37
+
38
+ const { editor, stop, copy } = defineProps<NexxtGradientStopRowProps>()
39
+
40
+ const emit = defineEmits<{
41
+ /** The element the colour editor should render in, or `null` once the popover closes. */
42
+ target: [element: HTMLElement | null]
43
+ /** The remove button was used; the parent removes the stop and moves the focus. */
44
+ remove: []
45
+ }>()
46
+
47
+ const positionField = useTemplateRef('positionField')
48
+ const swatchButton = useTemplateRef<HTMLButtonElement>('swatchButton')
49
+ const hexInvalid = ref(false)
50
+
51
+ defineExpose({ focusPosition: () => positionField.value?.focus() })
52
+
53
+ const order = computed(() => editor.sorted.value.indexOf(stop) + 1)
54
+ const groupLabel = computed(() =>
55
+ fillMessage(copy.stop, { n: order.value, total: editor.stops.value.length }),
56
+ )
57
+ const dialogLabel = computed(() => fillMessage(copy.dialog, { n: order.value }))
58
+
59
+ const swatchStyle = computed(() => ({
60
+ backgroundImage: `linear-gradient(${stop.color}, ${stop.color}), ${CHECKERBOARD}`,
61
+ backgroundSize: 'auto, 6px 6px',
62
+ }))
63
+
64
+ /**
65
+ * Re-sorting the rows moves their elements in the DOM, which drops the focus.
66
+ * The focused element itself survives the move, so it gets the focus back.
67
+ */
68
+ const keepingFocus = async (action: () => void) => {
69
+ const active = document.activeElement
70
+ action()
71
+ await nextTick()
72
+ if (active instanceof HTMLElement && document.activeElement !== active) active.focus()
73
+ }
74
+
75
+ const commitPosition = (text: string) =>
76
+ keepingFocus(() => {
77
+ editor.moveStop(stop.id, Number(text))
78
+ editor.settle()
79
+ })
80
+
81
+ /** A typed hex replaces the whole colour, opacity included; `ff` is dropped. */
82
+ const commitHex = (text: string) => {
83
+ const match = HEX.exec(text)
84
+ if (match) editor.setColorOf(stop.id, `#${match[1]}`)
85
+ }
86
+
87
+ const isHex = (text: string) => HEX.test(text)
88
+
89
+ const onDialogRef = (element: unknown) => {
90
+ emit('target', element instanceof HTMLElement ? element : null)
91
+ }
92
+
93
+ /**
94
+ * Escape closes this popover only, from inside it or from its button. FloatingPanel listens on `document`, so
95
+ * stopping the event here keeps the outer panel open; the focus goes back to
96
+ * the colour button.
97
+ */
98
+ const onDialogKeydown = (event: KeyboardEvent, close: () => void) => {
99
+ if (event.key !== 'Escape') return
100
+ event.stopPropagation()
101
+ close()
102
+ swatchButton.value?.focus()
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div
108
+ role="group"
109
+ :aria-label="groupLabel"
110
+ data-gradient-row
111
+ class="flex h-8 items-center gap-1"
112
+ @focusin="editor.select(stop.id)"
113
+ @pointerdown="editor.select(stop.id)"
114
+ >
115
+ <label
116
+ class="flex h-8 w-12 shrink-0 cursor-text items-center rounded-lg px-1.5 ring ring-gray-200 focus-within:ring-cornflower-blue-500"
117
+ >
118
+ <NexxtGradientField
119
+ ref="positionField"
120
+ :value="String(stop.position)"
121
+ :name="copy.position"
122
+ :validate="isPercent"
123
+ numeric
124
+ class="w-0 min-w-0 flex-1 bg-transparent text-right extra-small-normal text-gray-900 tabular-nums focus:outline-none"
125
+ @commit="commitPosition"
126
+ />
127
+ <span aria-hidden="true" class="extra-small-normal text-gray-700">%</span>
128
+ </label>
129
+
130
+ <div
131
+ class="flex h-8 min-w-0 flex-1 items-center gap-1 rounded-lg px-2 ring focus-within:ring-cornflower-blue-500"
132
+ :class="hexInvalid ? 'ring-brick-400' : 'ring-gray-200'"
133
+ >
134
+ <NexxtFloatingPanel placement="left-start" class="shrink-0">
135
+ <template #trigger="{ isOpen, toggle, close }">
136
+ <button
137
+ ref="swatchButton"
138
+ type="button"
139
+ :aria-label="copy.pick"
140
+ aria-haspopup="dialog"
141
+ :aria-expanded="isOpen"
142
+ class="block size-4 cursor-pointer rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.15)] outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2"
143
+ :style="swatchStyle"
144
+ @click="toggle"
145
+ @keydown="isOpen && onDialogKeydown($event, close)"
146
+ />
147
+ </template>
148
+ <template #content="{ close }">
149
+ <div
150
+ :ref="onDialogRef"
151
+ role="dialog"
152
+ :aria-label="dialogLabel"
153
+ class="w-70 rounded-xl bg-white p-4 shadow-lg ring ring-gray-200"
154
+ @keydown="onDialogKeydown($event, close)"
155
+ />
156
+ </template>
157
+ </NexxtFloatingPanel>
158
+
159
+ <NexxtGradientField
160
+ :value="stop.preset ? stop.preset.label : stop.color"
161
+ :name="copy.hex"
162
+ :validate="isHex"
163
+ :readonly="!!stop.preset"
164
+ class="ml-0.5 w-0 min-w-0 flex-1 truncate bg-transparent text-gray-900 focus:outline-none"
165
+ :class="stop.preset ? 'extra-small-normal' : 'extra-small-normal-mono'"
166
+ @commit="commitHex"
167
+ @invalid="hexInvalid = $event"
168
+ />
169
+ </div>
170
+
171
+ <button
172
+ type="button"
173
+ :aria-label="copy.remove"
174
+ :disabled="!editor.canRemove.value"
175
+ class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-gray-900 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cornflower-blue-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
176
+ @click="emit('remove')"
177
+ >
178
+ <NexxtIcon name="minus" aria-hidden="true" />
179
+ </button>
180
+ </div>
181
+ </template>