@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.
Files changed (87) hide show
  1. package/dist/assets/{LpCommandPalette-nGNnzXqQ.css → LpCommandPalette-5miKYsb-.css} +21 -21
  2. package/dist/assets/{LpDropzone-i4BdMi1H.css → LpDropzone-Ev6X03zj.css} +12 -12
  3. package/dist/assets/{LpTable-CPwWxTJS.css → LpTable-BoeA_TgK.css} +17 -17
  4. package/dist/chunks/{LpAddressInput-BwX8uo4_.js → LpAddressInput-iOJhmRzy.js} +1 -1
  5. package/dist/chunks/{LpAppShell-C53_RaGF.js → LpAppShell-BmMYcEVx.js} +2 -2
  6. package/dist/chunks/{LpAvatar-C1XStdOo.js → LpAvatar-DeSb6H0c.js} +1 -1
  7. package/dist/chunks/{LpCard-C2IvucmX.js → LpCard-BdxkMXjo.js} +1 -1
  8. package/dist/chunks/{LpColorPicker-zz4VGkG4.js → LpColorPicker-BLqPX5Vw.js} +1 -1
  9. package/dist/chunks/{LpConfirmDialog-DCw5jR1t.js → LpConfirmDialog-Ca-CTlxT.js} +1 -1
  10. package/dist/chunks/{LpConfirmHost-CbywMpMP.js → LpConfirmHost-BAaSaT2b.js} +1 -1
  11. package/dist/chunks/{LpContextMenu-BqkAYa9X.js → LpContextMenu-1cKew_Dc.js} +17 -4
  12. package/dist/chunks/{LpDatePicker-DuusgT69.js → LpDatePicker-BrdFtSTy.js} +3 -1
  13. package/dist/chunks/{LpDateRangePicker-DHeuznfC.js → LpDateRangePicker-Drd7EbIz.js} +4 -2
  14. package/dist/chunks/{LpDrawer-Dt4aB4Fr.js → LpDrawer-CBFVD624.js} +2 -2
  15. package/dist/chunks/{LpDropdownMenu-DdJGQzPn.js → LpDropdownMenu-DrpJJBp0.js} +22 -8
  16. package/dist/chunks/{LpFileTree-CNurOvkV.js → LpFileTree-bk5VjBey.js} +2 -2
  17. package/dist/chunks/{LpFileTreeNode-D_qRRdsH.js → LpFileTreeNode-BvMSBM7X.js} +1 -1
  18. package/dist/chunks/{LpLogViewer-KypbgOyo.js → LpLogViewer-BDFXzvcl.js} +2 -2
  19. package/dist/chunks/{LpModal-DMMDQHAu.js → LpModal-JNa3B_Lr.js} +13 -6
  20. package/dist/chunks/{LpModalNav-BVPDyIup.js → LpModalNav-BAxC9et7.js} +20 -7
  21. package/dist/chunks/{LpNotificationBell-CCwyJP5C.js → LpNotificationBell-DsqSyMwS.js} +2 -2
  22. package/dist/chunks/{LpPie-BEJ2SfI6.js → LpPie-BdSnta11.js} +1 -1
  23. package/dist/chunks/{LpScrollArea-Bry1fdOM.js → LpScrollArea-Hz3-JWUG.js} +23 -5
  24. package/dist/chunks/{LpSidebar-CQNB0-yU.js → LpSidebar-DpEiGi8L.js} +2 -2
  25. package/dist/chunks/{LpSidebarNav-kx-LdqNU.js → LpSidebarNav-BUuxipX-.js} +2 -2
  26. package/dist/chunks/{LpThemeSwitcher-B4aVPbNC.js → LpThemeSwitcher-BbYFFT5w.js} +1 -1
  27. package/dist/chunks/{LpToaster-DpJvoCOh.js → LpToaster-DTtWuKvq.js} +39 -6
  28. package/dist/chunks/MenuTitle-J3ZDYjpV.js +56 -0
  29. package/dist/components/LpAddressInput.vue.js +1 -1
  30. package/dist/components/LpAppShell.vue.js +1 -1
  31. package/dist/components/LpAvatar.vue.js +1 -1
  32. package/dist/components/LpCard.vue.js +1 -1
  33. package/dist/components/LpChart.vue.js +1 -1
  34. package/dist/components/LpColorPicker.vue.js +1 -1
  35. package/dist/components/LpCommandPalette.vue.js +7 -4
  36. package/dist/components/LpConfirmDialog.vue.js +1 -1
  37. package/dist/components/LpConfirmHost.vue.js +1 -1
  38. package/dist/components/LpContextMenu.vue.d.ts +11 -0
  39. package/dist/components/LpContextMenu.vue.js +1 -1
  40. package/dist/components/LpDatePicker.vue.js +1 -1
  41. package/dist/components/LpDateRangePicker.vue.js +1 -1
  42. package/dist/components/LpDrawer.vue.js +1 -1
  43. package/dist/components/LpDropdownMenu.vue.d.ts +6 -0
  44. package/dist/components/LpDropdownMenu.vue.js +1 -1
  45. package/dist/components/LpDropzone.vue.d.ts +14 -0
  46. package/dist/components/LpDropzone.vue.js +9 -7
  47. package/dist/components/LpFileTree.vue.js +1 -1
  48. package/dist/components/LpFileTreeNode.vue.js +1 -1
  49. package/dist/components/LpLogViewer.vue.js +1 -1
  50. package/dist/components/LpModal.vue.d.ts +7 -5
  51. package/dist/components/LpModal.vue.js +1 -1
  52. package/dist/components/LpModalNav.vue.d.ts +5 -3
  53. package/dist/components/LpModalNav.vue.js +1 -1
  54. package/dist/components/LpNotificationBell.vue.js +1 -1
  55. package/dist/components/LpPie.vue.js +1 -1
  56. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  57. package/dist/components/LpScrollArea.vue.js +1 -1
  58. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  59. package/dist/components/LpSidebar.vue.js +1 -1
  60. package/dist/components/LpSidebarNav.vue.js +1 -1
  61. package/dist/components/LpTable.vue.js +5 -5
  62. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  63. package/dist/components/LpToaster.vue.d.ts +2 -0
  64. package/dist/components/LpToaster.vue.js +1 -1
  65. package/dist/components/MenuTitle.vue.d.ts +9 -0
  66. package/dist/components/MenuTitle.vue.js +4 -0
  67. package/dist/composables/useClipboard.js +35 -2
  68. package/dist/composables/useLocale.d.ts +2 -0
  69. package/dist/composables/useLocale.js +9 -0
  70. package/dist/index.js +22 -22
  71. package/package.json +1 -1
  72. package/src/components/LpCommandPalette.vue +7 -1
  73. package/src/components/LpContextMenu.vue +19 -1
  74. package/src/components/LpDatePicker.vue +3 -1
  75. package/src/components/LpDateRangePicker.vue +3 -1
  76. package/src/components/LpDrawer.vue +1 -1
  77. package/src/components/LpDropdownMenu.vue +14 -2
  78. package/src/components/LpDropzone.vue +13 -5
  79. package/src/components/LpModal.vue +8 -2
  80. package/src/components/LpModalNav.vue +17 -2
  81. package/src/components/LpScrollArea.vue +30 -2
  82. package/src/components/LpTable.vue +1 -1
  83. package/src/components/LpToaster.vue +42 -4
  84. package/src/components/MenuTitle.vue +34 -0
  85. package/src/composables/useClipboard.ts +49 -7
  86. package/src/composables/useLocale.ts +14 -0
  87. 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 over = ref(false)
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
- over.value = true
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
- over.value = false
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
- over.value = false
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="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
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="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
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
- class="min-w-1 rounded-l-pill bg-brand/40 transition-[min-width,background-color] duration-[var(--duration-fast)] group-hover:min-w-1.5 group-hover:bg-brand"
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
- class="min-h-1 rounded-t-pill bg-brand/40 transition-[min-height,background-color] duration-[var(--duration-fast)] group-hover:min-h-1.5 group-hover:bg-brand"
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 class="break-words text-xs text-muted">
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. A
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
- // 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")
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
+ }
@@ -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
- background-color: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
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 {