@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
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ColorClass, EditorColorRole, EditorColorThemeRole } from './colorClass'
|
|
3
|
+
import type { EditorColorGradientValue } from './gradientClass'
|
|
4
|
+
import type {
|
|
5
|
+
ColorPickerFillMode,
|
|
6
|
+
ColorPickerGradientMessages,
|
|
7
|
+
} from '../../../organisms/ColorPicker/ColorPicker.vue'
|
|
8
|
+
|
|
9
|
+
export interface ColorFieldProps<P extends string> {
|
|
10
|
+
/** The prefix of the class that is written, such as `'bg'` or `'from'`. */
|
|
11
|
+
property: P
|
|
12
|
+
/**
|
|
13
|
+
* The prefixes that are read as a colour. Defaults to `property` alone.
|
|
14
|
+
* NexxtEditorColor reads both `bg` and `text`, so a value with the other
|
|
15
|
+
* prefix is shown, and rewritten once `property` changes.
|
|
16
|
+
*/
|
|
17
|
+
accepts?: readonly P[]
|
|
18
|
+
/** Visible label above the trigger. Without it the placeholder names the trigger. */
|
|
19
|
+
label?: string
|
|
20
|
+
/** Id(s) set as `aria-describedby` on the trigger. */
|
|
21
|
+
describedby?: string
|
|
22
|
+
/** Text shown while no colour is set. */
|
|
23
|
+
placeholder: string
|
|
24
|
+
/** Label shown above the suggestions. */
|
|
25
|
+
suggestionsLabel: string
|
|
26
|
+
/** Label shown above the theme roles. */
|
|
27
|
+
themeLabel: string
|
|
28
|
+
/** Label above the opacity slider, and its accessible name. */
|
|
29
|
+
alphaLabel: string
|
|
30
|
+
/** Hex colours offered as one-click suggestions. */
|
|
31
|
+
suggestions?: string[]
|
|
32
|
+
/** Theme roles offered above the suggestions; an entry with an unreadable hex is left out. */
|
|
33
|
+
themeRoles?: EditorColorThemeRole[]
|
|
34
|
+
/** Offer an opacity slider, hidden while a theme role is selected. */
|
|
35
|
+
showAlpha?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* The fills offered: `['solid']` (the default), `['solid', 'gradient']` or
|
|
38
|
+
* `['gradient']`. A gradient only exists for `property` `'bg'`; for any other
|
|
39
|
+
* prefix only the solid colour is offered, also with `['gradient']`. The
|
|
40
|
+
* gradient is written as the class string of `gradientClass.ts`.
|
|
41
|
+
*/
|
|
42
|
+
modes?: readonly ColorPickerFillMode[]
|
|
43
|
+
/** Copy of the gradient mode, merged per key over the picker's defaults. */
|
|
44
|
+
gradientMessages?: ColorPickerGradientMessages
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What switching to the gradient mode writes when there is no colour to start from. */
|
|
48
|
+
const DEFAULT_GRADIENT_CLASS = 'bg-linear-180 from-[#00000000] to-[#00000099]'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The picker writes `#rrggbbaa` only below full opacity, but an alpha a hair
|
|
52
|
+
* under 1 still rounds to `ff`. That pair says nothing, so it is dropped.
|
|
53
|
+
*/
|
|
54
|
+
const OPAQUE_ALPHA_PATTERN = /^(#[0-9A-Fa-f]{6})[fF]{2}$/
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<script lang="ts" setup generic="P extends string">
|
|
58
|
+
import { computed, ref, watch } from 'vue'
|
|
59
|
+
import NexxtTooltip from '../../../atoms/Tooltip/Tooltip.vue'
|
|
60
|
+
import NexxtColorPicker from '../../../organisms/ColorPicker/ColorPicker.vue'
|
|
61
|
+
import type { ColorPickerGradient } from '../../../organisms/ColorPicker/ColorPicker.vue'
|
|
62
|
+
import { clampCenter } from '../../../organisms/ColorPicker/gradientGeometry'
|
|
63
|
+
import { COLOR_ROLES, ROLE_HEX_PATTERN, isHex, parseColorClass } from './colorClass'
|
|
64
|
+
import {
|
|
65
|
+
looksLikeGradientClass,
|
|
66
|
+
normalizeGradientHex,
|
|
67
|
+
parseGradientClass,
|
|
68
|
+
serializeGradientClass,
|
|
69
|
+
} from './gradientClass'
|
|
70
|
+
import type { GradientClass, GradientClassColor } from './gradientClass'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The colour picker of the editor: a hex with an optional opacity slider, or a
|
|
74
|
+
* theme role, and with `gradient` a gradient of those. The model is the class
|
|
75
|
+
* itself, built from `property`.
|
|
76
|
+
*
|
|
77
|
+
* `matchWidth` makes the panel as wide as the full-width trigger instead of the
|
|
78
|
+
* fixed `w-70` it defaults to. It works by setting a `min-width` on the floating
|
|
79
|
+
* element, so a panel in a container narrower than its content would still be
|
|
80
|
+
* wider than the trigger; in the settings column it is not.
|
|
81
|
+
*/
|
|
82
|
+
defineOptions({ name: 'NexxtColorField' })
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
property,
|
|
86
|
+
accepts,
|
|
87
|
+
label,
|
|
88
|
+
describedby,
|
|
89
|
+
placeholder,
|
|
90
|
+
suggestionsLabel,
|
|
91
|
+
themeLabel,
|
|
92
|
+
alphaLabel,
|
|
93
|
+
suggestions = [],
|
|
94
|
+
themeRoles = [],
|
|
95
|
+
showAlpha = false,
|
|
96
|
+
modes = ['solid'],
|
|
97
|
+
gradientMessages,
|
|
98
|
+
} = defineProps<ColorFieldProps<P>>()
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The chosen colour, as the class to apply, or with `gradient` the gradient's
|
|
102
|
+
* class string. Unset or unreadable shows the placeholder.
|
|
103
|
+
*/
|
|
104
|
+
const model = defineModel<ColorClass<P> | EditorColorGradientValue>()
|
|
105
|
+
|
|
106
|
+
const parsed = computed(() => parseColorClass(model.value, accepts ?? [property]))
|
|
107
|
+
|
|
108
|
+
/** The hex out of the model, or an empty string when it is not a literal colour. */
|
|
109
|
+
const hex = computed(() => (parsed.value && 'hex' in parsed.value ? parsed.value.hex : ''))
|
|
110
|
+
|
|
111
|
+
/** The role out of the model, or `undefined` when it is not a role class. */
|
|
112
|
+
const storedRole = computed(() =>
|
|
113
|
+
parsed.value && 'role' in parsed.value ? parsed.value.role : undefined,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const offeredRoles = computed(() => themeRoles.filter((entry) => ROLE_HEX_PATTERN.test(entry.hex)))
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The stored role as the consumer offers it. Only a role class selects a role:
|
|
120
|
+
* a literal colour that happens to equal a role's hex stays a literal.
|
|
121
|
+
*/
|
|
122
|
+
const activeRole = computed(() => offeredRoles.value.find(({ role }) => role === storedRole.value))
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Picking the role's own colour -- from the suggestions, or typed -- is how a
|
|
126
|
+
* colour is detached from the theme without changing how it looks. The picker
|
|
127
|
+
* reports nothing when it is told to pick the string it already has, so a role's
|
|
128
|
+
* hex is handed over in its 8-digit form: with `show-alpha` off the picker never
|
|
129
|
+
* reports an alpha pair, which makes every pick a change.
|
|
130
|
+
*
|
|
131
|
+
* A role has no opacity here, so an alpha pair in the role's own hex is replaced
|
|
132
|
+
* by `ff`. Handed over translucent, the picker would drop that alpha the moment
|
|
133
|
+
* the role turns `show-alpha` off, and write the opaque hex over the role.
|
|
134
|
+
*/
|
|
135
|
+
const pickerHex = (roleHex: string) => `${roleHex.slice(0, 7)}ff`
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A role class has no opacity, so the slider is only offered for a literal.
|
|
139
|
+
* Hiding it is also what keeps the trick in `pickerHex` working: without the
|
|
140
|
+
* alpha channel the picker reads the role's hex as opaque.
|
|
141
|
+
*/
|
|
142
|
+
const alphaEnabled = computed(() => showAlpha && !activeRole.value)
|
|
143
|
+
|
|
144
|
+
// An unparseable value -- empty, a token class such as `bg-red-500`, or copy
|
|
145
|
+
// from before this component -- leaves the picker empty and its placeholder
|
|
146
|
+
// visible, rather than being reported as a problem: the first pick writes a
|
|
147
|
+
// valid class back. A role the consumer does not offer is shown the same way,
|
|
148
|
+
// because there is no label or hex to show it with; it is not written back.
|
|
149
|
+
const picked = computed({
|
|
150
|
+
get: () => (activeRole.value ? pickerHex(activeRole.value.hex) : hex.value),
|
|
151
|
+
// The picker clears its own value in no flow it offers, but an empty string
|
|
152
|
+
// would still wipe a perfectly good colour, so it is dropped rather than
|
|
153
|
+
// written through. The picker keeps rendering the model's colour.
|
|
154
|
+
set: (value: string) => {
|
|
155
|
+
const color = value.replace(OPAQUE_ALPHA_PATTERN, '$1')
|
|
156
|
+
if (isHex(color)) model.value = `${property}-[${color}]`
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const pickRole = (role: EditorColorRole) => {
|
|
161
|
+
model.value = `${property}-theme-${role}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Gradient ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The fills the picker offers. A gradient only exists for a background; for
|
|
168
|
+
* any other property this falls back to the solid colour alone.
|
|
169
|
+
*/
|
|
170
|
+
const offeredModes = computed<ColorPickerFillMode[]>(() => {
|
|
171
|
+
const withGradient = modes.includes('gradient') && property === 'bg'
|
|
172
|
+
if (!withGradient) return ['solid']
|
|
173
|
+
return modes.includes('solid') ? ['solid', 'gradient'] : ['gradient']
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const gradientEnabled = computed(() => offeredModes.value.includes('gradient'))
|
|
177
|
+
|
|
178
|
+
/** Only the gradient is offered: there is no switch, and the mode is always gradient. */
|
|
179
|
+
const gradientOnly = computed(() => !offeredModes.value.includes('solid'))
|
|
180
|
+
|
|
181
|
+
const parsedGradient = computed(() =>
|
|
182
|
+
gradientEnabled.value ? parseGradientClass(model.value) : null,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const modeOf = (value: string | undefined): ColorPickerFillMode | null => {
|
|
186
|
+
if (looksLikeGradientClass(value)) return 'gradient'
|
|
187
|
+
if (parseColorClass(value, accepts ?? [property])) return 'solid'
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Which fill the picker edits. It follows the model, but an empty or
|
|
193
|
+
* unreadable value keeps the mode the user switched to: the picker then shows
|
|
194
|
+
* its default, and nothing is written until the first change.
|
|
195
|
+
*/
|
|
196
|
+
const switchedMode = ref<ColorPickerFillMode>(
|
|
197
|
+
gradientEnabled.value ? (modeOf(model.value) ?? 'solid') : 'solid',
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
watch(
|
|
201
|
+
() => [model.value, gradientEnabled.value] as const,
|
|
202
|
+
([value, enabled]) => {
|
|
203
|
+
if (!enabled) switchedMode.value = 'solid'
|
|
204
|
+
else switchedMode.value = modeOf(value) ?? switchedMode.value
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const mode = computed<ColorPickerFillMode>(() => {
|
|
209
|
+
if (!gradientEnabled.value) return 'solid'
|
|
210
|
+
return gradientOnly.value ? 'gradient' : switchedMode.value
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/** The selected stop, as its index in order of position. */
|
|
214
|
+
const selectedStop = ref(0)
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The gradient last switched away from in this panel session, so switching
|
|
218
|
+
* back brings it back. Forgotten when the panel closes.
|
|
219
|
+
*/
|
|
220
|
+
const remembered = ref<{ value: EditorColorGradientValue; stop: number } | null>(null)
|
|
221
|
+
|
|
222
|
+
const roleEntry = (role: EditorColorRole) => offeredRoles.value.find((entry) => entry.role === role)
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A gradient as the picker takes it. A role the consumer does not offer has
|
|
226
|
+
* no colour to paint, so such a gradient is shown as unreadable.
|
|
227
|
+
*/
|
|
228
|
+
const toPickerGradient = (parsed: GradientClass | null): ColorPickerGradient | undefined => {
|
|
229
|
+
if (!parsed) return undefined
|
|
230
|
+
const stops = []
|
|
231
|
+
for (const { color, position } of parsed.stops) {
|
|
232
|
+
if ('hex' in color) {
|
|
233
|
+
stops.push({ color: color.hex, position })
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const entry = roleEntry(color.role)
|
|
237
|
+
if (!entry) return undefined
|
|
238
|
+
stops.push({
|
|
239
|
+
color: entry.hex.slice(0, 7).toLowerCase(),
|
|
240
|
+
position,
|
|
241
|
+
preset: { key: entry.role, label: entry.label },
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
type: parsed.type,
|
|
246
|
+
angle: parsed.type === 'linear' ? parsed.angle : 180,
|
|
247
|
+
// Read clamped to 1–99, as the preview's geometry works; a stored 0 or 100
|
|
248
|
+
// is only rewritten by a change.
|
|
249
|
+
centerX: parsed.type === 'radial' ? clampCenter(parsed.x) : 50,
|
|
250
|
+
centerY: parsed.type === 'radial' ? clampCenter(parsed.y) : 50,
|
|
251
|
+
stops,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const pickerGradient = computed(() => toPickerGradient(parsedGradient.value))
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* With only the gradient offered, an empty value or a solid colour opens on
|
|
259
|
+
* the gradient a switch from solid would give: the colour fading out to its own
|
|
260
|
+
* hex at `00`. It is shown, not written, until the first change.
|
|
261
|
+
*/
|
|
262
|
+
const pickerGradientStart = computed(() =>
|
|
263
|
+
gradientOnly.value && gradientEnabled.value && !pickerGradient.value
|
|
264
|
+
? toPickerGradient(parseGradientClass(gradientFromSolid()))
|
|
265
|
+
: undefined,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
/** A stop from the picker as a class colour: its preset is a role, otherwise it is a hex. */
|
|
269
|
+
const toClassColor = (color: string, presetKey?: string): GradientClassColor => {
|
|
270
|
+
const role = COLOR_ROLES.find((candidate) => candidate === presetKey)
|
|
271
|
+
if (role) return { role }
|
|
272
|
+
return { hex: normalizeGradientHex(isHex(color) ? color : `#${color}`) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const writeGradient = (value: ColorPickerGradient | undefined) => {
|
|
276
|
+
if (!value) return
|
|
277
|
+
const stops = value.stops.map(({ color, position, preset }) => ({
|
|
278
|
+
color: toClassColor(color, preset?.key),
|
|
279
|
+
position,
|
|
280
|
+
}))
|
|
281
|
+
model.value = serializeGradientClass(
|
|
282
|
+
value.type === 'linear'
|
|
283
|
+
? { type: 'linear', angle: value.angle, stops }
|
|
284
|
+
: { type: 'radial', x: value.centerX, y: value.centerY, stops },
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The solid colour as the first stop of a new gradient, fading out to its own hex at `00`. */
|
|
289
|
+
const gradientFromSolid = (): EditorColorGradientValue => {
|
|
290
|
+
const role = activeRole.value
|
|
291
|
+
const literal = hex.value
|
|
292
|
+
if (role) {
|
|
293
|
+
const hex6 = role.hex.slice(0, 7).toLowerCase()
|
|
294
|
+
return `bg-linear-180 from-theme-${role.role} to-[${hex6}00]`
|
|
295
|
+
}
|
|
296
|
+
if (literal) {
|
|
297
|
+
const from = normalizeGradientHex(literal)
|
|
298
|
+
return `bg-linear-180 from-[${from}] to-[${from.slice(0, 7)}00]`
|
|
299
|
+
}
|
|
300
|
+
return DEFAULT_GRADIENT_CLASS
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A stop's hex as the solid colour. Without `showAlpha` the solid mode has no
|
|
305
|
+
* opacity, so the alpha is dropped -- as the picker does when `showAlpha` goes off.
|
|
306
|
+
*/
|
|
307
|
+
const solidHex = (stopHex: `#${string}`): `#${string}` => {
|
|
308
|
+
const normal = normalizeGradientHex(stopHex)
|
|
309
|
+
const opaque = normal.slice(0, 7)
|
|
310
|
+
return showAlpha || !isHex(opaque) ? normal : opaque
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether the picker can paint every stop: a role stop needs a role the consumer offers. */
|
|
314
|
+
const isShowable = (parsed: GradientClass) =>
|
|
315
|
+
parsed.stops.every(({ color }) => 'hex' in color || !!roleEntry(color.role))
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The selected stop (or the first) as a solid class with the given prefix: its
|
|
319
|
+
* role, or its hex -- without alpha when there is no opacity slider.
|
|
320
|
+
*/
|
|
321
|
+
const solidFromGradient = <Q extends string>(
|
|
322
|
+
parsed: GradientClass,
|
|
323
|
+
prefix: Q,
|
|
324
|
+
): ColorClass<Q> | null => {
|
|
325
|
+
const stop = parsed.stops[selectedStop.value] ?? parsed.stops[0]
|
|
326
|
+
if (!stop) return null
|
|
327
|
+
return 'role' in stop.color
|
|
328
|
+
? `${prefix}-theme-${stop.color.role}`
|
|
329
|
+
: `${prefix}-[${solidHex(stop.color.hex)}]`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const switchMode = (next: ColorPickerFillMode) => {
|
|
333
|
+
if (next === mode.value || gradientOnly.value) return
|
|
334
|
+
if (next === 'gradient') {
|
|
335
|
+
switchedMode.value = 'gradient'
|
|
336
|
+
const restore = remembered.value
|
|
337
|
+
selectedStop.value = restore?.stop ?? 0
|
|
338
|
+
model.value = restore?.value ?? gradientFromSolid()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
switchedMode.value = 'solid'
|
|
342
|
+
// The same source as what the picker shows: a gradient it cannot show (a role
|
|
343
|
+
// the consumer does not offer) shows the default, so none of its stops is used.
|
|
344
|
+
const parsed = pickerGradient.value ? parsedGradient.value : null
|
|
345
|
+
if (!parsed) return
|
|
346
|
+
remembered.value = { value: serializeGradientClass(parsed), stop: selectedStop.value }
|
|
347
|
+
const solid = solidFromGradient(parsed, property)
|
|
348
|
+
if (solid) model.value = solid
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A role swatch picks the role for the solid colour, or in the gradient mode
|
|
353
|
+
* for the selected stop, through the picker.
|
|
354
|
+
*/
|
|
355
|
+
const onRoleClick = (
|
|
356
|
+
entry: EditorColorThemeRole,
|
|
357
|
+
selectPreset: (preset: { key: string; label: string }, hex: string) => void,
|
|
358
|
+
) => {
|
|
359
|
+
if (mode.value === 'gradient') selectPreset({ key: entry.role, label: entry.label }, entry.hex)
|
|
360
|
+
else pickRole(entry.role)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether a role swatch is pressed: the solid colour's role, or the selected stop's. */
|
|
364
|
+
const isRolePressed = (role: EditorColorRole, stopPreset: string | undefined) =>
|
|
365
|
+
mode.value === 'gradient' ? role === stopPreset : role === activeRole.value?.role
|
|
366
|
+
|
|
367
|
+
const onClose = () => {
|
|
368
|
+
remembered.value = null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// `property` is a prop, so the parent can flip a background setting into a
|
|
372
|
+
// text-colour one while a value is already set. Rewriting the prefix here is
|
|
373
|
+
// what keeps the stored class and the prop from drifting apart.
|
|
374
|
+
watch(
|
|
375
|
+
() => property,
|
|
376
|
+
(next) => {
|
|
377
|
+
if (storedRole.value) model.value = `${next}-theme-${storedRole.value}`
|
|
378
|
+
else if (hex.value) model.value = `${next}-[${hex.value}]`
|
|
379
|
+
else if (next !== 'bg') {
|
|
380
|
+
// A gradient only exists for a background, so it becomes the solid colour
|
|
381
|
+
// of its selected stop, as a switch to "Effen" would make it.
|
|
382
|
+
const parsed = parseGradientClass(model.value)
|
|
383
|
+
const solid = parsed && isShowable(parsed) ? solidFromGradient(parsed, next) : null
|
|
384
|
+
if (solid) model.value = solid
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
</script>
|
|
389
|
+
|
|
390
|
+
<template>
|
|
391
|
+
<NexxtColorPicker
|
|
392
|
+
v-model="picked"
|
|
393
|
+
v-model:selected-stop="selectedStop"
|
|
394
|
+
:modes="offeredModes"
|
|
395
|
+
:gradient-messages="gradientMessages"
|
|
396
|
+
:mode="mode"
|
|
397
|
+
:gradient-value="pickerGradient"
|
|
398
|
+
:gradient-start="pickerGradientStart"
|
|
399
|
+
full-width
|
|
400
|
+
match-width
|
|
401
|
+
:label="label"
|
|
402
|
+
:describedby="describedby"
|
|
403
|
+
:formats="['hex']"
|
|
404
|
+
:show-alpha="alphaEnabled"
|
|
405
|
+
:placeholder="placeholder"
|
|
406
|
+
:suggestions="{ hex: suggestions }"
|
|
407
|
+
:suggestions-label="suggestionsLabel"
|
|
408
|
+
:alpha-label="alphaLabel"
|
|
409
|
+
:value-label="activeRole?.label"
|
|
410
|
+
@update:mode="switchMode"
|
|
411
|
+
@update:gradient-value="writeGradient"
|
|
412
|
+
@close="onClose"
|
|
413
|
+
>
|
|
414
|
+
<template v-if="offeredRoles.length" #presets="{ preset, selectPreset }">
|
|
415
|
+
<div class="flex flex-col gap-1">
|
|
416
|
+
<span class="extra-small-normal text-gray-500">{{ themeLabel }}</span>
|
|
417
|
+
<!--
|
|
418
|
+
Same swatches and row as the picker's suggestions. Two roles often
|
|
419
|
+
share a hex (heading and text), so the tooltip is what tells them
|
|
420
|
+
apart; it repeats the accessible name rather than adding copy.
|
|
421
|
+
-->
|
|
422
|
+
<div class="flex flex-wrap gap-1.5">
|
|
423
|
+
<NexxtTooltip v-for="entry in offeredRoles" :key="entry.role" disable-closing-trigger>
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
:aria-label="entry.label"
|
|
427
|
+
:aria-pressed="isRolePressed(entry.role, preset)"
|
|
428
|
+
class="cursor-pointer 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 aria-pressed:ring-2 aria-pressed:ring-cornflower-blue-500"
|
|
429
|
+
@click="onRoleClick(entry, selectPreset)"
|
|
430
|
+
>
|
|
431
|
+
<span
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
class="block size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
|
|
434
|
+
:style="{ backgroundColor: entry.hex }"
|
|
435
|
+
/>
|
|
436
|
+
</button>
|
|
437
|
+
<template #tooltip>
|
|
438
|
+
<span class="small-normal text-gray-900">{{ entry.label }}</span>
|
|
439
|
+
</template>
|
|
440
|
+
</NexxtTooltip>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</template>
|
|
444
|
+
</NexxtColorPicker>
|
|
445
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing of the colour classes of the editor: NexxtEditorColor writes `bg-`/
|
|
3
|
+
* `text-` classes, and its gradient `from-`/`via-`/`to-` ones (see
|
|
4
|
+
* `gradientClass.ts`). The class is always a prefix, then either a hex between
|
|
5
|
+
* brackets or `theme-` and a role.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const COLOR_ROLES = ['main', 'accent', 'background', 'heading', 'text'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A role of the customer's theme. A colour bound to a role follows the theme,
|
|
12
|
+
* where a literal colour stays what it was picked as.
|
|
13
|
+
*/
|
|
14
|
+
export type EditorColorRole = (typeof COLOR_ROLES)[number]
|
|
15
|
+
|
|
16
|
+
export interface EditorColorThemeRole {
|
|
17
|
+
/** The role the class is written for: `'main'` becomes `bg-theme-main`. */
|
|
18
|
+
role: EditorColorRole
|
|
19
|
+
/** Name of the role as the user reads it, in the swatch's tooltip and in the trigger. */
|
|
20
|
+
label: string
|
|
21
|
+
/**
|
|
22
|
+
* The role's current colour as a 6- or 8-digit hex. Only used to paint the
|
|
23
|
+
* swatch: the library ships no CSS for the role classes, and the hex never
|
|
24
|
+
* reaches the model.
|
|
25
|
+
*/
|
|
26
|
+
hex: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A colour class with the given prefix: a literal hex or a theme role. */
|
|
30
|
+
export type ColorClass<P extends string> = `${P}-[#${string}]` | `${P}-theme-${EditorColorRole}`
|
|
31
|
+
|
|
32
|
+
/** What a colour class stands for, or `null` when it is not one. */
|
|
33
|
+
export type ParsedColorClass = { hex: `#${string}` } | { role: EditorColorRole } | null
|
|
34
|
+
|
|
35
|
+
/** The picker throws on a colour it cannot read, so a role's hex is checked first. */
|
|
36
|
+
export const ROLE_HEX_PATTERN = /^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `#` is part of the model's type, so a plain `string` cannot be written
|
|
40
|
+
* into it. This narrows a regex capture or picker output without a cast.
|
|
41
|
+
*/
|
|
42
|
+
export const isHex = (value: string): value is `#${string}` => value.startsWith('#')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads a class written with one of `prefixes`. A 6- or 8-digit hex is accepted
|
|
46
|
+
* whatever its case, so a stored `#1777EA80` survives a round trip. A tint such
|
|
47
|
+
* as `bg-theme-main/10`, a token class such as `bg-red-500` or a class with
|
|
48
|
+
* another prefix is read as `null`.
|
|
49
|
+
*/
|
|
50
|
+
export const parseColorClass = (
|
|
51
|
+
value: string | undefined,
|
|
52
|
+
prefixes: readonly string[],
|
|
53
|
+
): ParsedColorClass => {
|
|
54
|
+
if (!value) return null
|
|
55
|
+
const prefix = `(?:${prefixes.join('|')})`
|
|
56
|
+
const hex = new RegExp(`^${prefix}-\\[(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)\\]$`).exec(value)?.[1]
|
|
57
|
+
if (hex && isHex(hex)) return { hex }
|
|
58
|
+
const captured = new RegExp(`^${prefix}-theme-(${COLOR_ROLES.join('|')})$`).exec(value)?.[1]
|
|
59
|
+
const role = COLOR_ROLES.find((candidate) => candidate === captured)
|
|
60
|
+
return role ? { role } : null
|
|
61
|
+
}
|