@leavepulse/ui 0.29.5 → 0.30.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/chunks/LpActionBar-CxmBZpGu.js +173 -0
- package/dist/chunks/{LpAppShell-BJk3b-u4.js → LpAppShell-DnocEJAu.js} +1 -1
- package/dist/chunks/{LpCodeBlock-rS_6mO3D.js → LpCodeBlock-BWX1KZwk.js} +7 -13
- package/dist/chunks/LpColorPicker-CFC6iaNK.js +491 -0
- package/dist/chunks/{LpDisclosure-BnwP4Q-b.js → LpDisclosure-C1_YkMrd.js} +8 -2
- package/dist/chunks/{LpLogViewer-CVaGfTF_.js → LpLogViewer-TSRyt3t9.js} +3 -6
- package/dist/chunks/{LpSidebar-C_Mr-JPI.js → LpSidebar-CWSbczWi.js} +11 -2
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpActionBar.vue.d.ts +84 -0
- package/dist/components/LpActionBar.vue.js +4 -0
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +124 -0
- package/dist/components/LpColorPicker.vue.js +4 -0
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +2 -2
- package/dist/components/LpSidebar.vue.d.ts +21 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/actionBar.d.ts +19 -0
- package/dist/components/actionBar.js +31 -0
- package/dist/components/color.d.ts +65 -0
- package/dist/components/color.js +158 -0
- package/dist/composables/useClipboard.d.ts +46 -0
- package/dist/composables/useClipboard.js +28 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +145 -126
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpActionBar.vue +268 -0
- package/src/components/LpCodeBlock.vue +5 -10
- package/src/components/LpColorPicker.vue +784 -0
- package/src/components/LpDisclosure.vue +17 -2
- package/src/components/LpLogViewer.vue +5 -8
- package/src/components/LpSidebar.vue +42 -6
- package/src/components/actionBar.ts +73 -0
- package/src/components/color.ts +257 -0
- package/src/composables/useClipboard.ts +107 -0
- package/src/index.ts +21 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Colour picker — a trigger showing the current colour, and a popover holding a
|
|
4
|
+
* saturation/value field, a hue rail, an optional alpha rail, a hex field and a
|
|
5
|
+
* row of presets.
|
|
6
|
+
*
|
|
7
|
+
* The model is a CSS COLOUR STRING, not an object: `v-model="#3b82f6"` is what a
|
|
8
|
+
* caller stores, sends to an API and drops straight into a style binding. HSV
|
|
9
|
+
* lives inside the component only, because a colour that round-trips through
|
|
10
|
+
* hex on every drag loses the hue of a black or a white — drag the value down to
|
|
11
|
+
* zero and everything becomes #000000, and dragging back up would come back red
|
|
12
|
+
* instead of the blue you started on. Keeping HSV as the source of truth means
|
|
13
|
+
* the rails stay where you left them.
|
|
14
|
+
*
|
|
15
|
+
* `format` picks the shape emitted back ("hex" | "rgb" | "hsl"); input is parsed
|
|
16
|
+
* from any of the three regardless, so switching the format never orphans a
|
|
17
|
+
* value already saved.
|
|
18
|
+
*
|
|
19
|
+
* Dragging is pointer-capture based, the same as LpMapPicker: the pointer keeps
|
|
20
|
+
* feeding the field it started on even when it leaves the element, so a drag
|
|
21
|
+
* past the edge pins to the edge instead of stopping dead. Both the field and
|
|
22
|
+
* the rails are arrow-key operable, since a picker that only answers the mouse
|
|
23
|
+
* is one nobody can use from the keyboard.
|
|
24
|
+
*
|
|
25
|
+
* Right-clicking either the trigger or the panel copies the colour as hex, rgb
|
|
26
|
+
* or hsl, switches which of the three the picker shows, or pastes one in —
|
|
27
|
+
* taking a value somewhere else is most of what a colour swatch is for, and
|
|
28
|
+
* reading it off the screen by hand is how a hex ends up mistyped. `recent`
|
|
29
|
+
* keeps the colours settled on in browser storage and offers them back above
|
|
30
|
+
* the presets.
|
|
31
|
+
*/
|
|
32
|
+
import {
|
|
33
|
+
PopoverAnchor,
|
|
34
|
+
PopoverContent,
|
|
35
|
+
PopoverPortal,
|
|
36
|
+
PopoverRoot,
|
|
37
|
+
PopoverTrigger,
|
|
38
|
+
} from "reka-ui"
|
|
39
|
+
import { computed, ref, watch } from "vue"
|
|
40
|
+
import {
|
|
41
|
+
clampHsv,
|
|
42
|
+
DEFAULT_SWATCHES,
|
|
43
|
+
formatColor,
|
|
44
|
+
type ColorFormat,
|
|
45
|
+
type Hsv,
|
|
46
|
+
isLight,
|
|
47
|
+
opaqueCss,
|
|
48
|
+
parseColor,
|
|
49
|
+
rgbToHsv,
|
|
50
|
+
} from "./color"
|
|
51
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
52
|
+
import { POPOVER_PANEL } from "./dropdown"
|
|
53
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
54
|
+
import LpIcon from "./LpIcon.vue"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every user-facing string the picker renders on its own — the menu items, the
|
|
58
|
+
* "Recent" heading, and the accessible names the area, rails and text field
|
|
59
|
+
* carry. Apps with i18n pass their translations in; the English defaults keep
|
|
60
|
+
* the component usable bare. Without this an app in another language would
|
|
61
|
+
* still announce "Saturation and brightness" to a screen reader.
|
|
62
|
+
*/
|
|
63
|
+
export interface ColorPickerLabels {
|
|
64
|
+
saturation?: string
|
|
65
|
+
hue?: string
|
|
66
|
+
opacity?: string
|
|
67
|
+
value?: string
|
|
68
|
+
recent?: string
|
|
69
|
+
copyHex?: string
|
|
70
|
+
copyRgb?: string
|
|
71
|
+
copyHsl?: string
|
|
72
|
+
paste?: string
|
|
73
|
+
/** Submenu title for the "show the value as…" format switch. */
|
|
74
|
+
formatAs?: string
|
|
75
|
+
format_hex?: string
|
|
76
|
+
format_rgb?: string
|
|
77
|
+
format_hsl?: string
|
|
78
|
+
/** Read out as the area's value, with {s} and {v} as whole percentages. */
|
|
79
|
+
saturationValueText?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const props = withDefaults(
|
|
83
|
+
defineProps<{
|
|
84
|
+
/** The colour as a CSS string (v-model). Hex, rgb()/rgba() or hsl()/hsla(). */
|
|
85
|
+
modelValue?: string
|
|
86
|
+
/** Shape of the emitted string. Parsing accepts all three either way. */
|
|
87
|
+
format?: ColorFormat
|
|
88
|
+
/** Show the alpha rail and emit a colour carrying its alpha. */
|
|
89
|
+
alpha?: boolean
|
|
90
|
+
/** Preset colours under the rails. Pass [] to drop the row entirely. */
|
|
91
|
+
swatches?: readonly string[]
|
|
92
|
+
/** Hide the hex/CSS text field (leaves the field, rails and presets). */
|
|
93
|
+
hideInput?: boolean
|
|
94
|
+
disabled?: boolean
|
|
95
|
+
invalid?: boolean
|
|
96
|
+
size?: "sm" | "md" | "lg"
|
|
97
|
+
/** Trigger text beside the swatch. Defaults to the colour's own value. */
|
|
98
|
+
label?: string
|
|
99
|
+
/** Accessible name for the trigger when it carries no visible text. */
|
|
100
|
+
ariaLabel?: string
|
|
101
|
+
/** Popover side, as LpPopover. */
|
|
102
|
+
side?: "top" | "right" | "bottom" | "left"
|
|
103
|
+
align?: "start" | "center" | "end"
|
|
104
|
+
/**
|
|
105
|
+
* Right-click menu on the trigger. Copy-as-hex/rgb/hsl and paste are built
|
|
106
|
+
* in — the thing a person actually wants from a colour swatch is to take
|
|
107
|
+
* the value somewhere else, and reading it off the trigger by hand is how
|
|
108
|
+
* a hex ends up mistyped. These come first; `menuItems` is appended below a
|
|
109
|
+
* separator. Pass `menu="none"` to drop the built-ins and keep only yours.
|
|
110
|
+
*/
|
|
111
|
+
menu?: "default" | "none"
|
|
112
|
+
/** Extra right-click items, appended after the built-in ones. */
|
|
113
|
+
menuItems?: ContextMenuItemDef[]
|
|
114
|
+
/**
|
|
115
|
+
* Remember the colours picked here and offer them back as a "Recent" row.
|
|
116
|
+
* A person who tints one tag reaches for the same colour on the next one,
|
|
117
|
+
* and the presets cannot know it. Off by default: history is a per-person
|
|
118
|
+
* convenience, and a component that wrote to storage uninvited would be a
|
|
119
|
+
* surprise in a form that is meant to be stateless.
|
|
120
|
+
*/
|
|
121
|
+
recent?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Storage key for that history, so two pickers can share a list or keep
|
|
124
|
+
* separate ones (e.g. "lp.color.tag" vs "lp.color.calendar").
|
|
125
|
+
*/
|
|
126
|
+
recentKey?: string
|
|
127
|
+
/** How many recent colours to keep. */
|
|
128
|
+
recentLimit?: number
|
|
129
|
+
/** Translations for the strings the picker renders itself. */
|
|
130
|
+
labels?: ColorPickerLabels
|
|
131
|
+
/**
|
|
132
|
+
* Toast raised after a successful copy, with `{value}` standing in for the
|
|
133
|
+
* colour. `false` copies silently — for a page that already confirms it, or
|
|
134
|
+
* one where a toast per copy would be noise. Translate it via `labels`
|
|
135
|
+
* rather than here if the rest of your strings go through i18n.
|
|
136
|
+
*/
|
|
137
|
+
copyToast?: string | false
|
|
138
|
+
}>(),
|
|
139
|
+
{
|
|
140
|
+
modelValue: "#3b82f6",
|
|
141
|
+
format: "hex",
|
|
142
|
+
alpha: false,
|
|
143
|
+
swatches: () => DEFAULT_SWATCHES,
|
|
144
|
+
size: "md",
|
|
145
|
+
side: "bottom",
|
|
146
|
+
align: "start",
|
|
147
|
+
recent: false,
|
|
148
|
+
recentKey: "lp.color.recent",
|
|
149
|
+
recentLimit: 8,
|
|
150
|
+
copyToast: "Copied {value}",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const emit = defineEmits<{
|
|
155
|
+
// Fires live while dragging.
|
|
156
|
+
(e: "update:modelValue", value: string): void
|
|
157
|
+
// Fires once on release, mirroring LpSlider — for commit-vs-preview splits
|
|
158
|
+
// where every intermediate colour would mean a write or a network call.
|
|
159
|
+
(e: "change", value: string): void
|
|
160
|
+
}>()
|
|
161
|
+
|
|
162
|
+
/** Fill `{s}` / `{v}` in a label template. */
|
|
163
|
+
function fill(tpl: string, vars: Record<string, string | number>): string {
|
|
164
|
+
return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Single lookup for every label, so the template never repeats the fallbacks.
|
|
168
|
+
const l = computed(() => ({
|
|
169
|
+
saturation: props.labels?.saturation ?? "Saturation and brightness",
|
|
170
|
+
hue: props.labels?.hue ?? "Hue",
|
|
171
|
+
opacity: props.labels?.opacity ?? "Opacity",
|
|
172
|
+
value: props.labels?.value ?? "Colour value",
|
|
173
|
+
recent: props.labels?.recent ?? "Recent",
|
|
174
|
+
copyHex: props.labels?.copyHex ?? "Copy hex",
|
|
175
|
+
copyRgb: props.labels?.copyRgb ?? "Copy rgb",
|
|
176
|
+
copyHsl: props.labels?.copyHsl ?? "Copy hsl",
|
|
177
|
+
paste: props.labels?.paste ?? "Paste colour",
|
|
178
|
+
formatAs: props.labels?.formatAs ?? "Show as",
|
|
179
|
+
format_hex: props.labels?.format_hex ?? "HEX",
|
|
180
|
+
format_rgb: props.labels?.format_rgb ?? "RGB",
|
|
181
|
+
format_hsl: props.labels?.format_hsl ?? "HSL",
|
|
182
|
+
saturationValueText:
|
|
183
|
+
props.labels?.saturationValueText ?? "saturation {s}%, brightness {v}%",
|
|
184
|
+
}))
|
|
185
|
+
|
|
186
|
+
const { copy } = useClipboard()
|
|
187
|
+
|
|
188
|
+
const open = ref(false)
|
|
189
|
+
|
|
190
|
+
// HSV is the source of truth while the popover is in use; see the note above.
|
|
191
|
+
const hsv = ref<Hsv>({ h: 217, s: 0.76, v: 0.96, a: 1 })
|
|
192
|
+
|
|
193
|
+
function adopt(value: string | undefined) {
|
|
194
|
+
const rgb = parseColor(value ?? "")
|
|
195
|
+
if (!rgb) return
|
|
196
|
+
const next = rgbToHsv(rgb)
|
|
197
|
+
// A grey has no hue of its own, and a black no saturation — taking the zeroes
|
|
198
|
+
// the conversion reports would swing the rails every time the value passes
|
|
199
|
+
// through one. Keep what the user last aimed at instead.
|
|
200
|
+
if (next.s === 0) next.h = hsv.value.h
|
|
201
|
+
if (next.v === 0) next.s = hsv.value.s
|
|
202
|
+
hsv.value = next
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
adopt(props.modelValue)
|
|
206
|
+
|
|
207
|
+
// Only adopt an OUTSIDE change: our own emit comes back through modelValue, and
|
|
208
|
+
// re-deriving HSV from it is exactly the round-trip this component avoids.
|
|
209
|
+
watch(
|
|
210
|
+
() => props.modelValue,
|
|
211
|
+
(v) => {
|
|
212
|
+
if (v !== current.value) adopt(v)
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The format the picker emits and shows in its text field. Seeded from the
|
|
218
|
+
* `format` prop and switchable from the right-click menu, because which shape
|
|
219
|
+
* you want is a property of the moment — the stored value is hex, the thing you
|
|
220
|
+
* are pasting into a stylesheet wants rgb — not of how the component was
|
|
221
|
+
* configured months ago. A caller that changes the prop still wins: that is an
|
|
222
|
+
* explicit instruction, unlike a menu pick made for one paste.
|
|
223
|
+
*/
|
|
224
|
+
const activeFormat = ref<ColorFormat>(props.format)
|
|
225
|
+
watch(() => props.format, (f) => (activeFormat.value = f))
|
|
226
|
+
|
|
227
|
+
const current = computed(() =>
|
|
228
|
+
formatColor(props.alpha ? hsv.value : { ...hsv.value, a: 1 }, activeFormat.value),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
function set(patch: Partial<Hsv>, commit = false) {
|
|
232
|
+
if (props.disabled) return
|
|
233
|
+
hsv.value = clampHsv({ ...hsv.value, ...patch })
|
|
234
|
+
emit("update:modelValue", current.value)
|
|
235
|
+
if (commit) emit("change", current.value)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ── pointer drag ─────────────────────────────────
|
|
239
|
+
One handler for the field and both rails: each reports the pointer as a 0..1
|
|
240
|
+
fraction of the box it was pressed in, and the caller maps that to its own
|
|
241
|
+
axis. */
|
|
242
|
+
function trackPointer(e: PointerEvent, onMove: (x: number, y: number) => void) {
|
|
243
|
+
if (props.disabled) return
|
|
244
|
+
// Only the primary button drags; a right-click belongs to the context menu.
|
|
245
|
+
if (e.button !== 0) return
|
|
246
|
+
// A pointerdown that isn't cancelled still starts a native text selection, so
|
|
247
|
+
// dragging across the field swept a highlight over the whole page behind it.
|
|
248
|
+
e.preventDefault()
|
|
249
|
+
const el = e.currentTarget as HTMLElement
|
|
250
|
+
const rect = el.getBoundingClientRect()
|
|
251
|
+
const at = (ev: PointerEvent) => {
|
|
252
|
+
const x = rect.width === 0 ? 0 : (ev.clientX - rect.left) / rect.width
|
|
253
|
+
const y = rect.height === 0 ? 0 : (ev.clientY - rect.top) / rect.height
|
|
254
|
+
onMove(Math.min(1, Math.max(0, x)), Math.min(1, Math.max(0, y)))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
el.setPointerCapture(e.pointerId)
|
|
258
|
+
at(e)
|
|
259
|
+
|
|
260
|
+
const move = (ev: PointerEvent) => at(ev)
|
|
261
|
+
const up = () => {
|
|
262
|
+
el.removeEventListener("pointermove", move)
|
|
263
|
+
el.removeEventListener("pointerup", up)
|
|
264
|
+
el.removeEventListener("pointercancel", up)
|
|
265
|
+
emit("change", current.value)
|
|
266
|
+
}
|
|
267
|
+
el.addEventListener("pointermove", move)
|
|
268
|
+
el.addEventListener("pointerup", up)
|
|
269
|
+
el.addEventListener("pointercancel", up)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const onFieldPointer = (e: PointerEvent) =>
|
|
273
|
+
trackPointer(e, (x, y) => set({ s: x, v: 1 - y }))
|
|
274
|
+
const onHuePointer = (e: PointerEvent) => trackPointer(e, (x) => set({ h: x * 360 }))
|
|
275
|
+
const onAlphaPointer = (e: PointerEvent) => trackPointer(e, (x) => set({ a: x }))
|
|
276
|
+
|
|
277
|
+
/* ── keyboard ─────────────────────────────────────
|
|
278
|
+
Arrows step, shift-arrows step ten times as far, Home/End jump to the ends —
|
|
279
|
+
the same grammar as a slider, so the field behaves like the rails do. */
|
|
280
|
+
function step(e: KeyboardEvent, axis: "sv" | "h" | "a") {
|
|
281
|
+
const big = e.shiftKey
|
|
282
|
+
const k = e.key
|
|
283
|
+
const arrows = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]
|
|
284
|
+
if (!arrows.includes(k)) return
|
|
285
|
+
e.preventDefault()
|
|
286
|
+
|
|
287
|
+
const dx = (k === "ArrowRight" ? 1 : k === "ArrowLeft" ? -1 : 0) * (big ? 10 : 1)
|
|
288
|
+
const dy = (k === "ArrowUp" ? 1 : k === "ArrowDown" ? -1 : 0) * (big ? 10 : 1)
|
|
289
|
+
|
|
290
|
+
if (axis === "h") {
|
|
291
|
+
if (k === "Home") return set({ h: 0 }, true)
|
|
292
|
+
if (k === "End") return set({ h: 359 }, true)
|
|
293
|
+
return set({ h: hsv.value.h + (dx || dy) }, true)
|
|
294
|
+
}
|
|
295
|
+
if (axis === "a") {
|
|
296
|
+
if (k === "Home") return set({ a: 0 }, true)
|
|
297
|
+
if (k === "End") return set({ a: 1 }, true)
|
|
298
|
+
return set({ a: hsv.value.a + (dx || dy) / 100 }, true)
|
|
299
|
+
}
|
|
300
|
+
if (k === "Home") return set({ s: 0 }, true)
|
|
301
|
+
if (k === "End") return set({ s: 1 }, true)
|
|
302
|
+
set({ s: hsv.value.s + dx / 100, v: hsv.value.v + dy / 100 }, true)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* ── text field ───────────────────────────────────
|
|
306
|
+
Typed text is parsed on every keystroke but only applied when it resolves to
|
|
307
|
+
a real colour, so a half-written "#3b8" never snaps the picker somewhere the
|
|
308
|
+
user is still on their way past. The field shows what is typed while it has
|
|
309
|
+
focus and the committed value once it doesn't. */
|
|
310
|
+
const typed = ref<string | null>(null)
|
|
311
|
+
const inputValue = computed(() => typed.value ?? current.value)
|
|
312
|
+
|
|
313
|
+
function onInput(e: Event) {
|
|
314
|
+
const value = (e.target as HTMLInputElement).value
|
|
315
|
+
typed.value = value
|
|
316
|
+
const rgb = parseColor(value)
|
|
317
|
+
if (!rgb) return
|
|
318
|
+
const next = rgbToHsv(rgb)
|
|
319
|
+
if (next.s === 0) next.h = hsv.value.h
|
|
320
|
+
if (next.v === 0) next.s = hsv.value.s
|
|
321
|
+
set(props.alpha ? next : { ...next, a: 1 }, true)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function onInputBlur() {
|
|
325
|
+
typed.value = null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function pickSwatch(value: string) {
|
|
329
|
+
const rgb = parseColor(value)
|
|
330
|
+
if (!rgb) return
|
|
331
|
+
typed.value = null
|
|
332
|
+
set(rgbToHsv(rgb), true)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isActiveSwatch(value: string) {
|
|
336
|
+
const rgb = parseColor(value)
|
|
337
|
+
return !!rgb && formatColor(rgbToHsv(rgb), "hex") === formatColor({ ...hsv.value, a: 1 }, "hex")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Ink for the tick on a selected swatch: dark on a pale colour, light on a deep
|
|
342
|
+
* one. Unparseable falls back to the light tick, which is the safe end — the
|
|
343
|
+
* swatch is showing nothing in that case anyway.
|
|
344
|
+
*/
|
|
345
|
+
function tickClass(value: string) {
|
|
346
|
+
const rgb = parseColor(value)
|
|
347
|
+
return rgb && isLight(rgbToHsv(rgb)) ? "text-black" : "text-white"
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const triggerSize = {
|
|
351
|
+
sm: "h-(--size-control-sm) text-xs",
|
|
352
|
+
md: "h-(--size-control-md) text-sm",
|
|
353
|
+
lg: "h-(--size-control-lg) text-sm",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The colour the swatch/preview paints, alpha included so a translucent pick
|
|
357
|
+
// reads as translucent against the checkerboard behind it.
|
|
358
|
+
const previewCss = computed(() => formatColor(props.alpha ? hsv.value : { ...hsv.value, a: 1 }, "rgb"))
|
|
359
|
+
const hueCss = computed(() => `hsl(${Math.round(hsv.value.h)}, 100%, 50%)`)
|
|
360
|
+
// The alpha ramp's see-through end: this colour at zero alpha, so the fade stays
|
|
361
|
+
// on its own hue instead of sliding through grey on the way out.
|
|
362
|
+
const alphaRampStart = computed(() => formatColor({ ...hsv.value, a: 0 }, "rgb"))
|
|
363
|
+
/**
|
|
364
|
+
* The square's backdrop is the PURE HUE — full saturation, full value — not the
|
|
365
|
+
* current colour. The whole point of the area is to show every s/v combination
|
|
366
|
+
* of one hue at once, so it may only ever change when the hue rail moves. Using
|
|
367
|
+
* the live colour here made the backdrop follow the marker: dragging toward
|
|
368
|
+
* black darkened the entire square under it, which is the picker redrawing the
|
|
369
|
+
* very thing you are choosing from.
|
|
370
|
+
*/
|
|
371
|
+
const fieldCss = computed(() => opaqueCss({ ...hsv.value, s: 1, v: 1 }))
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The saturation/value square as ONE background on ONE element: white across,
|
|
375
|
+
* black up, hue underneath, in a single `background` shorthand.
|
|
376
|
+
*
|
|
377
|
+
* They used to be three things — the hue as the element's background-color and
|
|
378
|
+
* the two ramps as absolutely-positioned children clipped by the parent's
|
|
379
|
+
* radius. A rounded clip is antialiased, so the pixels along each corner's arc
|
|
380
|
+
* got only partial coverage of those children, and what showed through the rest
|
|
381
|
+
* of the way was the RAW HUE sitting behind them. At the bottom of the square,
|
|
382
|
+
* where the composited colour is near-black, a leak of rgb(59,130,246) is a
|
|
383
|
+
* bright fringe tracing both bottom corners — visible at 100%, obvious zoomed.
|
|
384
|
+
*
|
|
385
|
+
* Painting all three as layers of one background means the corner clips a
|
|
386
|
+
* finished image: a partially covered pixel fades toward transparent, which is
|
|
387
|
+
* the panel behind it, instead of toward a colour that should never be on show.
|
|
388
|
+
*/
|
|
389
|
+
const fieldStyle = computed(() => ({
|
|
390
|
+
background: [
|
|
391
|
+
"linear-gradient(to top, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0))",
|
|
392
|
+
"linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))",
|
|
393
|
+
fieldCss.value,
|
|
394
|
+
].join(", "),
|
|
395
|
+
}))
|
|
396
|
+
|
|
397
|
+
/* The alpha checkerboard, inline so it needs no stylesheet entry and no asset:
|
|
398
|
+
a repeating conic gradient is the standard trick for it.
|
|
399
|
+
|
|
400
|
+
Whatever paints this must let the colour reach the very edge. A border is
|
|
401
|
+
drawn OVER the element's own background, and every line colour in the kit is
|
|
402
|
+
a translucent white (--color-line is rgba(…,0.1)), so a bordered swatch let
|
|
403
|
+
the checkerboard show through its frame — a see-through outline around
|
|
404
|
+
colours that were perfectly opaque. The swatches carry an inset RING over the
|
|
405
|
+
colour instead.
|
|
406
|
+
|
|
407
|
+
The saturation area and the hue rail carry no resting line at all: they are
|
|
408
|
+
saturated edge to edge and need no help separating from the panel, and a
|
|
409
|
+
translucent white hairline over their near-black corner read as a bright arc
|
|
410
|
+
tracing the radius — the "glow" in the corner of the area. Only the focus
|
|
411
|
+
ring draws on them, and only while they have focus. */
|
|
412
|
+
const CHECKERBOARD =
|
|
413
|
+
"repeating-conic-gradient(var(--color-line-strong) 0% 25%, transparent 0% 50%) 0 0 / 12px 12px"
|
|
414
|
+
|
|
415
|
+
/* ── recent colours ───────────────────────────────
|
|
416
|
+
Persisted per `recentKey`, newest first, de-duplicated by hex so the same
|
|
417
|
+
colour picked twice moves to the front instead of filling the row. Every
|
|
418
|
+
storage access is allowed to fail the way useFormDraft's does: a private
|
|
419
|
+
window or a storage policy must not take the picker down with it. */
|
|
420
|
+
function storage(): Storage | null {
|
|
421
|
+
try {
|
|
422
|
+
if (typeof window === "undefined") return null
|
|
423
|
+
return window.localStorage
|
|
424
|
+
} catch {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function readRecent(): string[] {
|
|
430
|
+
const s = storage()
|
|
431
|
+
if (!s) return []
|
|
432
|
+
try {
|
|
433
|
+
const raw = JSON.parse(s.getItem(props.recentKey) ?? "[]")
|
|
434
|
+
// Whatever is in storage came from outside this run — a stale shape, or
|
|
435
|
+
// another app on the same origin. Take only strings that still parse.
|
|
436
|
+
return Array.isArray(raw)
|
|
437
|
+
? raw.filter((v): v is string => typeof v === "string" && !!parseColor(v)).slice(0, props.recentLimit)
|
|
438
|
+
: []
|
|
439
|
+
} catch {
|
|
440
|
+
return []
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const recentColors = ref<string[]>(props.recent ? readRecent() : [])
|
|
445
|
+
|
|
446
|
+
// A history is only worth keeping once a colour is SETTLED on: recording every
|
|
447
|
+
// intermediate value of a drag would fill the row with the sweep rather than
|
|
448
|
+
// the choice. So this is called from the commit paths, never from `set`.
|
|
449
|
+
function remember(value: string) {
|
|
450
|
+
if (!props.recent) return
|
|
451
|
+
const key = formatColor({ ...hsv.value, a: 1 }, "hex")
|
|
452
|
+
const without = recentColors.value.filter(
|
|
453
|
+
(c) => formatColor(rgbToHsv(parseColor(c) ?? { r: 0, g: 0, b: 0, a: 1 }), "hex") !== key,
|
|
454
|
+
)
|
|
455
|
+
recentColors.value = [value, ...without].slice(0, props.recentLimit)
|
|
456
|
+
try {
|
|
457
|
+
storage()?.setItem(props.recentKey, JSON.stringify(recentColors.value))
|
|
458
|
+
} catch {
|
|
459
|
+
// Quota, or storage turned off mid-session. The row still works in memory.
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Remember on close rather than on every commit: the colour a person leaves the
|
|
464
|
+
// popover on is the one they chose, while the ones they passed through on the
|
|
465
|
+
// way are not history, they are the search for it.
|
|
466
|
+
watch(open, (isOpen, was) => {
|
|
467
|
+
if (was && !isOpen) remember(current.value)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The swatch rows, in the order they appear. One description each, so the two
|
|
472
|
+
* rows cannot drift apart in the markup the way two copies of it would.
|
|
473
|
+
* Recent is slightly smaller and packs eight to a line: it is an aid, and the
|
|
474
|
+
* presets stay the row the layout is built around.
|
|
475
|
+
*/
|
|
476
|
+
const swatchRows = computed(() =>
|
|
477
|
+
[
|
|
478
|
+
{
|
|
479
|
+
key: "recent",
|
|
480
|
+
heading: l.value.recent,
|
|
481
|
+
colors: props.recent ? recentColors.value : [],
|
|
482
|
+
cols: "grid-cols-8",
|
|
483
|
+
size: "size-6",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
key: "presets",
|
|
487
|
+
heading: "",
|
|
488
|
+
colors: [...props.swatches],
|
|
489
|
+
cols: "grid-cols-6",
|
|
490
|
+
size: "size-7",
|
|
491
|
+
},
|
|
492
|
+
].filter((r) => r.colors.length),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* reka focuses the first focusable thing in the panel when it opens, which is
|
|
497
|
+
* the saturation area — and a focus ring is a bright 2px arc traced around a
|
|
498
|
+
* near-black corner, so opening the picker with the mouse lit up an edge nobody
|
|
499
|
+
* asked for. The panel takes the focus instead: keyboard users still Tab
|
|
500
|
+
* straight into the area from there, and the ring appears when it is earned.
|
|
501
|
+
*/
|
|
502
|
+
function onPanelOpen(e: Event) {
|
|
503
|
+
e.preventDefault()
|
|
504
|
+
;(e.currentTarget as HTMLElement | null)?.focus?.({ preventScroll: true })
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* ── right-click menu ─────────────────────────────
|
|
508
|
+
Copy writes the colour in a named format regardless of which one `format`
|
|
509
|
+
emits: the value you need in a stylesheet is often not the one the app
|
|
510
|
+
stores. Paste reads the clipboard and takes it only if it parses, so a
|
|
511
|
+
right-click on a clipboard holding something else leaves the colour alone. */
|
|
512
|
+
function copyAs(fmt: ColorFormat) {
|
|
513
|
+
const text = formatColor(props.alpha ? hsv.value : { ...hsv.value, a: 1 }, fmt)
|
|
514
|
+
// The toast quotes the value: a menu item that closes the moment it is picked
|
|
515
|
+
// leaves nothing on screen to confirm WHICH colour went to the clipboard, and
|
|
516
|
+
// the one thing worth checking is that it was the colour you meant.
|
|
517
|
+
const toast = props.copyToast
|
|
518
|
+
void copy(text, { toast: toast === false ? false : fill(toast, { value: text }) })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function pasteColor() {
|
|
522
|
+
try {
|
|
523
|
+
const text = await navigator.clipboard.readText()
|
|
524
|
+
const rgb = parseColor(text)
|
|
525
|
+
if (!rgb) return
|
|
526
|
+
typed.value = null
|
|
527
|
+
const next = rgbToHsv(rgb)
|
|
528
|
+
set(props.alpha ? next : { ...next, a: 1 }, true)
|
|
529
|
+
} catch {
|
|
530
|
+
// Reading needs a permission the user may not have granted. Same reasoning.
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Switching the format re-emits the SAME colour in the new shape, so a caller
|
|
536
|
+
* bound with v-model ends up holding what the field now shows. Without the
|
|
537
|
+
* emit the picker would display rgb() while the app still stored hex, and the
|
|
538
|
+
* next thing to read that value would disagree with the screen.
|
|
539
|
+
*/
|
|
540
|
+
function setFormat(f: ColorFormat) {
|
|
541
|
+
if (activeFormat.value === f) return
|
|
542
|
+
activeFormat.value = f
|
|
543
|
+
typed.value = null
|
|
544
|
+
emit("update:modelValue", current.value)
|
|
545
|
+
emit("change", current.value)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const menuItems = computed<ContextMenuItemDef[]>(() => {
|
|
549
|
+
const own = props.menuItems ?? []
|
|
550
|
+
if (props.menu === "none") return own
|
|
551
|
+
|
|
552
|
+
const builtin: ContextMenuItemDef[] = [
|
|
553
|
+
{ label: l.value.copyHex, icon: "lucide:copy", onSelect: () => copyAs("hex") },
|
|
554
|
+
{ label: l.value.copyRgb, icon: "lucide:copy", onSelect: () => copyAs("rgb") },
|
|
555
|
+
{ label: l.value.copyHsl, icon: "lucide:copy", onSelect: () => copyAs("hsl") },
|
|
556
|
+
{ label: l.value.paste, icon: "lucide:clipboard", onSelect: pasteColor },
|
|
557
|
+
{
|
|
558
|
+
// Ticks rather than a plain list: this is a setting with a current value,
|
|
559
|
+
// and the menu should say which one is on without being opened twice.
|
|
560
|
+
label: l.value.formatAs,
|
|
561
|
+
icon: "lucide:code",
|
|
562
|
+
separatorBefore: true,
|
|
563
|
+
children: (["hex", "rgb", "hsl"] as const).map((f) => ({
|
|
564
|
+
label: l.value[`format_${f}` as const],
|
|
565
|
+
checked: activeFormat.value === f,
|
|
566
|
+
onSelect: () => setFormat(f),
|
|
567
|
+
})),
|
|
568
|
+
},
|
|
569
|
+
]
|
|
570
|
+
// The consumer's own items are clearly theirs, below a rule.
|
|
571
|
+
return own.length
|
|
572
|
+
? [...builtin, { ...own[0], separatorBefore: true }, ...own.slice(1)]
|
|
573
|
+
: builtin
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
defineExpose({ open })
|
|
577
|
+
</script>
|
|
578
|
+
|
|
579
|
+
<template>
|
|
580
|
+
<PopoverRoot v-model:open="open">
|
|
581
|
+
<!-- reka supports chaining `as-child` triggers onto one element, but only
|
|
582
|
+
when each link forwards the props down — and LpContextMenu is a wrapper
|
|
583
|
+
with a slot, so the chain breaks at it: the popover ended up measuring
|
|
584
|
+
the menu wrapper rather than the button and put the panel 600px off
|
|
585
|
+
screen. PopoverAnchor is reka's own answer to this. It names what the
|
|
586
|
+
panel positions against, so the button is free to be the context
|
|
587
|
+
menu's trigger and the popover's trigger both. LpContextMenu renders
|
|
588
|
+
just its slot when `items` is empty, so `menu="none"` with no items of
|
|
589
|
+
your own leaves the native browser menu in place. -->
|
|
590
|
+
<PopoverAnchor class="block w-full">
|
|
591
|
+
<LpContextMenu :items="menuItems">
|
|
592
|
+
<PopoverTrigger as-child>
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
:disabled="disabled"
|
|
596
|
+
:aria-label="ariaLabel"
|
|
597
|
+
class="group inline-flex w-full items-center gap-2 rounded-control border bg-surface-soft px-2 text-left text-ink outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:border-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
598
|
+
:class="[
|
|
599
|
+
triggerSize[size],
|
|
600
|
+
invalid ? 'border-danger focus-visible:ring-danger-soft' : 'border-line focus-visible:border-brand',
|
|
601
|
+
]"
|
|
602
|
+
>
|
|
603
|
+
<!-- The swatch sits on the checkerboard so a transparent colour looks
|
|
604
|
+
transparent rather than like the panel behind it. One radius on
|
|
605
|
+
the outer box with `overflow-hidden`, the colour filling it edge
|
|
606
|
+
to edge, and the hairline as an inset ring ON TOP — a real border
|
|
607
|
+
would be painted over the checkerboard and, being translucent
|
|
608
|
+
white, would show it through as a see-through outline. -->
|
|
609
|
+
<span
|
|
610
|
+
class="relative size-5 shrink-0 overflow-hidden rounded-md"
|
|
611
|
+
:style="{ background: CHECKERBOARD }"
|
|
612
|
+
>
|
|
613
|
+
<span class="block size-full" :style="{ backgroundColor: previewCss }" />
|
|
614
|
+
<span class="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-line" />
|
|
615
|
+
</span>
|
|
616
|
+
<span class="min-w-0 flex-1 truncate font-mono text-xs text-muted-strong">
|
|
617
|
+
{{ label ?? current }}
|
|
618
|
+
</span>
|
|
619
|
+
<LpIcon
|
|
620
|
+
name="lucide:chevron-down"
|
|
621
|
+
:size="15"
|
|
622
|
+
class="shrink-0 text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[state=open]:rotate-180"
|
|
623
|
+
/>
|
|
624
|
+
</button>
|
|
625
|
+
</PopoverTrigger>
|
|
626
|
+
</LpContextMenu>
|
|
627
|
+
</PopoverAnchor>
|
|
628
|
+
|
|
629
|
+
<PopoverPortal>
|
|
630
|
+
<PopoverContent
|
|
631
|
+
:side="side"
|
|
632
|
+
:align="align"
|
|
633
|
+
:side-offset="6"
|
|
634
|
+
:class="[POPOVER_PANEL, 'z-(--z-popover) w-64 select-none rounded-card p-3 outline-none']"
|
|
635
|
+
@open-auto-focus="onPanelOpen"
|
|
636
|
+
>
|
|
637
|
+
<!-- The same menu inside the panel: copy/paste is wanted most while
|
|
638
|
+
you are actually choosing, and right-clicking the area you are
|
|
639
|
+
working in should not fall through to the browser's menu. -->
|
|
640
|
+
<LpContextMenu :items="menuItems">
|
|
641
|
+
<div class="flex flex-col gap-3">
|
|
642
|
+
<!-- Saturation (x) × value (y), over the flat hue — all three as
|
|
643
|
+
one background; see fieldStyle for why it is not layered. -->
|
|
644
|
+
<div
|
|
645
|
+
role="slider"
|
|
646
|
+
:tabindex="disabled ? -1 : 0"
|
|
647
|
+
:aria-label="l.saturation"
|
|
648
|
+
:aria-valuetext="fill(l.saturationValueText, { s: Math.round(hsv.s * 100), v: Math.round(hsv.v * 100) })"
|
|
649
|
+
class="relative h-36 w-full cursor-crosshair touch-none overflow-hidden rounded-control outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
650
|
+
:style="fieldStyle"
|
|
651
|
+
@pointerdown="onFieldPointer"
|
|
652
|
+
@keydown="step($event, 'sv')"
|
|
653
|
+
>
|
|
654
|
+
<span
|
|
655
|
+
class="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-pill border-2 border-white shadow ring-1 ring-black/30"
|
|
656
|
+
:style="{
|
|
657
|
+
left: `${hsv.s * 100}%`,
|
|
658
|
+
top: `${(1 - hsv.v) * 100}%`,
|
|
659
|
+
backgroundColor: previewCss,
|
|
660
|
+
}"
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
<!-- Hue rail. -->
|
|
665
|
+
<div
|
|
666
|
+
role="slider"
|
|
667
|
+
:aria-label="l.hue"
|
|
668
|
+
:aria-valuemin="0"
|
|
669
|
+
:aria-valuemax="360"
|
|
670
|
+
:aria-valuenow="Math.round(hsv.h)"
|
|
671
|
+
:tabindex="disabled ? -1 : 0"
|
|
672
|
+
class="relative h-3 w-full cursor-pointer touch-none rounded-pill outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
673
|
+
style="background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
|
|
674
|
+
@pointerdown="onHuePointer"
|
|
675
|
+
@keydown="step($event, 'h')"
|
|
676
|
+
>
|
|
677
|
+
<span
|
|
678
|
+
class="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-pill border-2 border-white shadow ring-1 ring-black/30"
|
|
679
|
+
:style="{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: hueCss }"
|
|
680
|
+
/>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<!-- Alpha rail — opaque-to-transparent of the current hue, on the
|
|
684
|
+
checkerboard so the far end reads as see-through. -->
|
|
685
|
+
<div
|
|
686
|
+
v-if="alpha"
|
|
687
|
+
role="slider"
|
|
688
|
+
:aria-label="l.opacity"
|
|
689
|
+
:aria-valuemin="0"
|
|
690
|
+
:aria-valuemax="100"
|
|
691
|
+
:aria-valuenow="Math.round(hsv.a * 100)"
|
|
692
|
+
:tabindex="disabled ? -1 : 0"
|
|
693
|
+
class="relative h-3 w-full cursor-pointer touch-none rounded-pill outline-none ring-1 ring-inset ring-line focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
694
|
+
:style="{ background: CHECKERBOARD }"
|
|
695
|
+
@pointerdown="onAlphaPointer"
|
|
696
|
+
@keydown="step($event, 'a')"
|
|
697
|
+
>
|
|
698
|
+
<!-- Fades from the colour at zero alpha to the colour at full, NOT
|
|
699
|
+
from `transparent`: that keyword is transparent black, which
|
|
700
|
+
dirtied the see-through end with grey instead of letting the
|
|
701
|
+
checkerboard show through cleanly. -->
|
|
702
|
+
<div
|
|
703
|
+
class="absolute inset-0 overflow-hidden rounded-pill"
|
|
704
|
+
:style="{ backgroundImage: `linear-gradient(to right, ${alphaRampStart}, ${opaqueCss(hsv)})` }"
|
|
705
|
+
/>
|
|
706
|
+
<span
|
|
707
|
+
class="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-pill border-2 border-white shadow ring-1 ring-black/30"
|
|
708
|
+
:style="{ left: `${hsv.a * 100}%`, backgroundColor: previewCss }"
|
|
709
|
+
/>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<!-- Text field: type or paste a colour, or copy the one on screen. -->
|
|
713
|
+
<div
|
|
714
|
+
v-if="!hideInput"
|
|
715
|
+
data-lp-ring-owner
|
|
716
|
+
class="flex h-(--size-control-sm) items-center gap-2 overflow-hidden rounded-control border border-line bg-surface-soft px-2 transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring"
|
|
717
|
+
>
|
|
718
|
+
<span
|
|
719
|
+
class="size-4 shrink-0 rounded-[5px] border border-line"
|
|
720
|
+
:style="{ backgroundColor: previewCss }"
|
|
721
|
+
/>
|
|
722
|
+
<input
|
|
723
|
+
:value="inputValue"
|
|
724
|
+
:disabled="disabled"
|
|
725
|
+
spellcheck="false"
|
|
726
|
+
autocapitalize="off"
|
|
727
|
+
autocomplete="off"
|
|
728
|
+
:aria-label="l.value"
|
|
729
|
+
class="min-w-0 flex-1 select-text bg-transparent font-mono text-xs text-ink outline-none placeholder:text-muted"
|
|
730
|
+
@input="onInput"
|
|
731
|
+
@blur="onInputBlur"
|
|
732
|
+
/>
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
<!-- Recent sits ABOVE the presets because it is the row that
|
|
736
|
+
changes: what you reached for last time is a better guess than
|
|
737
|
+
a fixed palette, so the eye should land on it first. Both rows
|
|
738
|
+
are the same markup — one swatch button, described by `rows`. -->
|
|
739
|
+
<div v-for="row in swatchRows" :key="row.key" class="flex flex-col gap-1.5">
|
|
740
|
+
<span
|
|
741
|
+
v-if="row.heading"
|
|
742
|
+
class="text-[11px] font-medium uppercase tracking-wide text-muted"
|
|
743
|
+
>
|
|
744
|
+
{{ row.heading }}
|
|
745
|
+
</span>
|
|
746
|
+
<div class="grid gap-1.5" :class="row.cols">
|
|
747
|
+
<button
|
|
748
|
+
v-for="c in row.colors"
|
|
749
|
+
:key="c"
|
|
750
|
+
type="button"
|
|
751
|
+
:disabled="disabled"
|
|
752
|
+
:aria-label="c"
|
|
753
|
+
:aria-pressed="isActiveSwatch(c)"
|
|
754
|
+
class="relative flex items-center justify-center overflow-hidden rounded-md outline-none transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed motion-reduce:hover:scale-100 motion-reduce:active:scale-95"
|
|
755
|
+
:class="row.size"
|
|
756
|
+
:style="{ background: CHECKERBOARD }"
|
|
757
|
+
@click="pickSwatch(c)"
|
|
758
|
+
>
|
|
759
|
+
<!-- On the checkerboard in both rows: a preset is opaque and
|
|
760
|
+
simply covers it, while a remembered colour may not be.
|
|
761
|
+
The colour fills the whole button — no border to leave a
|
|
762
|
+
1px moat of checkerboard around it — and the frame is an
|
|
763
|
+
inset ring painted OVER the colour instead, so it reads
|
|
764
|
+
as one fixed hairline rather than a translucent gap. -->
|
|
765
|
+
<span class="absolute inset-0" :style="{ backgroundColor: c }" />
|
|
766
|
+
<span class="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-line" />
|
|
767
|
+
<LpIcon
|
|
768
|
+
v-if="isActiveSwatch(c)"
|
|
769
|
+
name="lucide:check"
|
|
770
|
+
:size="13"
|
|
771
|
+
class="relative"
|
|
772
|
+
:class="tickClass(c)"
|
|
773
|
+
/>
|
|
774
|
+
</button>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
<slot :color="current" :hsv="hsv" />
|
|
779
|
+
</div>
|
|
780
|
+
</LpContextMenu>
|
|
781
|
+
</PopoverContent>
|
|
782
|
+
</PopoverPortal>
|
|
783
|
+
</PopoverRoot>
|
|
784
|
+
</template>
|