@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.
Files changed (39) hide show
  1. package/dist/chunks/LpActionBar-CxmBZpGu.js +173 -0
  2. package/dist/chunks/{LpAppShell-BJk3b-u4.js → LpAppShell-DnocEJAu.js} +1 -1
  3. package/dist/chunks/{LpCodeBlock-rS_6mO3D.js → LpCodeBlock-BWX1KZwk.js} +7 -13
  4. package/dist/chunks/LpColorPicker-CFC6iaNK.js +491 -0
  5. package/dist/chunks/{LpDisclosure-BnwP4Q-b.js → LpDisclosure-C1_YkMrd.js} +8 -2
  6. package/dist/chunks/{LpLogViewer-CVaGfTF_.js → LpLogViewer-TSRyt3t9.js} +3 -6
  7. package/dist/chunks/{LpSidebar-C_Mr-JPI.js → LpSidebar-CWSbczWi.js} +11 -2
  8. package/dist/component-names.d.ts +1 -1
  9. package/dist/components/LpActionBar.vue.d.ts +84 -0
  10. package/dist/components/LpActionBar.vue.js +4 -0
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCodeBlock.vue.js +1 -1
  13. package/dist/components/LpColorPicker.vue.d.ts +124 -0
  14. package/dist/components/LpColorPicker.vue.js +4 -0
  15. package/dist/components/LpDisclosure.vue.js +1 -1
  16. package/dist/components/LpLogViewer.vue.js +1 -1
  17. package/dist/components/LpNotificationBell.vue.d.ts +2 -2
  18. package/dist/components/LpSidebar.vue.d.ts +21 -0
  19. package/dist/components/LpSidebar.vue.js +1 -1
  20. package/dist/components/actionBar.d.ts +19 -0
  21. package/dist/components/actionBar.js +31 -0
  22. package/dist/components/color.d.ts +65 -0
  23. package/dist/components/color.js +158 -0
  24. package/dist/composables/useClipboard.d.ts +46 -0
  25. package/dist/composables/useClipboard.js +28 -0
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +145 -126
  28. package/package.json +1 -1
  29. package/src/component-names.ts +2 -0
  30. package/src/components/LpActionBar.vue +268 -0
  31. package/src/components/LpCodeBlock.vue +5 -10
  32. package/src/components/LpColorPicker.vue +784 -0
  33. package/src/components/LpDisclosure.vue +17 -2
  34. package/src/components/LpLogViewer.vue +5 -8
  35. package/src/components/LpSidebar.vue +42 -6
  36. package/src/components/actionBar.ts +73 -0
  37. package/src/components/color.ts +257 -0
  38. package/src/composables/useClipboard.ts +107 -0
  39. 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>