@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,276 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A bar that rises from the bottom of the window to ask for one decision, then
4
+ * leaves. Unsaved changes with Save/Discard, a cookie notice, "3 rows selected"
5
+ * with a bulk action, an update waiting to be applied.
6
+ *
7
+ * It is deliberately NOT a dialog and NOT a toast, because it is neither:
8
+ *
9
+ * A dialog takes the page hostage. The whole point of an unsaved-changes bar
10
+ * is that you can keep editing while it sits there — trapping focus and
11
+ * dimming the form behind it would be answering a question the user has not
12
+ * been asked yet.
13
+ *
14
+ * A toast leaves on a timer. A decision that vanishes after four seconds is a
15
+ * decision the user can miss, and "your changes were lost because you looked
16
+ * away" is the outcome. This stays until something resolves it.
17
+ *
18
+ * So: fixed to the bottom, above the page but below modals, never focus-
19
+ * trapped, and it does not close itself. `role="region"` with an accessible
20
+ * name rather than `alertdialog` — it is a landmark you can Tab into, not a
21
+ * thing demanding an answer before anything else can happen.
22
+ */
23
+ import { computed, nextTick, ref, useSlots, watch } from "vue"
24
+ import { useBarStack } from "./actionBar"
25
+ import { CLOSE_ICON } from "./dropdown"
26
+ import LpButton from "./LpButton.vue"
27
+ import LpIcon from "./LpIcon.vue"
28
+
29
+ export interface ActionBarAction {
30
+ label: string
31
+ /** Leading icon (any name LpIcon takes). */
32
+ icon?: string
33
+ /** Mirrors LpButton's variants; the default reads as the primary action. */
34
+ variant?: "solid" | "action" | "outline" | "soft" | "ghost" | "muted" | "light" | "danger"
35
+ disabled?: boolean
36
+ /** Shows a spinner in place of the icon and blocks repeat clicks. */
37
+ loading?: boolean
38
+ onClick?: () => void | Promise<void>
39
+ }
40
+
41
+ const props = withDefaults(
42
+ defineProps<{
43
+ /** Shown or not (v-model:open, or a plain :open). */
44
+ open?: boolean
45
+ /** Lead line — "You have unsaved changes". */
46
+ title?: string
47
+ /** Second line, quieter. Long explanations belong here, not in the title. */
48
+ description?: string
49
+ /** Leading icon. Omit for a bar that is purely text + buttons. */
50
+ icon?: string
51
+ /**
52
+ * Buttons on the right, in reading order. The LAST one is the primary and
53
+ * gets the solid treatment unless it says otherwise — the confirming action
54
+ * sits closest to the edge the thumb and the eye finish on.
55
+ */
56
+ actions?: ActionBarAction[]
57
+ /** Adds a "✕" that emits `dismiss`. For notices, not for decisions. */
58
+ dismissible?: boolean
59
+ /**
60
+ * Tone. `danger` for a destructive pending action; the default stays neutral
61
+ * because most of these bars are routine and a page edged in red for every
62
+ * unsaved field trains people to ignore red.
63
+ */
64
+ variant?: "default" | "danger"
65
+ /**
66
+ * How wide the bar runs.
67
+ *
68
+ * "contained" (the default) keeps it to the width of its own content, up to
69
+ * a cap, and centres it: a bar carrying six words and two buttons has no
70
+ * business spanning a 1920px display, and stretching it puts the buttons a
71
+ * screen away from the field you were editing.
72
+ *
73
+ * "full" spans the viewport edge to edge — for a cookie notice, which reads
74
+ * as part of the page frame rather than as a message about one thing on it.
75
+ * Its CONTENTS are still capped and centred, so the buttons stay beside the
76
+ * text instead of being flung to the far corner.
77
+ */
78
+ width?: "contained" | "full"
79
+ /**
80
+ * Cap for the bar (or, when `width="full"`, for the row inside it). Any CSS
81
+ * length. Defaults to a comfortable measure for a line of text and two or
82
+ * three buttons — wider for `full`, whose job is to span the page and whose
83
+ * contents therefore sit on the page's own measure, not a card's.
84
+ */
85
+ maxWidth?: string
86
+ /** Accessible name for the landmark. Falls back to `title`. */
87
+ ariaLabel?: string
88
+ }>(),
89
+ {
90
+ open: true,
91
+ actions: () => [],
92
+ variant: "default",
93
+ width: "contained",
94
+ },
95
+ )
96
+
97
+ const emit = defineEmits<{
98
+ (e: "update:open", value: boolean): void
99
+ (e: "dismiss"): void
100
+ }>()
101
+
102
+ const slots = useSlots()
103
+
104
+ // The last action is the primary one unless the caller has said otherwise; the
105
+ // rest default to `ghost` so a bar with three buttons still has one obvious
106
+ // answer instead of three competing ones.
107
+ const resolvedActions = computed(() =>
108
+ props.actions.map((a, i) => ({
109
+ ...a,
110
+ variant:
111
+ a.variant ??
112
+ (i === props.actions.length - 1
113
+ ? props.variant === "danger"
114
+ ? ("danger" as const)
115
+ : ("solid" as const)
116
+ : ("ghost" as const)),
117
+ })),
118
+ )
119
+
120
+ const hasBody = computed(() => !!props.title || !!props.description || !!slots.default)
121
+
122
+ /**
123
+ * 36rem for a contained bar: a line of text and two or three buttons, at about
124
+ * the measure prose stays comfortable at — wider starts reading as a page
125
+ * element, which a contained bar is not. A full-width bar IS page furniture, so
126
+ * its row sits on the shell's own measure (LpAppShell centres content at
127
+ * max-w-6xl) rather than being squeezed into a card's width, which cropped the
128
+ * description of a cookie notice halfway through a word.
129
+ */
130
+ const maxWidth = computed(() => props.maxWidth ?? (props.width === "full" ? "72rem" : "36rem"))
131
+
132
+ function run(action: ActionBarAction) {
133
+ if (action.disabled || action.loading) return
134
+ void action.onClick?.()
135
+ }
136
+
137
+ function dismiss() {
138
+ emit("dismiss")
139
+ emit("update:open", false)
140
+ }
141
+
142
+ /* ── stacking ─────────────────────────────────────
143
+ Two bars at once is ordinary — a cookie notice over a form that has just been
144
+ edited — and both are translucent, so overlapping them makes each unreadable
145
+ through the other. Each open bar takes a slot and rides above the ones that
146
+ opened before it. */
147
+ const root = ref<HTMLElement | null>(null)
148
+ const { register, unregister, setHeight, offset, bars } = useBarStack()
149
+
150
+ const stackOffset = computed(() => {
151
+ // `bars` is read so the offset recomputes when a bar below is added, resized
152
+ // or removed; the value itself comes from the registry.
153
+ void bars.value
154
+ return offset()
155
+ })
156
+
157
+ watch(
158
+ () => props.open,
159
+ async (isOpen) => {
160
+ if (!isOpen) {
161
+ unregister()
162
+ return
163
+ }
164
+ register()
165
+ await nextTick()
166
+ if (root.value) setHeight(root.value.getBoundingClientRect().height)
167
+ },
168
+ { immediate: true },
169
+ )
170
+ </script>
171
+
172
+ <template>
173
+ <!-- Teleported so the bar is pinned to the VIEWPORT rather than to whatever
174
+ container it was declared in: a `position: fixed` element inside an
175
+ ancestor that has a transform, filter or backdrop-filter anchors to that
176
+ ancestor instead, and the kit is full of such ancestors (any panel with a
177
+ blur). Declaring it next to the form it belongs to should not decide
178
+ where on screen it lands. -->
179
+ <Teleport to="body">
180
+ <!-- `forwards` on the way out, because the leave animation finishing is not
181
+ the same moment as the element leaving. `rise-out` is a bare `to {}`, so
182
+ once it ends the element snaps back to opacity 1 at its resting
183
+ position, and Vue then takes a frame to unmount it — a bar that had
184
+ faded to nothing flashed back at full strength and vanished. Holding the
185
+ final frame means the last thing painted is the last frame of the fade.
186
+ Reduced motion skips it entirely rather than holding a frame it never
187
+ animated to. -->
188
+ <Transition
189
+ enter-active-class="animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)]"
190
+ leave-active-class="animate-[rise-out_var(--duration-fast)_ease_forwards] motion-reduce:animate-none"
191
+ >
192
+ <div
193
+ v-if="open"
194
+ ref="root"
195
+ role="region"
196
+ :aria-label="ariaLabel ?? title"
197
+ class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-modal) flex justify-center transition-[translate] duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
198
+ :class="width === 'full' ? '' : 'p-3 sm:p-4'"
199
+ :style="{ translate: `0 -${stackOffset}px` }"
200
+ >
201
+ <div
202
+ class="lp-skin-panel pointer-events-auto flex items-center gap-3 border-solid bg-surface-overlay"
203
+ :class="[
204
+ // A full-width bar is the page's own furniture: square, edge to
205
+ // edge, and only its top border drawn, so it reads as a frame
206
+ // rather than as a card that happens to be very wide.
207
+ width === 'full'
208
+ ? 'w-full justify-center border-x-0 border-b-0 px-4 py-3 sm:px-6'
209
+ : 'w-full rounded-card px-4 py-3',
210
+ variant === 'danger' ? 'border-danger/40' : 'border-line',
211
+ ]"
212
+ :style="width === 'full' ? undefined : { maxWidth }"
213
+ >
214
+ <!-- `full` keeps its contents on the same measure as a contained bar,
215
+ so the buttons stay next to the words instead of being pushed to
216
+ the far edge of a wide display. -->
217
+ <div
218
+ class="flex w-full items-center gap-3"
219
+ :style="{ maxWidth }"
220
+ >
221
+ <LpIcon
222
+ v-if="icon"
223
+ :name="icon"
224
+ :size="18"
225
+ :class="['shrink-0', variant === 'danger' ? 'text-danger' : 'text-brand']"
226
+ />
227
+
228
+ <!-- min-w-0 so a long description truncates instead of pushing the
229
+ buttons off the end of the bar. -->
230
+ <div v-if="hasBody" class="min-w-0 flex-1">
231
+ <slot>
232
+ <p v-if="title" class="truncate text-sm font-medium text-ink">{{ title }}</p>
233
+ <p v-if="description" class="truncate text-xs text-muted">{{ description }}</p>
234
+ </slot>
235
+ </div>
236
+ <div v-else class="flex-1" />
237
+
238
+ <!-- Buttons never shrink: the bar gives up text before it gives up
239
+ the thing you came here to click. -->
240
+ <div class="flex shrink-0 items-center gap-2">
241
+ <slot name="actions">
242
+ <LpButton
243
+ v-for="(action, i) in resolvedActions"
244
+ :key="i"
245
+ :variant="action.variant"
246
+ size="sm"
247
+ :disabled="action.disabled || action.loading"
248
+ @click="run(action)"
249
+ >
250
+ <LpIcon
251
+ v-if="action.loading"
252
+ name="lucide:loader-circle"
253
+ :size="15"
254
+ class="animate-spin"
255
+ />
256
+ <LpIcon v-else-if="action.icon" :name="action.icon" :size="15" />
257
+ {{ action.label }}
258
+ </LpButton>
259
+ </slot>
260
+
261
+ <button
262
+ v-if="dismissible"
263
+ type="button"
264
+ aria-label="Dismiss"
265
+ class="group -mr-1 flex size-8 shrink-0 items-center justify-center rounded-control text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
266
+ @click="dismiss"
267
+ >
268
+ <LpIcon name="lucide:x" :size="16" :class="CLOSE_ICON" />
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </Transition>
275
+ </Teleport>
276
+ </template>
@@ -16,6 +16,7 @@
16
16
  * set `region:open` (e.g. `// #region label open`) to start expanded.
17
17
  */
18
18
  import { computed, reactive, ref, watch } from "vue"
19
+ import { useClipboard } from "../composables/useClipboard"
19
20
  import { type CodeLang, tokenizeLine } from "./codeHighlight"
20
21
  import LpIcon from "./LpIcon.vue"
21
22
 
@@ -168,16 +169,10 @@ const showHeader = computed(
168
169
  )
169
170
 
170
171
  // ── copy ─────────────────────────────────────────────────────
171
- const copied = ref(false)
172
- async function copy() {
173
- try {
174
- await navigator.clipboard.writeText(copyText.value)
175
- copied.value = true
176
- window.setTimeout(() => (copied.value = false), 1400)
177
- } catch {
178
- /* clipboard blocked — no-op */
179
- }
180
- }
172
+ // The tick on the button is the whole confirmation here, so no toast: the
173
+ // header is right next to the pointer that just clicked it.
174
+ const { copied, copy: writeClipboard } = useClipboard()
175
+ const copy = () => writeClipboard(copyText.value)
181
176
 
182
177
  function toggleLock() {
183
178
  emit("update:locked", !props.locked)