@leavepulse/ui 0.29.5 → 0.30.1
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-BlvtGB2P.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 +276 -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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* shown content isn't left below the fold.
|
|
10
10
|
*/
|
|
11
11
|
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
|
|
12
|
-
import { type ComponentPublicInstance, nextTick, ref } from "vue"
|
|
12
|
+
import { type ComponentPublicInstance, computed, getCurrentInstance, nextTick, ref } from "vue"
|
|
13
13
|
import LpIcon from "./LpIcon.vue"
|
|
14
14
|
|
|
15
15
|
const props = withDefaults(
|
|
@@ -33,6 +33,21 @@ const props = withDefaults(
|
|
|
33
33
|
|
|
34
34
|
const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
|
|
35
35
|
|
|
36
|
+
/*
|
|
37
|
+
* Vue casts an absent Boolean prop to `false`, so `open` alone cannot say whether
|
|
38
|
+
* the caller controls this block. Passing that `false` straight to reka pins the
|
|
39
|
+
* block shut: it reads a controlled-closed collapsible, and the trigger stops
|
|
40
|
+
* doing anything at all — `defaultOpen` never gets a say. So control is decided
|
|
41
|
+
* by whether the attribute was actually handed to us, and the uncontrolled case
|
|
42
|
+
* passes `undefined`, which is what reka checks for.
|
|
43
|
+
*/
|
|
44
|
+
const instance = getCurrentInstance()
|
|
45
|
+
const controlled = computed(() => {
|
|
46
|
+
const raw = instance?.vnode.props
|
|
47
|
+
return Boolean(raw && ("open" in raw || "onUpdate:open" in raw))
|
|
48
|
+
})
|
|
49
|
+
const rootOpen = computed(() => (controlled.value ? props.open : undefined))
|
|
50
|
+
|
|
36
51
|
// reka's CollapsibleRoot renders a real element but exposes it as a component
|
|
37
52
|
// instance, so reach through $el for the DOM node we scroll into view.
|
|
38
53
|
const root = ref<ComponentPublicInstance | null>(null)
|
|
@@ -55,7 +70,7 @@ function onToggle(value: boolean) {
|
|
|
55
70
|
<template>
|
|
56
71
|
<CollapsibleRoot
|
|
57
72
|
ref="root"
|
|
58
|
-
:open="
|
|
73
|
+
:open="rootOpen"
|
|
59
74
|
:default-open="defaultOpen"
|
|
60
75
|
class="overflow-hidden rounded-control border border-line bg-surface-soft"
|
|
61
76
|
@update:open="onToggle"
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
watch,
|
|
19
19
|
} from "vue"
|
|
20
20
|
import { prefersReducedMotion } from "../composables/easing"
|
|
21
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
21
22
|
import { useHotkeys } from "../composables/useHotkeys"
|
|
22
23
|
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
23
24
|
import LpIcon from "./LpIcon.vue"
|
|
@@ -344,14 +345,10 @@ function fmtTime(time?: number | string): string {
|
|
|
344
345
|
}
|
|
345
346
|
|
|
346
347
|
// ── row context menu ─────────────────────────────────────────────────
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Clipboard can reject (insecure context / denied permission); swallow —
|
|
352
|
-
// the menu action is best-effort and shouldn't throw into the UI.
|
|
353
|
-
}
|
|
354
|
-
}
|
|
348
|
+
// A menu item closes the menu as it runs, leaving nothing on screen to say the
|
|
349
|
+
// copy happened — so unlike LpCodeBlock's button, this one announces itself.
|
|
350
|
+
const { copy: writeClipboard } = useClipboard()
|
|
351
|
+
const copy = (text: string) => writeClipboard(text, { toast: "Copied to clipboard" })
|
|
355
352
|
|
|
356
353
|
// The whole line as one copyable string: "time LEVEL [source] message".
|
|
357
354
|
function lineText(line: LogLine): string {
|
|
@@ -56,6 +56,26 @@ const props = withDefaults(
|
|
|
56
56
|
* flush, header-less look.
|
|
57
57
|
*/
|
|
58
58
|
divider?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Where this nav lives.
|
|
61
|
+
*
|
|
62
|
+
* `rail` is the app shell's edge: one hairline on the inner side, square
|
|
63
|
+
* corners, the raised shell background.
|
|
64
|
+
*
|
|
65
|
+
* `panel` is a nav that sits inside a page next to the content it drives —
|
|
66
|
+
* a settings index, a table picker. It is a card: boxed on all four sides
|
|
67
|
+
* and rounded. Without it the rail's single border reads as a stray line
|
|
68
|
+
* down the middle of the page, and a consumer cannot cancel it with a class
|
|
69
|
+
* of their own.
|
|
70
|
+
*/
|
|
71
|
+
variant?: "rail" | "panel"
|
|
72
|
+
/**
|
|
73
|
+
* Rail width. A plain `class` cannot set it — the component's own `w-60`
|
|
74
|
+
* wins, and the kit does not merge conflicting Tailwind classes — so the
|
|
75
|
+
* width is a prop. Any CSS length; a page-embedded nav usually wants to
|
|
76
|
+
* match the column it lives in.
|
|
77
|
+
*/
|
|
78
|
+
width?: string
|
|
59
79
|
/**
|
|
60
80
|
* Which collapsible groups are folded away (v-model:collapsed). Leave it
|
|
61
81
|
* unbound to let the sidebar fold long navs on its own — see
|
|
@@ -82,6 +102,7 @@ const props = withDefaults(
|
|
|
82
102
|
responsive: false,
|
|
83
103
|
mobileBreakpoint: "md",
|
|
84
104
|
divider: true,
|
|
105
|
+
variant: "rail",
|
|
85
106
|
autoCollapseAfter: 14,
|
|
86
107
|
},
|
|
87
108
|
)
|
|
@@ -170,6 +191,15 @@ const railClass = computed(() =>
|
|
|
170
191
|
props.responsive ? RAIL_VISIBILITY[props.mobileBreakpoint] : "flex",
|
|
171
192
|
)
|
|
172
193
|
|
|
194
|
+
// Chrome is per variant, not per consumer: a `class` cannot cancel a border the
|
|
195
|
+
// component already put on (the kit does not merge conflicting Tailwind classes),
|
|
196
|
+
// so the two looks are spelled out here instead.
|
|
197
|
+
const chromeClass = computed(() =>
|
|
198
|
+
props.variant === "panel"
|
|
199
|
+
? "rounded-card border border-line bg-surface-raised"
|
|
200
|
+
: "border-r border-line bg-surface-raised",
|
|
201
|
+
)
|
|
202
|
+
|
|
173
203
|
// This component has a fragment root (the desktop <nav> + the mobile drawer), so
|
|
174
204
|
// Vue can't auto-inherit a consumer-passed `class`. Take attrs over manually and
|
|
175
205
|
// land them on the desktop rail (where a layout class like `shrink-0` belongs).
|
|
@@ -179,17 +209,23 @@ defineOptions({ inheritAttrs: false })
|
|
|
179
209
|
<template>
|
|
180
210
|
<!-- Desktop / always-on rail -->
|
|
181
211
|
<nav
|
|
182
|
-
class="h-full
|
|
183
|
-
:class="railClass"
|
|
212
|
+
class="h-full flex-col gap-1 p-3"
|
|
213
|
+
:class="[railClass, chromeClass, width ? '' : 'w-60']"
|
|
214
|
+
:style="width ? { width } : undefined"
|
|
184
215
|
aria-label="Sidebar"
|
|
185
216
|
v-bind="$attrs"
|
|
186
217
|
>
|
|
187
|
-
<!--
|
|
188
|
-
app header, so its bottom hairline continues the header's border-b.
|
|
218
|
+
<!-- Rail: edge-to-edge (negative margins cancel the p-3) and h-16 to match
|
|
219
|
+
the app header, so its bottom hairline continues the header's border-b.
|
|
220
|
+
Panel: kept inside the padding — bleeding to the edge would cut across
|
|
221
|
+
the rounded corners. -->
|
|
189
222
|
<div
|
|
190
223
|
v-if="$slots.header"
|
|
191
|
-
class="
|
|
192
|
-
:class="
|
|
224
|
+
class="mb-3 flex shrink-0 items-center"
|
|
225
|
+
:class="[
|
|
226
|
+
variant === 'panel' ? 'pb-3' : '-mx-3 -mt-3 h-16 px-3',
|
|
227
|
+
divider ? 'border-b border-line' : '',
|
|
228
|
+
]"
|
|
193
229
|
>
|
|
194
230
|
<slot name="header" />
|
|
195
231
|
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { onScopeDispose, ref } from "vue"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Shared stack for LpActionBar.
|
|
5
|
+
*
|
|
6
|
+
* Two bars are not a contrived case: a cookie notice sits on a first visit
|
|
7
|
+
* while the page underneath is a form, and the moment that form is edited the
|
|
8
|
+
* unsaved-changes bar wants the same strip of screen. Both are fixed to the
|
|
9
|
+
* bottom, both are translucent, so they landed on top of each other and the
|
|
10
|
+
* text of one showed through the other.
|
|
11
|
+
*
|
|
12
|
+
* Each open bar registers here and gets back a live index. The bar offsets
|
|
13
|
+
* itself by the heights of everything below it, so they stack upwards in the
|
|
14
|
+
* order they opened — the oldest at the bottom, the newest above it, which is
|
|
15
|
+
* also the order a person met them in.
|
|
16
|
+
*
|
|
17
|
+
* The registry is pinned to a global symbol for the same reason the toast queue
|
|
18
|
+
* is: a barrel import and a deep import can resolve to two module instances,
|
|
19
|
+
* and two registries would be no registry at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface BarEntry {
|
|
23
|
+
id: number
|
|
24
|
+
/** Measured height in px, reported by the bar once it is on screen. */
|
|
25
|
+
height: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STATE_KEY = Symbol.for("@leavepulse/ui:action-bar-stack")
|
|
29
|
+
type GlobalWithStack = typeof globalThis & {
|
|
30
|
+
[STATE_KEY]?: { bars: ReturnType<typeof ref<BarEntry[]>>; nextId: number }
|
|
31
|
+
}
|
|
32
|
+
const g = globalThis as GlobalWithStack
|
|
33
|
+
const state = (g[STATE_KEY] ??= { bars: ref<BarEntry[]>([]), nextId: 0 })
|
|
34
|
+
|
|
35
|
+
/** Gap between stacked bars, matching the padding the bar sits in. */
|
|
36
|
+
export const STACK_GAP = 8
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Registers a bar for as long as the calling scope lives. `setHeight` reports
|
|
40
|
+
* the measured height; `offset()` returns how far up this bar should sit —
|
|
41
|
+
* the total height of every bar that opened before it.
|
|
42
|
+
*/
|
|
43
|
+
export function useBarStack() {
|
|
44
|
+
const id = state.nextId++
|
|
45
|
+
const bars = state.bars as ReturnType<typeof ref<BarEntry[]>>
|
|
46
|
+
|
|
47
|
+
function register() {
|
|
48
|
+
if (!bars.value!.some((b) => b.id === id)) {
|
|
49
|
+
bars.value = [...bars.value!, { id, height: 0 }]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unregister() {
|
|
54
|
+
bars.value = bars.value!.filter((b) => b.id !== id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setHeight(height: number) {
|
|
58
|
+
bars.value = bars.value!.map((b) => (b.id === id ? { ...b, height } : b))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Sum of the heights below this one, plus a gap for each. */
|
|
62
|
+
function offset(): number {
|
|
63
|
+
const i = bars.value!.findIndex((b) => b.id === id)
|
|
64
|
+
if (i <= 0) return 0
|
|
65
|
+
return bars.value!
|
|
66
|
+
.slice(0, i)
|
|
67
|
+
.reduce((total, b) => total + b.height + STACK_GAP, 0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onScopeDispose(unregister)
|
|
71
|
+
|
|
72
|
+
return { register, unregister, setHeight, offset, bars }
|
|
73
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Colour conversion for LpColorPicker — kept out of the component so the maths
|
|
3
|
+
* is testable on its own and reusable by anything that has to meet a colour
|
|
4
|
+
* halfway (a swatch list, a theme editor, a chart legend).
|
|
5
|
+
*
|
|
6
|
+
* The picker works in HSV, not HSL: a saturation/value rectangle under a hue
|
|
7
|
+
* rail is the shape everybody already knows from design tools, and it is the
|
|
8
|
+
* one where dragging in a straight line does what the eye expects. HSL's
|
|
9
|
+
* lightness axis collapses to white and black at both ends of the rectangle,
|
|
10
|
+
* which wastes half the area on colours nobody is aiming for.
|
|
11
|
+
*
|
|
12
|
+
* Alpha travels alongside as 0..1 and is dropped from the output when it is
|
|
13
|
+
* fully opaque — "#3b82f6" rather than "#3b82f6ff", since the short form is
|
|
14
|
+
* what a person pastes into a stylesheet.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Hue 0..360, saturation/value 0..1, alpha 0..1. */
|
|
18
|
+
export interface Hsv {
|
|
19
|
+
h: number
|
|
20
|
+
s: number
|
|
21
|
+
v: number
|
|
22
|
+
a: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Channels 0..255, alpha 0..1. */
|
|
26
|
+
export interface Rgb {
|
|
27
|
+
r: number
|
|
28
|
+
g: number
|
|
29
|
+
b: number
|
|
30
|
+
a: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The string shapes a caller can hand the picker, and ask back out of it. */
|
|
34
|
+
export type ColorFormat = "hex" | "rgb" | "hsl"
|
|
35
|
+
|
|
36
|
+
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
|
|
37
|
+
|
|
38
|
+
/** Wraps rather than clamps: hue is a circle, and 370° is 10°, not 360°. */
|
|
39
|
+
export function normalizeHue(h: number): number {
|
|
40
|
+
const wrapped = h % 360
|
|
41
|
+
return wrapped < 0 ? wrapped + 360 : wrapped
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clampHsv(c: Hsv): Hsv {
|
|
45
|
+
return {
|
|
46
|
+
h: normalizeHue(c.h),
|
|
47
|
+
s: clamp(c.s, 0, 1),
|
|
48
|
+
v: clamp(c.v, 0, 1),
|
|
49
|
+
a: clamp(c.a, 0, 1),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hsvToRgb({ h, s, v, a }: Hsv): Rgb {
|
|
54
|
+
const hue = normalizeHue(h) / 60
|
|
55
|
+
const c = v * s
|
|
56
|
+
const x = c * (1 - Math.abs((hue % 2) - 1))
|
|
57
|
+
const m = v - c
|
|
58
|
+
// Sextant of the colour wheel; each one puts c/x/0 in a different order.
|
|
59
|
+
const [r, g, b] = (
|
|
60
|
+
[
|
|
61
|
+
[c, x, 0],
|
|
62
|
+
[x, c, 0],
|
|
63
|
+
[0, c, x],
|
|
64
|
+
[0, x, c],
|
|
65
|
+
[x, 0, c],
|
|
66
|
+
[c, 0, x],
|
|
67
|
+
] as const
|
|
68
|
+
)[Math.floor(hue) % 6]
|
|
69
|
+
return {
|
|
70
|
+
r: Math.round((r + m) * 255),
|
|
71
|
+
g: Math.round((g + m) * 255),
|
|
72
|
+
b: Math.round((b + m) * 255),
|
|
73
|
+
a: clamp(a, 0, 1),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function rgbToHsv({ r, g, b, a }: Rgb): Hsv {
|
|
78
|
+
const rn = r / 255
|
|
79
|
+
const gn = g / 255
|
|
80
|
+
const bn = b / 255
|
|
81
|
+
const max = Math.max(rn, gn, bn)
|
|
82
|
+
const min = Math.min(rn, gn, bn)
|
|
83
|
+
const d = max - min
|
|
84
|
+
|
|
85
|
+
let h = 0
|
|
86
|
+
if (d !== 0) {
|
|
87
|
+
if (max === rn) h = 60 * (((gn - bn) / d) % 6)
|
|
88
|
+
else if (max === gn) h = 60 * ((bn - rn) / d + 2)
|
|
89
|
+
else h = 60 * ((rn - gn) / d + 4)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
h: normalizeHue(h),
|
|
94
|
+
s: max === 0 ? 0 : d / max,
|
|
95
|
+
v: max,
|
|
96
|
+
a: clamp(a, 0, 1),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hex2 = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0")
|
|
101
|
+
|
|
102
|
+
/** "#rrggbb", or "#rrggbbaa" when the colour is not fully opaque. */
|
|
103
|
+
export function rgbToHex({ r, g, b, a }: Rgb): string {
|
|
104
|
+
const base = `#${hex2(r)}${hex2(g)}${hex2(b)}`
|
|
105
|
+
return a >= 1 ? base : base + hex2(a * 255)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parses #rgb / #rgba / #rrggbb / #rrggbbaa, with or without the leading `#`.
|
|
110
|
+
* Returns null for anything it doesn't recognise, so a half-typed value in the
|
|
111
|
+
* hex field can be left alone instead of snapping the picker to black.
|
|
112
|
+
*/
|
|
113
|
+
export function parseHex(input: string): Rgb | null {
|
|
114
|
+
const s = input.trim().replace(/^#/, "")
|
|
115
|
+
if (!/^[0-9a-f]+$/i.test(s)) return null
|
|
116
|
+
|
|
117
|
+
// The 3/4-digit forms double each digit: "f80" is "ff8800".
|
|
118
|
+
const expand = (hex: string) =>
|
|
119
|
+
hex.length <= 4 ? hex.split("").map((ch) => ch + ch) : (hex.match(/.{2}/g) ?? [])
|
|
120
|
+
|
|
121
|
+
const parts = expand(s)
|
|
122
|
+
if (s.length !== 3 && s.length !== 4 && s.length !== 6 && s.length !== 8) return null
|
|
123
|
+
|
|
124
|
+
const [r, g, b, alpha] = parts.map((p) => parseInt(p, 16))
|
|
125
|
+
return { r, g, b, a: alpha === undefined ? 1 : alpha / 255 }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parses a colour a person might paste: hex (3/4/6/8 digits, `#` optional),
|
|
130
|
+
* rgb()/rgba() and hsl()/hsla() in both the comma and the space/slash forms,
|
|
131
|
+
* percentages, hue in deg/turn/rad/grad, brackets optional, and a bare triple
|
|
132
|
+
* of numbers read as rgb. A round-trip through a caller's storage therefore
|
|
133
|
+
* comes back the colour it went in as, whatever shape it was stored in.
|
|
134
|
+
*
|
|
135
|
+
* Named colours ("rebeccapurple") and the newer spaces (lab, oklch, color())
|
|
136
|
+
* are out of scope: resolving those needs the browser's own parser, and a
|
|
137
|
+
* component that silently handled some and not others would be worse than one
|
|
138
|
+
* that is clear about what it takes.
|
|
139
|
+
*/
|
|
140
|
+
export function parseColor(input: string): Rgb | null {
|
|
141
|
+
const s = input.trim()
|
|
142
|
+
if (!s) return null
|
|
143
|
+
if (s.startsWith("#") || /^[0-9a-f]{3,8}$/i.test(s)) return parseHex(s)
|
|
144
|
+
|
|
145
|
+
// Brackets optional, and a bare triple of numbers is taken as rgb. What gets
|
|
146
|
+
// pasted into a colour field is rarely canonical CSS: it comes off a design
|
|
147
|
+
// tool as "rgb 59 130 246", out of a spreadsheet as "59, 130, 246", or with
|
|
148
|
+
// the closing bracket lost to a sloppy selection. All of them name a colour
|
|
149
|
+
// unambiguously, and refusing them would be pedantry at the user's expense.
|
|
150
|
+
const fn = s.match(/^(rgba?|hsla?)\s*\(?([^)]*)\)?$/i) ?? (
|
|
151
|
+
/^[\d.,\s/%]+$/.test(s) ? ([null, "rgb", s] as unknown as RegExpMatchArray) : null
|
|
152
|
+
)
|
|
153
|
+
if (!fn) return null
|
|
154
|
+
|
|
155
|
+
// Both the legacy comma form and the modern space form, with an optional
|
|
156
|
+
// "/ alpha" — the two shapes browsers themselves accept.
|
|
157
|
+
const parts = fn[2].split(/[\s,/]+/).filter(Boolean)
|
|
158
|
+
if (parts.length < 3) return null
|
|
159
|
+
|
|
160
|
+
// `deg` is the default unit and `turn`/`rad`/`grad` are the others CSS allows
|
|
161
|
+
// for a hue; parseFloat drops the suffix, so only turn/rad/grad need scaling.
|
|
162
|
+
const num = (t: string) => parseFloat(t)
|
|
163
|
+
const angle = (t: string) => {
|
|
164
|
+
const n = parseFloat(t)
|
|
165
|
+
if (/turn$/i.test(t)) return n * 360
|
|
166
|
+
if (/rad$/i.test(t)) return (n * 180) / Math.PI
|
|
167
|
+
if (/grad$/i.test(t)) return n * 0.9
|
|
168
|
+
return n
|
|
169
|
+
}
|
|
170
|
+
const alpha = parts[3] === undefined ? 1 : parts[3].endsWith("%") ? num(parts[3]) / 100 : num(parts[3])
|
|
171
|
+
if (Number.isNaN(alpha)) return null
|
|
172
|
+
|
|
173
|
+
if (fn[1].toLowerCase().startsWith("rgb")) {
|
|
174
|
+
const [r, g, b] = parts.slice(0, 3).map((t) => (t.endsWith("%") ? (num(t) / 100) * 255 : num(t)))
|
|
175
|
+
if ([r, g, b].some(Number.isNaN)) return null
|
|
176
|
+
return { r: Math.round(clamp(r, 0, 255)), g: Math.round(clamp(g, 0, 255)), b: Math.round(clamp(b, 0, 255)), a: clamp(alpha, 0, 1) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const h = angle(parts[0])
|
|
180
|
+
const sl = num(parts[1]) / 100
|
|
181
|
+
const l = num(parts[2]) / 100
|
|
182
|
+
if ([h, sl, l].some(Number.isNaN)) return null
|
|
183
|
+
return hslToRgb(h, clamp(sl, 0, 1), clamp(l, 0, 1), clamp(alpha, 0, 1))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hslToRgb(h: number, s: number, l: number, a: number): Rgb {
|
|
187
|
+
// HSL→HSV, then reuse the one conversion that already exists.
|
|
188
|
+
const v = l + s * Math.min(l, 1 - l)
|
|
189
|
+
return hsvToRgb({ h, s: v === 0 ? 0 : 2 * (1 - l / v), v, a })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** HSV→HSL, for the hsl() output format. Saturation/lightness come back 0..1. */
|
|
193
|
+
export function hsvToHsl({ h, s, v }: Hsv): { h: number; s: number; l: number } {
|
|
194
|
+
const l = v * (1 - s / 2)
|
|
195
|
+
const denom = Math.min(l, 1 - l)
|
|
196
|
+
return { h: normalizeHue(h), s: denom === 0 ? 0 : (v - l) / denom, l }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The colour as a CSS string in the requested format. */
|
|
200
|
+
export function formatColor(c: Hsv, format: ColorFormat): string {
|
|
201
|
+
const rgb = hsvToRgb(c)
|
|
202
|
+
const a = Math.round(clamp(c.a, 0, 1) * 100) / 100
|
|
203
|
+
|
|
204
|
+
if (format === "hex") return rgbToHex(rgb)
|
|
205
|
+
if (format === "rgb") {
|
|
206
|
+
return a >= 1
|
|
207
|
+
? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`
|
|
208
|
+
: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { h, s, l } = hsvToHsl(c)
|
|
212
|
+
const hh = Math.round(h)
|
|
213
|
+
const ss = Math.round(s * 100)
|
|
214
|
+
const ll = Math.round(l * 100)
|
|
215
|
+
return a >= 1 ? `hsl(${hh}, ${ss}%, ${ll}%)` : `hsla(${hh}, ${ss}%, ${ll}%, ${a})`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Always opaque — for the swatch behind an alpha checkerboard, and for rails. */
|
|
219
|
+
export function opaqueCss(c: Hsv): string {
|
|
220
|
+
const { r, g, b } = hsvToRgb({ ...c, a: 1 })
|
|
221
|
+
return `rgb(${r}, ${g}, ${b})`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether dark ink reads better than light on this colour, by WCAG relative
|
|
226
|
+
* luminance. The picker uses it for the check mark on a selected swatch, which
|
|
227
|
+
* otherwise disappears into a pale preset.
|
|
228
|
+
*/
|
|
229
|
+
export function isLight(c: Hsv): boolean {
|
|
230
|
+
const { r, g, b } = hsvToRgb(c)
|
|
231
|
+
const lin = (n: number) => {
|
|
232
|
+
const x = n / 255
|
|
233
|
+
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
|
|
234
|
+
}
|
|
235
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) > 0.35
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A neutral starting palette. Kit tokens are deliberately NOT used here: these
|
|
240
|
+
* are values a user picks *for their own data* (a tag, a calendar category),
|
|
241
|
+
* which must stay put when the app's theme changes — a preset that shifted with
|
|
242
|
+
* the theme would silently restyle everything already saved with it.
|
|
243
|
+
*/
|
|
244
|
+
export const DEFAULT_SWATCHES = [
|
|
245
|
+
"#ef4444",
|
|
246
|
+
"#f97316",
|
|
247
|
+
"#f59e0b",
|
|
248
|
+
"#22c55e",
|
|
249
|
+
"#10b981",
|
|
250
|
+
"#06b6d4",
|
|
251
|
+
"#3b82f6",
|
|
252
|
+
"#6366f1",
|
|
253
|
+
"#8b5cf6",
|
|
254
|
+
"#ec4899",
|
|
255
|
+
"#64748b",
|
|
256
|
+
"#111827",
|
|
257
|
+
] as const
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { onScopeDispose, ref } from "vue"
|
|
2
|
+
import { useToast } from "./useToast"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* "Copy this text, tell the person it worked, and don't throw if it didn't."
|
|
6
|
+
*
|
|
7
|
+
* Four components had grown their own version of this — LpCodeBlock with a
|
|
8
|
+
* `copied` flag on a 1400ms timer, LpLogViewer with a silent swallow, the
|
|
9
|
+
* colour picker with another — each wrapping `navigator.clipboard.writeText`
|
|
10
|
+
* in a try/catch and each disagreeing about what the user should see. Copying
|
|
11
|
+
* is never the task a person came to do; it is a step inside one, so the thing
|
|
12
|
+
* that matters is the answer arriving and nothing blowing up on the way.
|
|
13
|
+
*
|
|
14
|
+
* Why every call is allowed to fail: the Clipboard API needs a secure context
|
|
15
|
+
* and, in some browsers, a permission. It is absent over plain http, rejects
|
|
16
|
+
* outside a user gesture, and is missing entirely in a non-DOM test runner. A
|
|
17
|
+
* copy that cannot happen is a disappointment, not an exception, and a rejected
|
|
18
|
+
* promise reaching a component means an unhandled rejection in the console of
|
|
19
|
+
* an app that did nothing wrong.
|
|
20
|
+
*
|
|
21
|
+
* The `copied` flag is here because the other half of the feedback is the
|
|
22
|
+
* button itself: a tick that replaces the copy icon for a moment reads faster
|
|
23
|
+
* than any message, and the two are complementary rather than alternatives.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** How long `copied` stays true — long enough to read, short enough to not linger. */
|
|
27
|
+
const COPIED_MS = 1400
|
|
28
|
+
|
|
29
|
+
export interface CopyOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Toast to raise on success. `false` (the default) stays silent, for callers
|
|
32
|
+
* whose button already shows the tick and would otherwise say it twice.
|
|
33
|
+
*/
|
|
34
|
+
toast?: string | false
|
|
35
|
+
/**
|
|
36
|
+
* Toast to raise when the copy fails. Defaults to silence for the same
|
|
37
|
+
* reason the kit's other best-effort paths are silent: a copy that didn't
|
|
38
|
+
* happen is usually obvious, and a scary red box over a blocked clipboard
|
|
39
|
+
* helps nobody. Pass a string where losing the copy actually costs something.
|
|
40
|
+
*/
|
|
41
|
+
errorToast?: string | false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UseClipboard {
|
|
45
|
+
/**
|
|
46
|
+
* True for a moment after a successful copy. Bind it to swap a copy icon for
|
|
47
|
+
* a tick:
|
|
48
|
+
*
|
|
49
|
+
* ```vue
|
|
50
|
+
* <LpIcon :name="copied ? 'lucide:check' : 'lucide:copy'" />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
copied: import("vue").Ref<boolean>
|
|
54
|
+
/**
|
|
55
|
+
* Writes `text` to the clipboard. Resolves true on success, false when the
|
|
56
|
+
* clipboard was unavailable or refused — it never rejects, so `await copy(x)`
|
|
57
|
+
* is safe to call bare from an event handler.
|
|
58
|
+
*/
|
|
59
|
+
copy: (text: string, options?: CopyOptions) => Promise<boolean>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clipboard writes with optional toast feedback.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* const { copy, copied } = useClipboard()
|
|
67
|
+
* copy(hex) // silent; bind `copied` to a tick
|
|
68
|
+
* copy(hex, { toast: "Copied #3b82f6" }) // says so
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* Defaults are per-call rather than per-instance: one component often copies
|
|
72
|
+
* several things for different reasons — a menu item that should announce
|
|
73
|
+
* itself, a button that shows its own tick — and hoisting the choice to the
|
|
74
|
+
* composable would force those to agree.
|
|
75
|
+
*/
|
|
76
|
+
export function useClipboard(): UseClipboard {
|
|
77
|
+
const copied = ref(false)
|
|
78
|
+
const { success, error } = useToast()
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
80
|
+
|
|
81
|
+
async function copy(text: string, options: CopyOptions = {}): Promise<boolean> {
|
|
82
|
+
const { toast = false, errorToast = false } = options
|
|
83
|
+
try {
|
|
84
|
+
// Optional-chained: `navigator.clipboard` is undefined, not throwing, on
|
|
85
|
+
// an insecure origin, and that should take the same path as a rejection.
|
|
86
|
+
await navigator.clipboard?.writeText(text)
|
|
87
|
+
// A missing API resolves `undefined` rather than rejecting, so confirm
|
|
88
|
+
// the write was actually possible before claiming it happened.
|
|
89
|
+
if (!navigator.clipboard) throw new Error("clipboard unavailable")
|
|
90
|
+
|
|
91
|
+
copied.value = true
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
timer = setTimeout(() => (copied.value = false), COPIED_MS)
|
|
94
|
+
if (toast) success(toast)
|
|
95
|
+
return true
|
|
96
|
+
} catch {
|
|
97
|
+
if (errorToast) error(errorToast)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The reset outlives a component that unmounts inside the window — a menu
|
|
103
|
+
// that copies and closes is the ordinary case, not the exotic one.
|
|
104
|
+
onScopeDispose(() => clearTimeout(timer))
|
|
105
|
+
|
|
106
|
+
return { copied, copy }
|
|
107
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type {
|
|
|
21
21
|
NominatimOptions,
|
|
22
22
|
TileSource,
|
|
23
23
|
} from "./components/address"
|
|
24
|
+
export { default as LpActionBar } from "./components/LpActionBar.vue"
|
|
25
|
+
export type { ActionBarAction } from "./components/LpActionBar.vue"
|
|
24
26
|
export { default as LpAlert } from "./components/LpAlert.vue"
|
|
25
27
|
export { default as LpAppShell } from "./components/LpAppShell.vue"
|
|
26
28
|
export { default as LpAutocomplete } from "./components/LpAutocomplete.vue"
|
|
@@ -44,6 +46,23 @@ export type { ChartAxis, ChartPoint, ChartSeries } from "./components/chart"
|
|
|
44
46
|
export { default as LpCheckbox } from "./components/LpCheckbox.vue"
|
|
45
47
|
export { default as LpCodeBlock } from "./components/LpCodeBlock.vue"
|
|
46
48
|
export type { CodeLang } from "./components/codeHighlight"
|
|
49
|
+
export { default as LpColorPicker } from "./components/LpColorPicker.vue"
|
|
50
|
+
export type { ColorPickerLabels } from "./components/LpColorPicker.vue"
|
|
51
|
+
export {
|
|
52
|
+
clampHsv,
|
|
53
|
+
DEFAULT_SWATCHES,
|
|
54
|
+
formatColor,
|
|
55
|
+
hsvToHsl,
|
|
56
|
+
hsvToRgb,
|
|
57
|
+
isLight,
|
|
58
|
+
normalizeHue,
|
|
59
|
+
opaqueCss,
|
|
60
|
+
parseColor,
|
|
61
|
+
parseHex,
|
|
62
|
+
rgbToHex,
|
|
63
|
+
rgbToHsv,
|
|
64
|
+
} from "./components/color"
|
|
65
|
+
export type { ColorFormat, Hsv, Rgb } from "./components/color"
|
|
47
66
|
export { default as LpCommandPalette } from "./components/LpCommandPalette.vue"
|
|
48
67
|
export type { Command } from "./components/LpCommandPalette.vue"
|
|
49
68
|
export { default as LpConfirmDialog } from "./components/LpConfirmDialog.vue"
|
|
@@ -204,6 +223,8 @@ export type { PresetName } from "./theme/presets"
|
|
|
204
223
|
export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from "./theme/config"
|
|
205
224
|
export type { UiConfig } from "./theme/config"
|
|
206
225
|
|
|
226
|
+
export { useClipboard } from "./composables/useClipboard"
|
|
227
|
+
export type { CopyOptions, UseClipboard } from "./composables/useClipboard"
|
|
207
228
|
export { useHotkeys, isAppleKeyboard, appleModifierLabel } from "./composables/useHotkeys"
|
|
208
229
|
export type { HotkeyBinding } from "./composables/useHotkeys"
|
|
209
230
|
export { useInputFilter } from "./composables/useInputFilter"
|