@leavepulse/ui 0.45.0 → 0.46.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/assets/{LpCommandPalette-nGNnzXqQ.css → LpCommandPalette-5miKYsb-.css} +21 -21
- package/dist/assets/{LpDropzone-i4BdMi1H.css → LpDropzone-Ev6X03zj.css} +12 -12
- package/dist/assets/{LpTable-CPwWxTJS.css → LpTable-BoeA_TgK.css} +17 -17
- package/dist/chunks/{LpAddressInput-BwX8uo4_.js → LpAddressInput-iOJhmRzy.js} +1 -1
- package/dist/chunks/{LpAppShell-C53_RaGF.js → LpAppShell-BmMYcEVx.js} +2 -2
- package/dist/chunks/{LpAvatar-C1XStdOo.js → LpAvatar-DeSb6H0c.js} +1 -1
- package/dist/chunks/{LpCard-C2IvucmX.js → LpCard-BdxkMXjo.js} +1 -1
- package/dist/chunks/{LpColorPicker-zz4VGkG4.js → LpColorPicker-BLqPX5Vw.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-DCw5jR1t.js → LpConfirmDialog-Ca-CTlxT.js} +1 -1
- package/dist/chunks/{LpConfirmHost-CbywMpMP.js → LpConfirmHost-BAaSaT2b.js} +1 -1
- package/dist/chunks/{LpContextMenu-BqkAYa9X.js → LpContextMenu-1cKew_Dc.js} +17 -4
- package/dist/chunks/{LpDatePicker-DuusgT69.js → LpDatePicker-BrdFtSTy.js} +3 -1
- package/dist/chunks/{LpDateRangePicker-DHeuznfC.js → LpDateRangePicker-Drd7EbIz.js} +4 -2
- package/dist/chunks/{LpDrawer-Dt4aB4Fr.js → LpDrawer-CBFVD624.js} +2 -2
- package/dist/chunks/{LpDropdownMenu-DdJGQzPn.js → LpDropdownMenu-DrpJJBp0.js} +22 -8
- package/dist/chunks/{LpFileTree-CNurOvkV.js → LpFileTree-bk5VjBey.js} +2 -2
- package/dist/chunks/{LpFileTreeNode-D_qRRdsH.js → LpFileTreeNode-BvMSBM7X.js} +1 -1
- package/dist/chunks/{LpLogViewer-KypbgOyo.js → LpLogViewer-BDFXzvcl.js} +2 -2
- package/dist/chunks/{LpModal-DMMDQHAu.js → LpModal-JNa3B_Lr.js} +13 -6
- package/dist/chunks/{LpModalNav-BVPDyIup.js → LpModalNav-BAxC9et7.js} +20 -7
- package/dist/chunks/{LpNotificationBell-CCwyJP5C.js → LpNotificationBell-DsqSyMwS.js} +2 -2
- package/dist/chunks/{LpPie-BEJ2SfI6.js → LpPie-BdSnta11.js} +1 -1
- package/dist/chunks/{LpScrollArea-Bry1fdOM.js → LpScrollArea-Hz3-JWUG.js} +23 -5
- package/dist/chunks/{LpSidebar-CQNB0-yU.js → LpSidebar-DpEiGi8L.js} +2 -2
- package/dist/chunks/{LpSidebarNav-kx-LdqNU.js → LpSidebarNav-BUuxipX-.js} +2 -2
- package/dist/chunks/{LpThemeSwitcher-B4aVPbNC.js → LpThemeSwitcher-BbYFFT5w.js} +1 -1
- package/dist/chunks/{LpToaster-DpJvoCOh.js → LpToaster-DTtWuKvq.js} +39 -6
- package/dist/chunks/MenuTitle-J3ZDYjpV.js +56 -0
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +7 -4
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.d.ts +11 -0
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.d.ts +6 -0
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +14 -0
- package/dist/components/LpDropzone.vue.js +9 -7
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +7 -5
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.d.ts +5 -3
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.d.ts +2 -2
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +5 -5
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpToaster.vue.d.ts +2 -0
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/MenuTitle.vue.d.ts +9 -0
- package/dist/components/MenuTitle.vue.js +4 -0
- package/dist/composables/useClipboard.js +35 -2
- package/dist/composables/useLocale.d.ts +2 -0
- package/dist/composables/useLocale.js +9 -0
- package/dist/index.js +22 -22
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +7 -1
- package/src/components/LpContextMenu.vue +19 -1
- package/src/components/LpDatePicker.vue +3 -1
- package/src/components/LpDateRangePicker.vue +3 -1
- package/src/components/LpDrawer.vue +1 -1
- package/src/components/LpDropdownMenu.vue +14 -2
- package/src/components/LpDropzone.vue +13 -5
- package/src/components/LpModal.vue +8 -2
- package/src/components/LpModalNav.vue +17 -2
- package/src/components/LpScrollArea.vue +30 -2
- package/src/components/LpTable.vue +1 -1
- package/src/components/LpToaster.vue +42 -4
- package/src/components/MenuTitle.vue +34 -0
- package/src/composables/useClipboard.ts +49 -7
- package/src/composables/useLocale.ts +14 -0
- package/src/tokens/tokens.css +7 -4
|
@@ -23,7 +23,7 @@ export default { inheritAttrs: false }
|
|
|
23
23
|
* invisible to a keyboard: nothing focusable, nothing that Enter activates, and
|
|
24
24
|
* the file dialog unreachable without a mouse.
|
|
25
25
|
*/
|
|
26
|
-
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
26
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
27
27
|
import { useMergedAttrs } from "../composables/useMergedClass"
|
|
28
28
|
import { useReveal } from "../composables/useReveal"
|
|
29
29
|
import LpIcon from "./LpIcon.vue"
|
|
@@ -35,6 +35,13 @@ const props = withDefaults(
|
|
|
35
35
|
/** Take more than one at a time. */
|
|
36
36
|
multiple?: boolean
|
|
37
37
|
disabled?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* A drag the page itself cannot see is over the zone. Desktop shells
|
|
40
|
+
* (Tauri, Electron) often take file drags natively, and the page then gets
|
|
41
|
+
* no dragenter or dragover at all, only the shell's own events; the app
|
|
42
|
+
* hit-tests those and passes the answer here so the zone lights up the same.
|
|
43
|
+
*/
|
|
44
|
+
dragOver?: boolean
|
|
38
45
|
/**
|
|
39
46
|
* Also accept a paste anywhere on the page, not only while focused.
|
|
40
47
|
* Off by default: two zones both listening would each take the same
|
|
@@ -95,11 +102,12 @@ const input = ref<HTMLInputElement | null>(null)
|
|
|
95
102
|
* may not — one stuck increment and the zone stays lit after the drag is long
|
|
96
103
|
* gone, which is exactly what it did.
|
|
97
104
|
*/
|
|
98
|
-
const
|
|
105
|
+
const pointerOver = ref(false)
|
|
106
|
+
const over = computed(() => pointerOver.value || (props.dragOver && !props.disabled))
|
|
99
107
|
|
|
100
108
|
function onDragOver(event: DragEvent) {
|
|
101
109
|
if (props.disabled) return
|
|
102
|
-
|
|
110
|
+
pointerOver.value = true
|
|
103
111
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -108,7 +116,7 @@ function onDragLeave(event: DragEvent) {
|
|
|
108
116
|
// drag merely crossed a child, and the highlight stays.
|
|
109
117
|
const to = event.relatedTarget as Node | null
|
|
110
118
|
if (to && (event.currentTarget as HTMLElement).contains(to)) return
|
|
111
|
-
|
|
119
|
+
pointerOver.value = false
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** Does the file match `accept`? Mirrors the input's own matching so a drop
|
|
@@ -146,7 +154,7 @@ function take(list: FileList | File[] | null | undefined) {
|
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
function onDrop(event: DragEvent) {
|
|
149
|
-
|
|
157
|
+
pointerOver.value = false
|
|
150
158
|
take(event.dataTransfer?.files)
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -325,14 +325,14 @@ watch(
|
|
|
325
325
|
<div
|
|
326
326
|
ref="panelRef"
|
|
327
327
|
class="relative flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line shadow-panel"
|
|
328
|
-
:class="[frosted ? (layer.nested ? 'lp-glass-overlay--nested' : 'lp-glass-overlay') : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
|
|
328
|
+
:class="[frosted ? (layer.nested ? 'lp-glass-overlay lp-glass-overlay--nested' : 'lp-glass-overlay') : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
|
|
329
329
|
:style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
|
|
330
330
|
>
|
|
331
331
|
<DialogTitle v-if="!(title || $slots.title) && ariaLabel" class="sr-only">
|
|
332
332
|
{{ ariaLabel }}
|
|
333
333
|
</DialogTitle>
|
|
334
334
|
<header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
|
|
335
|
-
<div class="flex flex-col gap-1">
|
|
335
|
+
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
336
336
|
<DialogTitle class="text-base font-semibold text-ink">
|
|
337
337
|
<slot name="title">{{ title }}</slot>
|
|
338
338
|
</DialogTitle>
|
|
@@ -340,6 +340,12 @@ watch(
|
|
|
340
340
|
{{ description }}
|
|
341
341
|
</DialogDescription>
|
|
342
342
|
</div>
|
|
343
|
+
<!-- Controls that act on the whole dialog (a period, a filter) sit
|
|
344
|
+
beside the close button, not inside the title: the title is a
|
|
345
|
+
heading, and a button in it would be read out as part of it. -->
|
|
346
|
+
<div v-if="$slots.actions" class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
347
|
+
<slot name="actions" />
|
|
348
|
+
</div>
|
|
343
349
|
<!-- Marked so the auto-focus pass skips it: it is first in the DOM
|
|
344
350
|
and last in usefulness. Still reachable by Tab and Escape. -->
|
|
345
351
|
<DialogClose
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* one the sections are simply unreachable on a phone.
|
|
15
15
|
*/
|
|
16
16
|
import { computed, ref } from "vue"
|
|
17
|
+
import { useGlass } from "../composables/useGlass"
|
|
17
18
|
import { useLabels } from "../composables/useLpConfig"
|
|
18
19
|
import LpButton from "./LpButton.vue"
|
|
19
20
|
import LpIcon from "./LpIcon.vue"
|
|
@@ -127,6 +128,16 @@ const triggerClass = computed(() => TRIGGER_VISIBILITY[props.mobileBreakpoint])
|
|
|
127
128
|
* consuming app's scan.
|
|
128
129
|
*/
|
|
129
130
|
const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The rail is part of the dialog, so it takes the same surface: a glass
|
|
134
|
+
* dialog beside a solid rail read as two different windows.
|
|
135
|
+
*/
|
|
136
|
+
const frosted = useGlass(() => props.glass)
|
|
137
|
+
const railPanel = computed(() => [
|
|
138
|
+
"rounded-card border border-line p-2 shadow-panel",
|
|
139
|
+
frosted.value ? "lp-glass-overlay" : "bg-surface-raised",
|
|
140
|
+
])
|
|
130
141
|
</script>
|
|
131
142
|
|
|
132
143
|
<template>
|
|
@@ -158,7 +169,7 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
|
|
|
158
169
|
responsive
|
|
159
170
|
:mobile-breakpoint="mobileBreakpoint"
|
|
160
171
|
:style="railStyle"
|
|
161
|
-
class="
|
|
172
|
+
:class="railPanel"
|
|
162
173
|
/>
|
|
163
174
|
</div>
|
|
164
175
|
</template>
|
|
@@ -178,7 +189,7 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
|
|
|
178
189
|
responsive
|
|
179
190
|
:mobile-breakpoint="mobileBreakpoint"
|
|
180
191
|
:style="railStyle"
|
|
181
|
-
class="
|
|
192
|
+
:class="railPanel"
|
|
182
193
|
/>
|
|
183
194
|
</div>
|
|
184
195
|
</template>
|
|
@@ -200,6 +211,10 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
|
|
|
200
211
|
</div>
|
|
201
212
|
</template>
|
|
202
213
|
|
|
214
|
+
<template v-if="$slots.actions" #actions>
|
|
215
|
+
<slot name="actions" />
|
|
216
|
+
</template>
|
|
217
|
+
|
|
203
218
|
<slot />
|
|
204
219
|
|
|
205
220
|
<template v-if="$slots.footer" #footer>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ScrollAreaThumb,
|
|
14
14
|
ScrollAreaViewport,
|
|
15
15
|
} from "reka-ui"
|
|
16
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
16
17
|
|
|
17
18
|
// `fade` softens whichever of the top/bottom edges currently has content beyond
|
|
18
19
|
// it, so a clipped list reads as continuing rather than as cut off. On by
|
|
@@ -182,6 +183,29 @@ watch(
|
|
|
182
183
|
|
|
183
184
|
onBeforeUnmount(stopEdgeWatch)
|
|
184
185
|
|
|
186
|
+
/*
|
|
187
|
+
* A press on the bar beside the thumb. reka treats any press on the track as
|
|
188
|
+
* "put the thumb's middle under the pointer" and sets the scroll in one go,
|
|
189
|
+
* so a press that just missed the narrow thumb yanked the content by half a
|
|
190
|
+
* thumb, and a press further away teleported it. Like a native bar, a press
|
|
191
|
+
* off the thumb now pages one viewport towards the pointer, smoothly; the
|
|
192
|
+
* thumb itself has a hit area as wide as the bar (see its `before:`), so a
|
|
193
|
+
* press that meant to grab it does.
|
|
194
|
+
*/
|
|
195
|
+
function onTrackPointerDown(event: PointerEvent, axis: "x" | "y") {
|
|
196
|
+
const el = viewportEl.value
|
|
197
|
+
const bar = event.currentTarget as HTMLElement
|
|
198
|
+
const thumb = bar.querySelector<HTMLElement>("[data-lp-thumb]")
|
|
199
|
+
if (!el || !thumb || event.button !== 0 || thumb.contains(event.target as Node)) return
|
|
200
|
+
event.preventDefault()
|
|
201
|
+
event.stopImmediatePropagation()
|
|
202
|
+
const rect = thumb.getBoundingClientRect()
|
|
203
|
+
const before = axis === "y" ? event.clientY < rect.top : event.clientX < rect.left
|
|
204
|
+
const step = (axis === "y" ? el.clientHeight : el.clientWidth) * 0.85 * (before ? -1 : 1)
|
|
205
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth"
|
|
206
|
+
el.scrollBy(axis === "y" ? { top: step, behavior } : { left: step, behavior })
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
function onThumbPointerDown() {
|
|
186
210
|
dragging.value = true
|
|
187
211
|
const stop = () => {
|
|
@@ -230,6 +254,7 @@ function onThumbPointerDown() {
|
|
|
230
254
|
class="group z-1 flex w-3.5 touch-none select-none justify-end py-px pl-2.5 pr-px"
|
|
231
255
|
:class="barFade"
|
|
232
256
|
:style="barInsetTop ? { marginTop: barInsetTop } : undefined"
|
|
257
|
+
@pointerdown.capture="onTrackPointerDown($event, 'y')"
|
|
233
258
|
>
|
|
234
259
|
<!-- Sized with min-width, not width: reka sets an inline
|
|
235
260
|
`width: var(--reka-scroll-area-thumb-width)` on the thumb, and for a
|
|
@@ -238,7 +263,8 @@ function onThumbPointerDown() {
|
|
|
238
263
|
leaving an invisible thumb. A minimum wins over that without fighting
|
|
239
264
|
the positioning reka owns. -->
|
|
240
265
|
<ScrollAreaThumb
|
|
241
|
-
|
|
266
|
+
data-lp-thumb
|
|
267
|
+
class="relative min-w-1 rounded-l-pill bg-brand/40 before:absolute before:inset-y-0 before:right-0 before:-left-2.5 before:content-[''] transition-[min-width,background-color] duration-[var(--duration-fast)] group-hover:min-w-1.5 group-hover:bg-brand"
|
|
242
268
|
@pointerdown="onThumbPointerDown"
|
|
243
269
|
/>
|
|
244
270
|
</ScrollAreaScrollbar>
|
|
@@ -247,9 +273,11 @@ function onThumbPointerDown() {
|
|
|
247
273
|
orientation="horizontal"
|
|
248
274
|
class="group z-1 flex h-3.5 flex-col touch-none select-none justify-end px-px pb-px pt-2.5"
|
|
249
275
|
:class="barFade"
|
|
276
|
+
@pointerdown.capture="onTrackPointerDown($event, 'x')"
|
|
250
277
|
>
|
|
251
278
|
<ScrollAreaThumb
|
|
252
|
-
|
|
279
|
+
data-lp-thumb
|
|
280
|
+
class="relative min-h-1 rounded-t-pill bg-brand/40 before:absolute before:inset-x-0 before:bottom-0 before:-top-2.5 before:content-[''] transition-[min-height,background-color] duration-[var(--duration-fast)] group-hover:min-h-1.5 group-hover:bg-brand"
|
|
253
281
|
@pointerdown="onThumbPointerDown"
|
|
254
282
|
/>
|
|
255
283
|
</ScrollAreaScrollbar>
|
|
@@ -1098,7 +1098,7 @@ const barInsetTop = computed(() =>
|
|
|
1098
1098
|
<LpScrollArea
|
|
1099
1099
|
v-bind="scrollAttrs"
|
|
1100
1100
|
:fade="false"
|
|
1101
|
-
class="min-h-0 flex-1 rounded-card border border-line"
|
|
1101
|
+
class="min-h-0 flex-1 rounded-card border border-line bg-surface"
|
|
1102
1102
|
:style="load && floor ? { minHeight: `${floor + 2}px` } : undefined"
|
|
1103
1103
|
:bar-inset-top="barInsetTop"
|
|
1104
1104
|
>
|
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
} from "reka-ui"
|
|
14
14
|
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
|
|
15
15
|
import { prefersReducedMotion } from "../composables/easing"
|
|
16
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
16
17
|
import { useLabels } from "../composables/useLpConfig"
|
|
17
18
|
import { useToast, type ToastItem } from "../composables/useToast"
|
|
18
19
|
import { CLOSE_ICON } from "./dropdown"
|
|
19
20
|
import LpIcon from "./LpIcon.vue"
|
|
20
21
|
|
|
21
|
-
const props = defineProps<{ labels?: Partial<{ dismiss: string }> }>()
|
|
22
|
-
const text = useLabels("toast", { dismiss: "Dismiss" }, () => props.labels)
|
|
22
|
+
const props = defineProps<{ labels?: Partial<{ dismiss: string; copy: string; copied: string }> }>()
|
|
23
|
+
const text = useLabels("toast", { dismiss: "Dismiss", copy: "Copy message", copied: "Copied" }, () => props.labels)
|
|
23
24
|
|
|
24
25
|
const { queue, dismiss } = useToast()
|
|
25
26
|
|
|
@@ -211,8 +212,32 @@ async function settle(t: ToastItem, run: () => void | Promise<void>) {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/*
|
|
216
|
+
* An error is something people need to pass on, so its text can be selected
|
|
217
|
+
* and copied. reka sets user-select: none on the toast and starts a swipe on
|
|
218
|
+
* any press, which made the message impossible to select: the description
|
|
219
|
+
* opts back into selection and keeps its presses from starting a swipe. A
|
|
220
|
+
* click that ends a selection is not a click on the toast.
|
|
221
|
+
*/
|
|
222
|
+
const { copy } = useClipboard()
|
|
223
|
+
const copiedId = ref<string | null>(null)
|
|
224
|
+
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
|
225
|
+
const copyable = (t: ToastItem) => t.variant === "error" || t.variant === "warning"
|
|
226
|
+
|
|
227
|
+
async function copyToast(t: ToastItem) {
|
|
228
|
+
if (!(await copy([t.title, t.message].filter(Boolean).join("\n")))) return
|
|
229
|
+
copiedId.value = tag(t)
|
|
230
|
+
clearTimeout(copiedTimer)
|
|
231
|
+
copiedTimer = setTimeout(() => (copiedId.value = null), 1500)
|
|
232
|
+
}
|
|
233
|
+
onBeforeUnmount(() => clearTimeout(copiedTimer))
|
|
234
|
+
|
|
235
|
+
function selecting(): boolean {
|
|
236
|
+
return Boolean(window.getSelection()?.toString())
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
function runClick(t: ToastItem) {
|
|
215
|
-
if (swiped === tag(t)) return
|
|
240
|
+
if (swiped === tag(t) || selecting()) return
|
|
216
241
|
if (t.onClick) return settle(t, t.onClick)
|
|
217
242
|
}
|
|
218
243
|
|
|
@@ -250,7 +275,10 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
|
|
|
250
275
|
<ToastTitle v-if="t.title" class="text-sm font-semibold text-ink">
|
|
251
276
|
{{ t.title }}
|
|
252
277
|
</ToastTitle>
|
|
253
|
-
<ToastDescription
|
|
278
|
+
<ToastDescription
|
|
279
|
+
class="cursor-text select-text break-words text-xs text-muted"
|
|
280
|
+
@pointerdown.stop
|
|
281
|
+
>
|
|
254
282
|
{{ t.message }}
|
|
255
283
|
</ToastDescription>
|
|
256
284
|
|
|
@@ -274,6 +302,16 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
|
|
|
274
302
|
</div>
|
|
275
303
|
</div>
|
|
276
304
|
|
|
305
|
+
<button
|
|
306
|
+
v-if="copyable(t)"
|
|
307
|
+
type="button"
|
|
308
|
+
class="-mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
309
|
+
:aria-label="copiedId === tag(t) ? text.copied : text.copy"
|
|
310
|
+
:title="copiedId === tag(t) ? text.copied : text.copy"
|
|
311
|
+
@click.stop="copyToast(t)"
|
|
312
|
+
>
|
|
313
|
+
<LpIcon :name="copiedId === tag(t) ? 'lucide:check' : 'lucide:copy'" :size="15" />
|
|
314
|
+
</button>
|
|
277
315
|
<button
|
|
278
316
|
type="button"
|
|
279
317
|
class="group -mr-1 -mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* The heading row of a menu: what the menu acts on, so a right-click on one of
|
|
4
|
+
* many look-alike rows says which one it caught before anything is chosen.
|
|
5
|
+
* Not an item: it takes no focus or highlight, and long names truncate rather
|
|
6
|
+
* than widening the panel. Internal: LpContextMenu and LpDropdownMenu render it
|
|
7
|
+
* from their `title` props.
|
|
8
|
+
*/
|
|
9
|
+
import LpIcon from "./LpIcon.vue"
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
title: string
|
|
13
|
+
subtitle?: string
|
|
14
|
+
icon?: string
|
|
15
|
+
image?: string
|
|
16
|
+
}>()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex max-w-72 min-w-0 items-center gap-2 px-2.5 pt-1.5 pb-2" aria-hidden="true">
|
|
21
|
+
<img v-if="image" :src="image" alt="" class="size-7 shrink-0 rounded-md object-cover" />
|
|
22
|
+
<span
|
|
23
|
+
v-else-if="icon"
|
|
24
|
+
class="grid size-7 shrink-0 place-items-center rounded-md bg-surface-soft text-muted"
|
|
25
|
+
>
|
|
26
|
+
<LpIcon :name="icon" :size="15" />
|
|
27
|
+
</span>
|
|
28
|
+
<span class="flex min-w-0 flex-col">
|
|
29
|
+
<span class="truncate text-sm font-medium text-ink">{{ title }}</span>
|
|
30
|
+
<span v-if="subtitle" class="truncate text-xs text-muted">{{ subtitle }}</span>
|
|
31
|
+
</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="mb-1 h-px bg-line" role="presentation" />
|
|
34
|
+
</template>
|
|
@@ -13,7 +13,9 @@ import { useToast } from "./useToast"
|
|
|
13
13
|
*
|
|
14
14
|
* Why every call is allowed to fail: the Clipboard API needs a secure context
|
|
15
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.
|
|
16
|
+
* outside a user gesture, and is missing entirely in a non-DOM test runner.
|
|
17
|
+
* Where it is missing or refuses, the old copy command is tried before
|
|
18
|
+
* giving up (see `writeLegacy`). A
|
|
17
19
|
* copy that cannot happen is a disappointment, not an exception, and a rejected
|
|
18
20
|
* promise reaching a component means an unhandled rejection in the console of
|
|
19
21
|
* an app that did nothing wrong.
|
|
@@ -23,6 +25,51 @@ import { useToast } from "./useToast"
|
|
|
23
25
|
* than any message, and the two are complementary rather than alternatives.
|
|
24
26
|
*/
|
|
25
27
|
|
|
28
|
+
/** The Clipboard API, where there is one and it agrees. */
|
|
29
|
+
async function writeAsync(text: string): Promise<boolean> {
|
|
30
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) return false
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(text)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* The old copy command, for where the Clipboard API is missing or refuses.
|
|
41
|
+
* Embedded Chromium (CEF in a Tauri or Electron-like shell) rejects
|
|
42
|
+
* `writeText` when the host answers no permission requests, and plain http
|
|
43
|
+
* has no API at all, while a copy command run inside the click still works
|
|
44
|
+
* there. It copies the selection, so a throwaway text field holds the text
|
|
45
|
+
* and the page's own selection and focus are put back afterwards.
|
|
46
|
+
*/
|
|
47
|
+
function writeLegacy(text: string): boolean {
|
|
48
|
+
if (typeof document === "undefined") return false
|
|
49
|
+
const active = document.activeElement as HTMLElement | null
|
|
50
|
+
const selection = document.getSelection()
|
|
51
|
+
const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i)) : []
|
|
52
|
+
const field = document.createElement("textarea")
|
|
53
|
+
field.value = text
|
|
54
|
+
field.setAttribute("readonly", "")
|
|
55
|
+
field.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none"
|
|
56
|
+
document.body.appendChild(field)
|
|
57
|
+
field.select()
|
|
58
|
+
let ok = false
|
|
59
|
+
try {
|
|
60
|
+
ok = document.execCommand("copy")
|
|
61
|
+
} catch {
|
|
62
|
+
ok = false
|
|
63
|
+
}
|
|
64
|
+
field.remove()
|
|
65
|
+
if (selection) {
|
|
66
|
+
selection.removeAllRanges()
|
|
67
|
+
for (const range of ranges) selection.addRange(range)
|
|
68
|
+
}
|
|
69
|
+
active?.focus?.({ preventScroll: true })
|
|
70
|
+
return ok
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
/** How long `copied` stays true — long enough to read, short enough to not linger. */
|
|
27
74
|
const COPIED_MS = 1400
|
|
28
75
|
|
|
@@ -81,12 +128,7 @@ export function useClipboard(): UseClipboard {
|
|
|
81
128
|
async function copy(text: string, options: CopyOptions = {}): Promise<boolean> {
|
|
82
129
|
const { toast = false, errorToast = false } = options
|
|
83
130
|
try {
|
|
84
|
-
|
|
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")
|
|
131
|
+
if (!(await writeAsync(text)) && !writeLegacy(text)) throw new Error("clipboard unavailable")
|
|
90
132
|
|
|
91
133
|
copied.value = true
|
|
92
134
|
clearTimeout(timer)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from "vue"
|
|
2
|
+
import { injectConfigProviderContext } from "reka-ui"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The locale a date is written in: the component's own `locale`, else the one
|
|
6
|
+
* the app gave reka's ConfigProvider, else undefined, which lets Intl use the
|
|
7
|
+
* browser's. The calendars already followed ConfigProvider while the field
|
|
8
|
+
* that shows the picked date did not, so a Russian app showed a Russian
|
|
9
|
+
* calendar under "Oct 8, 2026".
|
|
10
|
+
*/
|
|
11
|
+
export function useLocale(own?: MaybeRefOrGetter<string | undefined>): ComputedRef<string | undefined> {
|
|
12
|
+
const context = injectConfigProviderContext({})
|
|
13
|
+
return computed(() => toValue(own) || context.locale?.value || undefined)
|
|
14
|
+
}
|
package/src/tokens/tokens.css
CHANGED
|
@@ -550,11 +550,14 @@
|
|
|
550
550
|
backdrop-filter: blur(var(--glass-overlay-blur, 28px)) saturate(170%);
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/*
|
|
554
|
+
* A dialog opened over another is glass too: without its own blur the text of
|
|
555
|
+
* the dialog below read straight through the fill. Its scrim stays unblurred
|
|
556
|
+
* (.lp-scrim--nested), so the only blur is the panel's, and the fill is a
|
|
557
|
+
* little denser than the first layer's, since what it blurs is glass already.
|
|
558
|
+
*/
|
|
553
559
|
.lp-glass-overlay--nested {
|
|
554
|
-
|
|
555
|
-
background-image: none;
|
|
556
|
-
-webkit-backdrop-filter: none;
|
|
557
|
-
backdrop-filter: none;
|
|
560
|
+
--glass-overlay-fill: 62%;
|
|
558
561
|
}
|
|
559
562
|
|
|
560
563
|
:root[data-lp-no-backdrop] .lp-glass-overlay {
|