@nexxtmove/ui 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
- package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
- package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
- package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
- package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
- package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
- package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
- package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
- package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
- package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
- package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
- package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
- package/dist/components/organisms/VideoControls/VideoControls.vue.d.ts +8 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3400 -2083
- package/package.json +1 -1
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
- package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
- package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
- package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
- package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
- package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
- package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
- package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
- package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
- package/src/components/organisms/ColorPicker/gradient.ts +303 -0
- package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
- package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
- package/src/components/organisms/VideoControls/VideoControls.vue +15 -4
- package/src/index.ts +25 -0
|
@@ -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>
|