@leavepulse/ui 0.29.5 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/LpActionBar-CxmBZpGu.js +173 -0
- package/dist/chunks/{LpAppShell-BJk3b-u4.js → LpAppShell-DnocEJAu.js} +1 -1
- package/dist/chunks/{LpCodeBlock-rS_6mO3D.js → LpCodeBlock-BWX1KZwk.js} +7 -13
- package/dist/chunks/LpColorPicker-CFC6iaNK.js +491 -0
- package/dist/chunks/{LpDisclosure-BnwP4Q-b.js → LpDisclosure-C1_YkMrd.js} +8 -2
- package/dist/chunks/{LpLogViewer-CVaGfTF_.js → LpLogViewer-TSRyt3t9.js} +3 -6
- package/dist/chunks/{LpSidebar-C_Mr-JPI.js → LpSidebar-CWSbczWi.js} +11 -2
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpActionBar.vue.d.ts +84 -0
- package/dist/components/LpActionBar.vue.js +4 -0
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +124 -0
- package/dist/components/LpColorPicker.vue.js +4 -0
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +2 -2
- package/dist/components/LpSidebar.vue.d.ts +21 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/actionBar.d.ts +19 -0
- package/dist/components/actionBar.js +31 -0
- package/dist/components/color.d.ts +65 -0
- package/dist/components/color.js +158 -0
- package/dist/composables/useClipboard.d.ts +46 -0
- package/dist/composables/useClipboard.js +28 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +145 -126
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpActionBar.vue +268 -0
- package/src/components/LpCodeBlock.vue +5 -10
- package/src/components/LpColorPicker.vue +784 -0
- package/src/components/LpDisclosure.vue +17 -2
- package/src/components/LpLogViewer.vue +5 -8
- package/src/components/LpSidebar.vue +42 -6
- package/src/components/actionBar.ts +73 -0
- package/src/components/color.ts +257 -0
- package/src/composables/useClipboard.ts +107 -0
- package/src/index.ts +21 -0
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
<Transition
|
|
181
|
+
enter-active-class="animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)]"
|
|
182
|
+
leave-active-class="animate-[rise-out_var(--duration-fast)_ease]"
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
v-if="open"
|
|
186
|
+
ref="root"
|
|
187
|
+
role="region"
|
|
188
|
+
:aria-label="ariaLabel ?? title"
|
|
189
|
+
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"
|
|
190
|
+
:class="width === 'full' ? '' : 'p-3 sm:p-4'"
|
|
191
|
+
:style="{ translate: `0 -${stackOffset}px` }"
|
|
192
|
+
>
|
|
193
|
+
<div
|
|
194
|
+
class="lp-skin-panel pointer-events-auto flex items-center gap-3 border-solid bg-surface-overlay"
|
|
195
|
+
:class="[
|
|
196
|
+
// A full-width bar is the page's own furniture: square, edge to
|
|
197
|
+
// edge, and only its top border drawn, so it reads as a frame
|
|
198
|
+
// rather than as a card that happens to be very wide.
|
|
199
|
+
width === 'full'
|
|
200
|
+
? 'w-full justify-center border-x-0 border-b-0 px-4 py-3 sm:px-6'
|
|
201
|
+
: 'w-full rounded-card px-4 py-3',
|
|
202
|
+
variant === 'danger' ? 'border-danger/40' : 'border-line',
|
|
203
|
+
]"
|
|
204
|
+
:style="width === 'full' ? undefined : { maxWidth }"
|
|
205
|
+
>
|
|
206
|
+
<!-- `full` keeps its contents on the same measure as a contained bar,
|
|
207
|
+
so the buttons stay next to the words instead of being pushed to
|
|
208
|
+
the far edge of a wide display. -->
|
|
209
|
+
<div
|
|
210
|
+
class="flex w-full items-center gap-3"
|
|
211
|
+
:style="{ maxWidth }"
|
|
212
|
+
>
|
|
213
|
+
<LpIcon
|
|
214
|
+
v-if="icon"
|
|
215
|
+
:name="icon"
|
|
216
|
+
:size="18"
|
|
217
|
+
:class="['shrink-0', variant === 'danger' ? 'text-danger' : 'text-brand']"
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
<!-- min-w-0 so a long description truncates instead of pushing the
|
|
221
|
+
buttons off the end of the bar. -->
|
|
222
|
+
<div v-if="hasBody" class="min-w-0 flex-1">
|
|
223
|
+
<slot>
|
|
224
|
+
<p v-if="title" class="truncate text-sm font-medium text-ink">{{ title }}</p>
|
|
225
|
+
<p v-if="description" class="truncate text-xs text-muted">{{ description }}</p>
|
|
226
|
+
</slot>
|
|
227
|
+
</div>
|
|
228
|
+
<div v-else class="flex-1" />
|
|
229
|
+
|
|
230
|
+
<!-- Buttons never shrink: the bar gives up text before it gives up
|
|
231
|
+
the thing you came here to click. -->
|
|
232
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
233
|
+
<slot name="actions">
|
|
234
|
+
<LpButton
|
|
235
|
+
v-for="(action, i) in resolvedActions"
|
|
236
|
+
:key="i"
|
|
237
|
+
:variant="action.variant"
|
|
238
|
+
size="sm"
|
|
239
|
+
:disabled="action.disabled || action.loading"
|
|
240
|
+
@click="run(action)"
|
|
241
|
+
>
|
|
242
|
+
<LpIcon
|
|
243
|
+
v-if="action.loading"
|
|
244
|
+
name="lucide:loader-circle"
|
|
245
|
+
:size="15"
|
|
246
|
+
class="animate-spin"
|
|
247
|
+
/>
|
|
248
|
+
<LpIcon v-else-if="action.icon" :name="action.icon" :size="15" />
|
|
249
|
+
{{ action.label }}
|
|
250
|
+
</LpButton>
|
|
251
|
+
</slot>
|
|
252
|
+
|
|
253
|
+
<button
|
|
254
|
+
v-if="dismissible"
|
|
255
|
+
type="button"
|
|
256
|
+
aria-label="Dismiss"
|
|
257
|
+
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"
|
|
258
|
+
@click="dismiss"
|
|
259
|
+
>
|
|
260
|
+
<LpIcon name="lucide:x" :size="16" :class="CLOSE_ICON" />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</Transition>
|
|
267
|
+
</Teleport>
|
|
268
|
+
</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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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)
|