@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.
Files changed (39) hide show
  1. package/dist/chunks/LpActionBar-BlvtGB2P.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 +276 -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,84 @@
1
+ export interface ActionBarAction {
2
+ label: string;
3
+ /** Leading icon (any name LpIcon takes). */
4
+ icon?: string;
5
+ /** Mirrors LpButton's variants; the default reads as the primary action. */
6
+ variant?: "solid" | "action" | "outline" | "soft" | "ghost" | "muted" | "light" | "danger";
7
+ disabled?: boolean;
8
+ /** Shows a spinner in place of the icon and blocks repeat clicks. */
9
+ loading?: boolean;
10
+ onClick?: () => void | Promise<void>;
11
+ }
12
+ type __VLS_Props = {
13
+ /** Shown or not (v-model:open, or a plain :open). */
14
+ open?: boolean;
15
+ /** Lead line — "You have unsaved changes". */
16
+ title?: string;
17
+ /** Second line, quieter. Long explanations belong here, not in the title. */
18
+ description?: string;
19
+ /** Leading icon. Omit for a bar that is purely text + buttons. */
20
+ icon?: string;
21
+ /**
22
+ * Buttons on the right, in reading order. The LAST one is the primary and
23
+ * gets the solid treatment unless it says otherwise — the confirming action
24
+ * sits closest to the edge the thumb and the eye finish on.
25
+ */
26
+ actions?: ActionBarAction[];
27
+ /** Adds a "✕" that emits `dismiss`. For notices, not for decisions. */
28
+ dismissible?: boolean;
29
+ /**
30
+ * Tone. `danger` for a destructive pending action; the default stays neutral
31
+ * because most of these bars are routine and a page edged in red for every
32
+ * unsaved field trains people to ignore red.
33
+ */
34
+ variant?: "default" | "danger";
35
+ /**
36
+ * How wide the bar runs.
37
+ *
38
+ * "contained" (the default) keeps it to the width of its own content, up to
39
+ * a cap, and centres it: a bar carrying six words and two buttons has no
40
+ * business spanning a 1920px display, and stretching it puts the buttons a
41
+ * screen away from the field you were editing.
42
+ *
43
+ * "full" spans the viewport edge to edge — for a cookie notice, which reads
44
+ * as part of the page frame rather than as a message about one thing on it.
45
+ * Its CONTENTS are still capped and centred, so the buttons stay beside the
46
+ * text instead of being flung to the far corner.
47
+ */
48
+ width?: "contained" | "full";
49
+ /**
50
+ * Cap for the bar (or, when `width="full"`, for the row inside it). Any CSS
51
+ * length. Defaults to a comfortable measure for a line of text and two or
52
+ * three buttons — wider for `full`, whose job is to span the page and whose
53
+ * contents therefore sit on the page's own measure, not a card's.
54
+ */
55
+ maxWidth?: string;
56
+ /** Accessible name for the landmark. Falls back to `title`. */
57
+ ariaLabel?: string;
58
+ };
59
+ declare var __VLS_18: {}, __VLS_20: {};
60
+ type __VLS_Slots = {} & {
61
+ default?: (props: typeof __VLS_18) => any;
62
+ } & {
63
+ actions?: (props: typeof __VLS_20) => any;
64
+ };
65
+ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
66
+ "update:open": (value: boolean) => any;
67
+ dismiss: () => any;
68
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
69
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
70
+ onDismiss?: (() => any) | undefined;
71
+ }>, {
72
+ width: "contained" | "full";
73
+ open: boolean;
74
+ variant: "default" | "danger";
75
+ actions: ActionBarAction[];
76
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
77
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
78
+ declare const _default: typeof __VLS_export;
79
+ export default _default;
80
+ type __VLS_WithSlots<T, S> = T & {
81
+ new (): {
82
+ $slots: S;
83
+ };
84
+ };
@@ -0,0 +1,4 @@
1
+ import { _ as _sfc_main } from "../chunks/LpActionBar-BlvtGB2P.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-BJk3b-u4.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-DnocEJAu.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpCodeBlock-rS_6mO3D.js";
1
+ import { _ as _sfc_main } from "../chunks/LpCodeBlock-BWX1KZwk.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -0,0 +1,124 @@
1
+ import { ColorFormat } from './color';
2
+ import { ContextMenuItemDef } from './LpContextMenu.vue';
3
+ /**
4
+ * Every user-facing string the picker renders on its own — the menu items, the
5
+ * "Recent" heading, and the accessible names the area, rails and text field
6
+ * carry. Apps with i18n pass their translations in; the English defaults keep
7
+ * the component usable bare. Without this an app in another language would
8
+ * still announce "Saturation and brightness" to a screen reader.
9
+ */
10
+ export interface ColorPickerLabels {
11
+ saturation?: string;
12
+ hue?: string;
13
+ opacity?: string;
14
+ value?: string;
15
+ recent?: string;
16
+ copyHex?: string;
17
+ copyRgb?: string;
18
+ copyHsl?: string;
19
+ paste?: string;
20
+ /** Submenu title for the "show the value as…" format switch. */
21
+ formatAs?: string;
22
+ format_hex?: string;
23
+ format_rgb?: string;
24
+ format_hsl?: string;
25
+ /** Read out as the area's value, with {s} and {v} as whole percentages. */
26
+ saturationValueText?: string;
27
+ }
28
+ type __VLS_Props = {
29
+ /** The colour as a CSS string (v-model). Hex, rgb()/rgba() or hsl()/hsla(). */
30
+ modelValue?: string;
31
+ /** Shape of the emitted string. Parsing accepts all three either way. */
32
+ format?: ColorFormat;
33
+ /** Show the alpha rail and emit a colour carrying its alpha. */
34
+ alpha?: boolean;
35
+ /** Preset colours under the rails. Pass [] to drop the row entirely. */
36
+ swatches?: readonly string[];
37
+ /** Hide the hex/CSS text field (leaves the field, rails and presets). */
38
+ hideInput?: boolean;
39
+ disabled?: boolean;
40
+ invalid?: boolean;
41
+ size?: "sm" | "md" | "lg";
42
+ /** Trigger text beside the swatch. Defaults to the colour's own value. */
43
+ label?: string;
44
+ /** Accessible name for the trigger when it carries no visible text. */
45
+ ariaLabel?: string;
46
+ /** Popover side, as LpPopover. */
47
+ side?: "top" | "right" | "bottom" | "left";
48
+ align?: "start" | "center" | "end";
49
+ /**
50
+ * Right-click menu on the trigger. Copy-as-hex/rgb/hsl and paste are built
51
+ * in — the thing a person actually wants from a colour swatch is to take
52
+ * the value somewhere else, and reading it off the trigger by hand is how
53
+ * a hex ends up mistyped. These come first; `menuItems` is appended below a
54
+ * separator. Pass `menu="none"` to drop the built-ins and keep only yours.
55
+ */
56
+ menu?: "default" | "none";
57
+ /** Extra right-click items, appended after the built-in ones. */
58
+ menuItems?: ContextMenuItemDef[];
59
+ /**
60
+ * Remember the colours picked here and offer them back as a "Recent" row.
61
+ * A person who tints one tag reaches for the same colour on the next one,
62
+ * and the presets cannot know it. Off by default: history is a per-person
63
+ * convenience, and a component that wrote to storage uninvited would be a
64
+ * surprise in a form that is meant to be stateless.
65
+ */
66
+ recent?: boolean;
67
+ /**
68
+ * Storage key for that history, so two pickers can share a list or keep
69
+ * separate ones (e.g. "lp.color.tag" vs "lp.color.calendar").
70
+ */
71
+ recentKey?: string;
72
+ /** How many recent colours to keep. */
73
+ recentLimit?: number;
74
+ /** Translations for the strings the picker renders itself. */
75
+ labels?: ColorPickerLabels;
76
+ /**
77
+ * Toast raised after a successful copy, with `{value}` standing in for the
78
+ * colour. `false` copies silently — for a page that already confirms it, or
79
+ * one where a toast per copy would be noise. Translate it via `labels`
80
+ * rather than here if the rest of your strings go through i18n.
81
+ */
82
+ copyToast?: string | false;
83
+ };
84
+ declare var __VLS_56: {
85
+ color: string;
86
+ hsv: {
87
+ h: number;
88
+ s: number;
89
+ v: number;
90
+ a: number;
91
+ };
92
+ };
93
+ type __VLS_Slots = {} & {
94
+ default?: (props: typeof __VLS_56) => any;
95
+ };
96
+ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
97
+ open: import('vue').Ref<boolean, boolean>;
98
+ }, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
99
+ change: (value: string) => any;
100
+ "update:modelValue": (value: string) => any;
101
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
102
+ onChange?: ((value: string) => any) | undefined;
103
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
104
+ }>, {
105
+ size: "sm" | "md" | "lg";
106
+ format: ColorFormat;
107
+ align: "start" | "center" | "end";
108
+ modelValue: string;
109
+ side: "top" | "right" | "bottom" | "left";
110
+ alpha: boolean;
111
+ swatches: readonly string[];
112
+ recent: boolean;
113
+ recentKey: string;
114
+ recentLimit: number;
115
+ copyToast: string | false;
116
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
117
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
118
+ declare const _default: typeof __VLS_export;
119
+ export default _default;
120
+ type __VLS_WithSlots<T, S> = T & {
121
+ new (): {
122
+ $slots: S;
123
+ };
124
+ };
@@ -0,0 +1,4 @@
1
+ import { _ as _sfc_main } from "../chunks/LpColorPicker-CFC6iaNK.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDisclosure-BnwP4Q-b.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDisclosure-C1_YkMrd.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpLogViewer-CVaGfTF_.js";
1
+ import { _ as _sfc_main } from "../chunks/LpLogViewer-TSRyt3t9.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -65,18 +65,18 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_PublicProps, {},
65
65
  } & {
66
66
  clear: () => any;
67
67
  select: (item: NotificationItem) => any;
68
+ dismiss: (id: string) => any;
68
69
  markRead: (id: string) => any;
69
70
  markUnread: (id: string) => any;
70
71
  markAllRead: () => any;
71
- dismiss: (id: string) => any;
72
72
  }, string, import('vue').PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
73
73
  onClear?: (() => any) | undefined;
74
74
  onSelect?: ((item: NotificationItem) => any) | undefined;
75
75
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
76
+ onDismiss?: ((id: string) => any) | undefined;
76
77
  onMarkRead?: ((id: string) => any) | undefined;
77
78
  onMarkUnread?: ((id: string) => any) | undefined;
78
79
  onMarkAllRead?: (() => any) | undefined;
79
- onDismiss?: ((id: string) => any) | undefined;
80
80
  }>, {
81
81
  items: NotificationItem[];
82
82
  maxBadge: number;
@@ -35,6 +35,26 @@ type __VLS_Props = {
35
35
  * flush, header-less look.
36
36
  */
37
37
  divider?: boolean;
38
+ /**
39
+ * Where this nav lives.
40
+ *
41
+ * `rail` is the app shell's edge: one hairline on the inner side, square
42
+ * corners, the raised shell background.
43
+ *
44
+ * `panel` is a nav that sits inside a page next to the content it drives —
45
+ * a settings index, a table picker. It is a card: boxed on all four sides
46
+ * and rounded. Without it the rail's single border reads as a stray line
47
+ * down the middle of the page, and a consumer cannot cancel it with a class
48
+ * of their own.
49
+ */
50
+ variant?: "rail" | "panel";
51
+ /**
52
+ * Rail width. A plain `class` cannot set it — the component's own `w-60`
53
+ * wins, and the kit does not merge conflicting Tailwind classes — so the
54
+ * width is a prop. Any CSS length; a page-embedded nav usually wants to
55
+ * match the column it lives in.
56
+ */
57
+ width?: string;
38
58
  /**
39
59
  * Which collapsible groups are folded away (v-model:collapsed). Leave it
40
60
  * unbound to let the sidebar fold long navs on its own — see
@@ -90,6 +110,7 @@ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {},
90
110
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
91
111
  "onUpdate:collapsed"?: ((keys: string[]) => any) | undefined;
92
112
  }>, {
113
+ variant: "rail" | "panel";
93
114
  skeletonRows: number;
94
115
  skeletonHeader: boolean;
95
116
  responsive: boolean;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebar-C_Mr-JPI.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebar-CWSbczWi.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -0,0 +1,19 @@
1
+ export interface BarEntry {
2
+ id: number;
3
+ /** Measured height in px, reported by the bar once it is on screen. */
4
+ height: number;
5
+ }
6
+ /** Gap between stacked bars, matching the padding the bar sits in. */
7
+ export declare const STACK_GAP = 8;
8
+ /**
9
+ * Registers a bar for as long as the calling scope lives. `setHeight` reports
10
+ * the measured height; `offset()` returns how far up this bar should sit —
11
+ * the total height of every bar that opened before it.
12
+ */
13
+ export declare function useBarStack(): {
14
+ register: () => void;
15
+ unregister: () => void;
16
+ setHeight: (height: number) => void;
17
+ offset: () => number;
18
+ bars: import('vue').Ref<BarEntry[] | undefined, BarEntry[] | undefined>;
19
+ };
@@ -0,0 +1,31 @@
1
+ import { ref, onScopeDispose } from "vue";
2
+ const STATE_KEY = /* @__PURE__ */ Symbol.for("@leavepulse/ui:action-bar-stack");
3
+ const g = globalThis;
4
+ const state = g[STATE_KEY] ??= { bars: ref([]), nextId: 0 };
5
+ const STACK_GAP = 8;
6
+ function useBarStack() {
7
+ const id = state.nextId++;
8
+ const bars = state.bars;
9
+ function register() {
10
+ if (!bars.value.some((b) => b.id === id)) {
11
+ bars.value = [...bars.value, { id, height: 0 }];
12
+ }
13
+ }
14
+ function unregister() {
15
+ bars.value = bars.value.filter((b) => b.id !== id);
16
+ }
17
+ function setHeight(height) {
18
+ bars.value = bars.value.map((b) => b.id === id ? { ...b, height } : b);
19
+ }
20
+ function offset() {
21
+ const i = bars.value.findIndex((b) => b.id === id);
22
+ if (i <= 0) return 0;
23
+ return bars.value.slice(0, i).reduce((total, b) => total + b.height + STACK_GAP, 0);
24
+ }
25
+ onScopeDispose(unregister);
26
+ return { register, unregister, setHeight, offset, bars };
27
+ }
28
+ export {
29
+ STACK_GAP,
30
+ useBarStack
31
+ };
@@ -0,0 +1,65 @@
1
+ /** Hue 0..360, saturation/value 0..1, alpha 0..1. */
2
+ export interface Hsv {
3
+ h: number;
4
+ s: number;
5
+ v: number;
6
+ a: number;
7
+ }
8
+ /** Channels 0..255, alpha 0..1. */
9
+ export interface Rgb {
10
+ r: number;
11
+ g: number;
12
+ b: number;
13
+ a: number;
14
+ }
15
+ /** The string shapes a caller can hand the picker, and ask back out of it. */
16
+ export type ColorFormat = "hex" | "rgb" | "hsl";
17
+ /** Wraps rather than clamps: hue is a circle, and 370° is 10°, not 360°. */
18
+ export declare function normalizeHue(h: number): number;
19
+ export declare function clampHsv(c: Hsv): Hsv;
20
+ export declare function hsvToRgb({ h, s, v, a }: Hsv): Rgb;
21
+ export declare function rgbToHsv({ r, g, b, a }: Rgb): Hsv;
22
+ /** "#rrggbb", or "#rrggbbaa" when the colour is not fully opaque. */
23
+ export declare function rgbToHex({ r, g, b, a }: Rgb): string;
24
+ /**
25
+ * Parses #rgb / #rgba / #rrggbb / #rrggbbaa, with or without the leading `#`.
26
+ * Returns null for anything it doesn't recognise, so a half-typed value in the
27
+ * hex field can be left alone instead of snapping the picker to black.
28
+ */
29
+ export declare function parseHex(input: string): Rgb | null;
30
+ /**
31
+ * Parses a colour a person might paste: hex (3/4/6/8 digits, `#` optional),
32
+ * rgb()/rgba() and hsl()/hsla() in both the comma and the space/slash forms,
33
+ * percentages, hue in deg/turn/rad/grad, brackets optional, and a bare triple
34
+ * of numbers read as rgb. A round-trip through a caller's storage therefore
35
+ * comes back the colour it went in as, whatever shape it was stored in.
36
+ *
37
+ * Named colours ("rebeccapurple") and the newer spaces (lab, oklch, color())
38
+ * are out of scope: resolving those needs the browser's own parser, and a
39
+ * component that silently handled some and not others would be worse than one
40
+ * that is clear about what it takes.
41
+ */
42
+ export declare function parseColor(input: string): Rgb | null;
43
+ /** HSV→HSL, for the hsl() output format. Saturation/lightness come back 0..1. */
44
+ export declare function hsvToHsl({ h, s, v }: Hsv): {
45
+ h: number;
46
+ s: number;
47
+ l: number;
48
+ };
49
+ /** The colour as a CSS string in the requested format. */
50
+ export declare function formatColor(c: Hsv, format: ColorFormat): string;
51
+ /** Always opaque — for the swatch behind an alpha checkerboard, and for rails. */
52
+ export declare function opaqueCss(c: Hsv): string;
53
+ /**
54
+ * Whether dark ink reads better than light on this colour, by WCAG relative
55
+ * luminance. The picker uses it for the check mark on a selected swatch, which
56
+ * otherwise disappears into a pale preset.
57
+ */
58
+ export declare function isLight(c: Hsv): boolean;
59
+ /**
60
+ * A neutral starting palette. Kit tokens are deliberately NOT used here: these
61
+ * are values a user picks *for their own data* (a tag, a calendar category),
62
+ * which must stay put when the app's theme changes — a preset that shifted with
63
+ * the theme would silently restyle everything already saved with it.
64
+ */
65
+ export declare const DEFAULT_SWATCHES: readonly ["#ef4444", "#f97316", "#f59e0b", "#22c55e", "#10b981", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6", "#ec4899", "#64748b", "#111827"];
@@ -0,0 +1,158 @@
1
+ const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
2
+ function normalizeHue(h) {
3
+ const wrapped = h % 360;
4
+ return wrapped < 0 ? wrapped + 360 : wrapped;
5
+ }
6
+ function clampHsv(c) {
7
+ return {
8
+ h: normalizeHue(c.h),
9
+ s: clamp(c.s, 0, 1),
10
+ v: clamp(c.v, 0, 1),
11
+ a: clamp(c.a, 0, 1)
12
+ };
13
+ }
14
+ function hsvToRgb({ h, s, v, a }) {
15
+ const hue = normalizeHue(h) / 60;
16
+ const c = v * s;
17
+ const x = c * (1 - Math.abs(hue % 2 - 1));
18
+ const m = v - c;
19
+ const [r, g, b] = [
20
+ [c, x, 0],
21
+ [x, c, 0],
22
+ [0, c, x],
23
+ [0, x, c],
24
+ [x, 0, c],
25
+ [c, 0, x]
26
+ ][Math.floor(hue) % 6];
27
+ return {
28
+ r: Math.round((r + m) * 255),
29
+ g: Math.round((g + m) * 255),
30
+ b: Math.round((b + m) * 255),
31
+ a: clamp(a, 0, 1)
32
+ };
33
+ }
34
+ function rgbToHsv({ r, g, b, a }) {
35
+ const rn = r / 255;
36
+ const gn = g / 255;
37
+ const bn = b / 255;
38
+ const max = Math.max(rn, gn, bn);
39
+ const min = Math.min(rn, gn, bn);
40
+ const d = max - min;
41
+ let h = 0;
42
+ if (d !== 0) {
43
+ if (max === rn) h = 60 * ((gn - bn) / d % 6);
44
+ else if (max === gn) h = 60 * ((bn - rn) / d + 2);
45
+ else h = 60 * ((rn - gn) / d + 4);
46
+ }
47
+ return {
48
+ h: normalizeHue(h),
49
+ s: max === 0 ? 0 : d / max,
50
+ v: max,
51
+ a: clamp(a, 0, 1)
52
+ };
53
+ }
54
+ const hex2 = (n) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
55
+ function rgbToHex({ r, g, b, a }) {
56
+ const base = `#${hex2(r)}${hex2(g)}${hex2(b)}`;
57
+ return a >= 1 ? base : base + hex2(a * 255);
58
+ }
59
+ function parseHex(input) {
60
+ const s = input.trim().replace(/^#/, "");
61
+ if (!/^[0-9a-f]+$/i.test(s)) return null;
62
+ const expand = (hex) => hex.length <= 4 ? hex.split("").map((ch) => ch + ch) : hex.match(/.{2}/g) ?? [];
63
+ const parts = expand(s);
64
+ if (s.length !== 3 && s.length !== 4 && s.length !== 6 && s.length !== 8) return null;
65
+ const [r, g, b, alpha] = parts.map((p) => parseInt(p, 16));
66
+ return { r, g, b, a: alpha === void 0 ? 1 : alpha / 255 };
67
+ }
68
+ function parseColor(input) {
69
+ const s = input.trim();
70
+ if (!s) return null;
71
+ if (s.startsWith("#") || /^[0-9a-f]{3,8}$/i.test(s)) return parseHex(s);
72
+ const fn = s.match(/^(rgba?|hsla?)\s*\(?([^)]*)\)?$/i) ?? (/^[\d.,\s/%]+$/.test(s) ? [null, "rgb", s] : null);
73
+ if (!fn) return null;
74
+ const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
75
+ if (parts.length < 3) return null;
76
+ const num = (t) => parseFloat(t);
77
+ const angle = (t) => {
78
+ const n = parseFloat(t);
79
+ if (/turn$/i.test(t)) return n * 360;
80
+ if (/rad$/i.test(t)) return n * 180 / Math.PI;
81
+ if (/grad$/i.test(t)) return n * 0.9;
82
+ return n;
83
+ };
84
+ const alpha = parts[3] === void 0 ? 1 : parts[3].endsWith("%") ? num(parts[3]) / 100 : num(parts[3]);
85
+ if (Number.isNaN(alpha)) return null;
86
+ if (fn[1].toLowerCase().startsWith("rgb")) {
87
+ const [r, g, b] = parts.slice(0, 3).map((t) => t.endsWith("%") ? num(t) / 100 * 255 : num(t));
88
+ if ([r, g, b].some(Number.isNaN)) return null;
89
+ 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) };
90
+ }
91
+ const h = angle(parts[0]);
92
+ const sl = num(parts[1]) / 100;
93
+ const l = num(parts[2]) / 100;
94
+ if ([h, sl, l].some(Number.isNaN)) return null;
95
+ return hslToRgb(h, clamp(sl, 0, 1), clamp(l, 0, 1), clamp(alpha, 0, 1));
96
+ }
97
+ function hslToRgb(h, s, l, a) {
98
+ const v = l + s * Math.min(l, 1 - l);
99
+ return hsvToRgb({ h, s: v === 0 ? 0 : 2 * (1 - l / v), v, a });
100
+ }
101
+ function hsvToHsl({ h, s, v }) {
102
+ const l = v * (1 - s / 2);
103
+ const denom = Math.min(l, 1 - l);
104
+ return { h: normalizeHue(h), s: denom === 0 ? 0 : (v - l) / denom, l };
105
+ }
106
+ function formatColor(c, format) {
107
+ const rgb = hsvToRgb(c);
108
+ const a = Math.round(clamp(c.a, 0, 1) * 100) / 100;
109
+ if (format === "hex") return rgbToHex(rgb);
110
+ if (format === "rgb") {
111
+ return a >= 1 ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;
112
+ }
113
+ const { h, s, l } = hsvToHsl(c);
114
+ const hh = Math.round(h);
115
+ const ss = Math.round(s * 100);
116
+ const ll = Math.round(l * 100);
117
+ return a >= 1 ? `hsl(${hh}, ${ss}%, ${ll}%)` : `hsla(${hh}, ${ss}%, ${ll}%, ${a})`;
118
+ }
119
+ function opaqueCss(c) {
120
+ const { r, g, b } = hsvToRgb({ ...c, a: 1 });
121
+ return `rgb(${r}, ${g}, ${b})`;
122
+ }
123
+ function isLight(c) {
124
+ const { r, g, b } = hsvToRgb(c);
125
+ const lin = (n) => {
126
+ const x = n / 255;
127
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
128
+ };
129
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) > 0.35;
130
+ }
131
+ const DEFAULT_SWATCHES = [
132
+ "#ef4444",
133
+ "#f97316",
134
+ "#f59e0b",
135
+ "#22c55e",
136
+ "#10b981",
137
+ "#06b6d4",
138
+ "#3b82f6",
139
+ "#6366f1",
140
+ "#8b5cf6",
141
+ "#ec4899",
142
+ "#64748b",
143
+ "#111827"
144
+ ];
145
+ export {
146
+ DEFAULT_SWATCHES,
147
+ clampHsv,
148
+ formatColor,
149
+ hsvToHsl,
150
+ hsvToRgb,
151
+ isLight,
152
+ normalizeHue,
153
+ opaqueCss,
154
+ parseColor,
155
+ parseHex,
156
+ rgbToHex,
157
+ rgbToHsv
158
+ };
@@ -0,0 +1,46 @@
1
+ export interface CopyOptions {
2
+ /**
3
+ * Toast to raise on success. `false` (the default) stays silent, for callers
4
+ * whose button already shows the tick and would otherwise say it twice.
5
+ */
6
+ toast?: string | false;
7
+ /**
8
+ * Toast to raise when the copy fails. Defaults to silence for the same
9
+ * reason the kit's other best-effort paths are silent: a copy that didn't
10
+ * happen is usually obvious, and a scary red box over a blocked clipboard
11
+ * helps nobody. Pass a string where losing the copy actually costs something.
12
+ */
13
+ errorToast?: string | false;
14
+ }
15
+ export interface UseClipboard {
16
+ /**
17
+ * True for a moment after a successful copy. Bind it to swap a copy icon for
18
+ * a tick:
19
+ *
20
+ * ```vue
21
+ * <LpIcon :name="copied ? 'lucide:check' : 'lucide:copy'" />
22
+ * ```
23
+ */
24
+ copied: import('vue').Ref<boolean>;
25
+ /**
26
+ * Writes `text` to the clipboard. Resolves true on success, false when the
27
+ * clipboard was unavailable or refused — it never rejects, so `await copy(x)`
28
+ * is safe to call bare from an event handler.
29
+ */
30
+ copy: (text: string, options?: CopyOptions) => Promise<boolean>;
31
+ }
32
+ /**
33
+ * Clipboard writes with optional toast feedback.
34
+ *
35
+ * ```ts
36
+ * const { copy, copied } = useClipboard()
37
+ * copy(hex) // silent; bind `copied` to a tick
38
+ * copy(hex, { toast: "Copied #3b82f6" }) // says so
39
+ * ```
40
+ *
41
+ * Defaults are per-call rather than per-instance: one component often copies
42
+ * several things for different reasons — a menu item that should announce
43
+ * itself, a button that shows its own tick — and hoisting the choice to the
44
+ * composable would force those to agree.
45
+ */
46
+ export declare function useClipboard(): UseClipboard;
@@ -0,0 +1,28 @@
1
+ import { ref, onScopeDispose } from "vue";
2
+ import { useToast } from "./useToast.js";
3
+ const COPIED_MS = 1400;
4
+ function useClipboard() {
5
+ const copied = ref(false);
6
+ const { success, error } = useToast();
7
+ let timer;
8
+ async function copy(text, options = {}) {
9
+ const { toast = false, errorToast = false } = options;
10
+ try {
11
+ await navigator.clipboard?.writeText(text);
12
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
13
+ copied.value = true;
14
+ clearTimeout(timer);
15
+ timer = setTimeout(() => copied.value = false, COPIED_MS);
16
+ if (toast) success(toast);
17
+ return true;
18
+ } catch {
19
+ if (errorToast) error(errorToast);
20
+ return false;
21
+ }
22
+ }
23
+ onScopeDispose(() => clearTimeout(timer));
24
+ return { copied, copy };
25
+ }
26
+ export {
27
+ useClipboard
28
+ };