@leavepulse/ui 0.45.1 → 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 (72) hide show
  1. package/dist/assets/{LpCommandPalette-HzAA4gri.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-CJOYGZlI.js → LpAddressInput-iOJhmRzy.js} +1 -1
  5. package/dist/chunks/{LpAppShell-B07Lf48e.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-BTVAR4tF.js → LpConfirmDialog-Ca-CTlxT.js} +1 -1
  10. package/dist/chunks/{LpConfirmHost-Ci_uCHgQ.js → LpConfirmHost-BAaSaT2b.js} +1 -1
  11. package/dist/chunks/{LpContextMenu-BqkAYa9X.js → LpContextMenu-1cKew_Dc.js} +17 -4
  12. package/dist/chunks/{LpDrawer-CfRhHwmE.js → LpDrawer-CBFVD624.js} +1 -1
  13. package/dist/chunks/{LpDropdownMenu-DdJGQzPn.js → LpDropdownMenu-DrpJJBp0.js} +22 -8
  14. package/dist/chunks/{LpFileTree-CNurOvkV.js → LpFileTree-bk5VjBey.js} +2 -2
  15. package/dist/chunks/{LpFileTreeNode-D_qRRdsH.js → LpFileTreeNode-BvMSBM7X.js} +1 -1
  16. package/dist/chunks/{LpLogViewer-KypbgOyo.js → LpLogViewer-BDFXzvcl.js} +2 -2
  17. package/dist/chunks/{LpModal-BzTVeNqe.js → LpModal-JNa3B_Lr.js} +12 -5
  18. package/dist/chunks/{LpModalNav-CjABSGCu.js → LpModalNav-BAxC9et7.js} +10 -3
  19. package/dist/chunks/{LpNotificationBell-CCwyJP5C.js → LpNotificationBell-DsqSyMwS.js} +2 -2
  20. package/dist/chunks/{LpPie-BEJ2SfI6.js → LpPie-BdSnta11.js} +1 -1
  21. package/dist/chunks/{LpScrollArea-Bry1fdOM.js → LpScrollArea-Hz3-JWUG.js} +23 -5
  22. package/dist/chunks/{LpSidebar-Bwws_X29.js → LpSidebar-DpEiGi8L.js} +2 -2
  23. package/dist/chunks/{LpSidebarNav-kx-LdqNU.js → LpSidebarNav-BUuxipX-.js} +2 -2
  24. package/dist/chunks/{LpThemeSwitcher-B4aVPbNC.js → LpThemeSwitcher-BbYFFT5w.js} +1 -1
  25. package/dist/chunks/MenuTitle-J3ZDYjpV.js +56 -0
  26. package/dist/components/LpAddressInput.vue.js +1 -1
  27. package/dist/components/LpAppShell.vue.js +1 -1
  28. package/dist/components/LpAvatar.vue.js +1 -1
  29. package/dist/components/LpCard.vue.js +1 -1
  30. package/dist/components/LpChart.vue.js +1 -1
  31. package/dist/components/LpColorPicker.vue.js +1 -1
  32. package/dist/components/LpCommandPalette.vue.js +6 -3
  33. package/dist/components/LpConfirmDialog.vue.js +1 -1
  34. package/dist/components/LpConfirmHost.vue.js +1 -1
  35. package/dist/components/LpContextMenu.vue.d.ts +11 -0
  36. package/dist/components/LpContextMenu.vue.js +1 -1
  37. package/dist/components/LpDrawer.vue.js +1 -1
  38. package/dist/components/LpDropdownMenu.vue.d.ts +6 -0
  39. package/dist/components/LpDropdownMenu.vue.js +1 -1
  40. package/dist/components/LpDropzone.vue.d.ts +14 -0
  41. package/dist/components/LpDropzone.vue.js +9 -7
  42. package/dist/components/LpFileTree.vue.js +1 -1
  43. package/dist/components/LpFileTreeNode.vue.js +1 -1
  44. package/dist/components/LpLogViewer.vue.js +1 -1
  45. package/dist/components/LpModal.vue.d.ts +7 -5
  46. package/dist/components/LpModal.vue.js +1 -1
  47. package/dist/components/LpModalNav.vue.d.ts +5 -3
  48. package/dist/components/LpModalNav.vue.js +1 -1
  49. package/dist/components/LpNotificationBell.vue.js +1 -1
  50. package/dist/components/LpPie.vue.js +1 -1
  51. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  52. package/dist/components/LpScrollArea.vue.js +1 -1
  53. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  54. package/dist/components/LpSidebar.vue.js +1 -1
  55. package/dist/components/LpSidebarNav.vue.js +1 -1
  56. package/dist/components/LpTable.vue.js +5 -5
  57. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  58. package/dist/components/MenuTitle.vue.d.ts +9 -0
  59. package/dist/components/MenuTitle.vue.js +4 -0
  60. package/dist/composables/useClipboard.js +35 -2
  61. package/dist/index.js +19 -19
  62. package/package.json +1 -1
  63. package/src/components/LpCommandPalette.vue +6 -0
  64. package/src/components/LpContextMenu.vue +19 -1
  65. package/src/components/LpDropdownMenu.vue +14 -2
  66. package/src/components/LpDropzone.vue +13 -5
  67. package/src/components/LpModal.vue +7 -1
  68. package/src/components/LpModalNav.vue +4 -0
  69. package/src/components/LpScrollArea.vue +30 -2
  70. package/src/components/LpTable.vue +1 -1
  71. package/src/components/MenuTitle.vue +34 -0
  72. package/src/composables/useClipboard.ts +49 -7
@@ -1,5 +1,39 @@
1
1
  import { ref, onScopeDispose } from "vue";
2
2
  import { useToast } from "./useToast.js";
3
+ async function writeAsync(text) {
4
+ if (typeof navigator === "undefined" || !navigator.clipboard) return false;
5
+ try {
6
+ await navigator.clipboard.writeText(text);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+ function writeLegacy(text) {
13
+ if (typeof document === "undefined") return false;
14
+ const active = document.activeElement;
15
+ const selection = document.getSelection();
16
+ const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i)) : [];
17
+ const field = document.createElement("textarea");
18
+ field.value = text;
19
+ field.setAttribute("readonly", "");
20
+ field.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none";
21
+ document.body.appendChild(field);
22
+ field.select();
23
+ let ok = false;
24
+ try {
25
+ ok = document.execCommand("copy");
26
+ } catch {
27
+ ok = false;
28
+ }
29
+ field.remove();
30
+ if (selection) {
31
+ selection.removeAllRanges();
32
+ for (const range of ranges) selection.addRange(range);
33
+ }
34
+ active?.focus?.({ preventScroll: true });
35
+ return ok;
36
+ }
3
37
  const COPIED_MS = 1400;
4
38
  function useClipboard() {
5
39
  const copied = ref(false);
@@ -8,8 +42,7 @@ function useClipboard() {
8
42
  async function copy(text, options = {}) {
9
43
  const { toast = false, errorToast = false } = options;
10
44
  try {
11
- await navigator.clipboard?.writeText(text);
12
- if (!navigator.clipboard) throw new Error("clipboard unavailable");
45
+ if (!await writeAsync(text) && !writeLegacy(text)) throw new Error("clipboard unavailable");
13
46
  copied.value = true;
14
47
  clearTimeout(timer);
15
48
  timer = setTimeout(() => copied.value = false, COPIED_MS);
package/dist/index.js CHANGED
@@ -1,42 +1,42 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-BkaGXTjx.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-CJOYGZlI.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-iOJhmRzy.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-Cc84LaM3.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-CnfFVIwC.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-B6ilrM7T.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-B07Lf48e.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-BmMYcEVx.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-C8IgSMsi.js";
9
- import { _ as _8 } from "./chunks/LpAvatar-C1XStdOo.js";
9
+ import { _ as _8 } from "./chunks/LpAvatar-DeSb6H0c.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
11
11
  import { _ as _10 } from "./chunks/LpBreadcrumbs-CQJd73Gm.js";
12
12
  import { _ as _11 } from "./chunks/LpButton-CKSwC6Mu.js";
13
13
  import { _ as _12 } from "./chunks/LpCalendar-DrA2McTU.js";
14
- import { _ as _13 } from "./chunks/LpCard-C2IvucmX.js";
14
+ import { _ as _13 } from "./chunks/LpCard-BdxkMXjo.js";
15
15
  import { default as default2 } from "./components/LpChart.vue.js";
16
16
  import { backgroundOf, csvField, downloadFile, fileStem, inlineSvg, resolveCssColor, svgToPng, toCsv } from "./components/exportData.js";
17
17
  import { CHART_PALETTE, axisTicks, chartSummary, formatTick, markerIndex, niceScale, resolveSlices } from "./components/chart.js";
18
18
  import { _ as _14 } from "./chunks/LpCheckbox-Bkj13CbW.js";
19
19
  import { _ as _15 } from "./chunks/LpCodeBlock-ca7ANFyW.js";
20
20
  import { tokenizeLines } from "./components/codeHighlight.js";
21
- import { _ as _16 } from "./chunks/LpColorPicker-zz4VGkG4.js";
21
+ import { _ as _16 } from "./chunks/LpColorPicker-BLqPX5Vw.js";
22
22
  import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
23
23
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
24
24
  import { parseCommandQuery, rankCommandItems } from "./components/commandSearch.js";
25
- import { _ as _17 } from "./chunks/LpConfirmDialog-BTVAR4tF.js";
26
- import { _ as _18 } from "./chunks/LpConfirmHost-Ci_uCHgQ.js";
25
+ import { _ as _17 } from "./chunks/LpConfirmDialog-Ca-CTlxT.js";
26
+ import { _ as _18 } from "./chunks/LpConfirmHost-BAaSaT2b.js";
27
27
  import { confirm, useConfirm } from "./components/confirm.js";
28
- import { _ as _19 } from "./chunks/LpContextMenu-BqkAYa9X.js";
28
+ import { _ as _19 } from "./chunks/LpContextMenu-1cKew_Dc.js";
29
29
  import { _ as _20 } from "./chunks/LpDatePicker-BrdFtSTy.js";
30
30
  import { _ as _21 } from "./chunks/LpDateRangePicker-Drd7EbIz.js";
31
31
  import { _ as _22 } from "./chunks/LpRangeCalendar-DZuaRV0W.js";
32
32
  import { clampRange, defaultRangePresets, formatRange, normalizeRange, sameRange } from "./components/dateRange.js";
33
33
  import { _ as _23 } from "./chunks/LpDisclosure-Cc23-JOT.js";
34
34
  import { _ as _24 } from "./chunks/LpDivider-CUX46KR0.js";
35
- import { _ as _25 } from "./chunks/LpDrawer-CfRhHwmE.js";
36
- import { _ as _26 } from "./chunks/LpDropdownMenu-DdJGQzPn.js";
35
+ import { _ as _25 } from "./chunks/LpDrawer-CBFVD624.js";
36
+ import { _ as _26 } from "./chunks/LpDropdownMenu-DrpJJBp0.js";
37
37
  import { default as default4 } from "./components/LpDropzone.vue.js";
38
38
  import { _ as _27 } from "./chunks/LpEmptyState-BJbZXC8D.js";
39
- import { _ as _28 } from "./chunks/LpFileTree-CNurOvkV.js";
39
+ import { _ as _28 } from "./chunks/LpFileTree-bk5VjBey.js";
40
40
  import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
41
41
  import { _ as _29 } from "./chunks/LpFieldNotice-wtieLUTj.js";
42
42
  import { _ as _30 } from "./chunks/LpFormField-CtVYj6tO.js";
@@ -48,19 +48,19 @@ import { _ as _33 } from "./chunks/LpInput-D-YGQbEv.js";
48
48
  import { _ as _34 } from "./chunks/LpLightbox-DSDahF23.js";
49
49
  import { fileNameOf } from "./components/lightbox.js";
50
50
  import { _ as _35 } from "./chunks/LpLink-Krkjhe_y.js";
51
- import { _ as _36 } from "./chunks/LpLogViewer-KypbgOyo.js";
51
+ import { _ as _36 } from "./chunks/LpLogViewer-BDFXzvcl.js";
52
52
  import { countLevels, matchPositions, matchesTerm, ringWindow, stepMatch } from "./components/logSearch.js";
53
53
  import { _ as _37 } from "./chunks/LpMediaGallery-YKr-cQPN.js";
54
- import { _ as _38 } from "./chunks/LpModal-BzTVeNqe.js";
55
- import { _ as _39 } from "./chunks/LpModalNav-CjABSGCu.js";
56
- import { _ as _40 } from "./chunks/LpNotificationBell-CCwyJP5C.js";
54
+ import { _ as _38 } from "./chunks/LpModal-JNa3B_Lr.js";
55
+ import { _ as _39 } from "./chunks/LpModalNav-BAxC9et7.js";
56
+ import { _ as _40 } from "./chunks/LpNotificationBell-DsqSyMwS.js";
57
57
  import { _ as _41 } from "./chunks/LpNumberField-B77O-z28.js";
58
58
  import { _ as _42 } from "./chunks/LpTimeField-Cqe7fXnh.js";
59
59
  import { _ as _43 } from "./chunks/LpNumberFlow-z4WPZcZu.js";
60
60
  import { _ as _44 } from "./chunks/LpOtpInput-CziPF3Oq.js";
61
61
  import { _ as _45 } from "./chunks/LpPagination-BcDlo3r_.js";
62
62
  import { _ as _46 } from "./chunks/LpPasswordInput-eKzQhbQP.js";
63
- import { _ as _47 } from "./chunks/LpPie-BEJ2SfI6.js";
63
+ import { _ as _47 } from "./chunks/LpPie-BdSnta11.js";
64
64
  import { _ as _48 } from "./chunks/LpPhoneInput-BCbrrhgE.js";
65
65
  import { filterCountries, flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
66
66
  import { _ as _49 } from "./chunks/LpPopover-DCKeqiVn.js";
@@ -68,14 +68,14 @@ import { _ as _50 } from "./chunks/LpProgress-CBU1iI69.js";
68
68
  import { _ as _51 } from "./chunks/LpRadio-B5AfqTpZ.js";
69
69
  import { _ as _52 } from "./chunks/LpRadioGroup-DmEW0l78.js";
70
70
  import { _ as _53 } from "./chunks/LpRollingText-DB7upf-5.js";
71
- import { _ as _54 } from "./chunks/LpScrollArea-Bry1fdOM.js";
71
+ import { _ as _54 } from "./chunks/LpScrollArea-Hz3-JWUG.js";
72
72
  import { _ as _55 } from "./chunks/LpSegmented-C09EEam2.js";
73
73
  import { _ as _56 } from "./chunks/LpSection-tKEH0jYf.js";
74
74
  import { _ as _57 } from "./chunks/LpSelect-DNIL6MB9.js";
75
75
  import { _ as _58 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
76
76
  import { _ as _59 } from "./chunks/LpShift-DXNsauQZ.js";
77
77
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
78
- import { _ as _60 } from "./chunks/LpSidebar-Bwws_X29.js";
78
+ import { _ as _60 } from "./chunks/LpSidebar-DpEiGi8L.js";
79
79
  import { _ as _61 } from "./chunks/LpSkeleton-BB6XIV-w.js";
80
80
  import { _ as _62 } from "./chunks/LpSlider-Cuzmrd7p.js";
81
81
  import { _ as _63 } from "./chunks/LpSparkline-CB80N8SJ.js";
@@ -89,7 +89,7 @@ import { _ as _68 } from "./chunks/LpTabs-29iW1XOx.js";
89
89
  import { _ as _69 } from "./chunks/LpTagsInput-D1SuqbhB.js";
90
90
  import { addTags, splitTags } from "./components/tags.js";
91
91
  import { _ as _70 } from "./chunks/LpTextarea-uuScWo6-.js";
92
- import { _ as _71 } from "./chunks/LpThemeSwitcher-B4aVPbNC.js";
92
+ import { _ as _71 } from "./chunks/LpThemeSwitcher-BbYFFT5w.js";
93
93
  import { _ as _72 } from "./chunks/LpTilt-B7KHX1zH.js";
94
94
  import { _ as _73 } from "./chunks/LpToaster-DTtWuKvq.js";
95
95
  import { _ as _74 } from "./chunks/LpTooltip-CXVKcOXT.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.45.1",
3
+ "version": "0.46.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -594,6 +594,10 @@ const grouped = computed<{ name: string; items: Command[] }[]>(() => {
594
594
  */
595
595
  const expanded = ref(new Set<string>())
596
596
  const moreOf = new WeakMap<Command, string>()
597
+ // The first row each "Show more" hides. Unfolding removes the "Show more" row
598
+ // itself, so the highlight has to be handed to this one: an id that is gone
599
+ // from the list sends the selection back to the top, and the list with it.
600
+ const firstHidden = new WeakMap<Command, string>()
597
601
 
598
602
  /*
599
603
  * Folding hides at least two rows: a "Show 1 more" row takes the same space
@@ -609,6 +613,7 @@ function folded(name: string, items: Command[]): Command[] {
609
613
  keepOpen: true,
610
614
  }
611
615
  moreOf.set(more, name)
616
+ firstHidden.set(more, items[limit].id)
612
617
  return [...items.slice(0, limit), more]
613
618
  }
614
619
 
@@ -822,6 +827,7 @@ function run(c: Command | undefined) {
822
827
  // was hidden: the arrows carry on from where the fold was.
823
828
  const group = moreOf.get(c)
824
829
  if (group !== undefined) {
830
+ selectedId.value = firstHidden.get(c) ?? null
825
831
  expanded.value = new Set([...expanded.value, group])
826
832
  inputRef.value?.focus()
827
833
  return
@@ -9,6 +9,7 @@
9
9
  import { ContextMenuContent, ContextMenuPortal, ContextMenuRoot, ContextMenuTrigger } from "reka-ui"
10
10
  import { computed } from "vue"
11
11
  import ContextMenuItems from "./ContextMenuItems.vue"
12
+ import MenuTitle from "./MenuTitle.vue"
12
13
  import { POPOVER_PANEL } from "./dropdown"
13
14
 
14
15
  export interface ContextMenuItemDef {
@@ -40,6 +41,17 @@ const props = defineProps<{
40
41
  * native-menu mode on that first click and swallow it.
41
42
  */
42
43
  always?: boolean
44
+ /**
45
+ * What the menu acts on, shown as a heading above the items: the row's name
46
+ * in a list of similar rows, so the user sees which one the right-click hit.
47
+ * Also names the menu for assistive tech.
48
+ */
49
+ title?: string
50
+ /** A muted second line under `title`, e.g. a version or a path. */
51
+ subtitle?: string
52
+ /** An icon beside the title, or `titleImage` for a picture (an avatar, a logo). */
53
+ titleIcon?: string
54
+ titleImage?: string
43
55
  }>()
44
56
 
45
57
  // With no items the trigger is disabled, so the element keeps the browser's
@@ -87,7 +99,13 @@ function returnFocus(event: Event) {
87
99
  <slot />
88
100
  </ContextMenuTrigger>
89
101
  <ContextMenuPortal v-if="items.length">
90
- <ContextMenuContent :collision-padding="8" :class="PANEL" @close-auto-focus="returnFocus">
102
+ <ContextMenuContent
103
+ :collision-padding="8"
104
+ :class="PANEL"
105
+ :aria-label="title"
106
+ @close-auto-focus="returnFocus"
107
+ >
108
+ <MenuTitle v-if="title" :title="title" :subtitle="subtitle" :icon="titleIcon" :image="titleImage" />
91
109
  <ContextMenuItems :items="items" :panel="PANEL" />
92
110
  </ContextMenuContent>
93
111
  </ContextMenuPortal>
@@ -9,16 +9,25 @@ import {
9
9
  } from "reka-ui"
10
10
  import { POPOVER_PANEL } from "./dropdown"
11
11
  import LpIcon from "./LpIcon.vue"
12
+ import MenuTitle from "./MenuTitle.vue"
12
13
 
13
14
  export interface MenuItem {
14
15
  label: string
15
16
  icon?: string
16
17
  danger?: boolean
17
18
  separatorBefore?: boolean
19
+ disabled?: boolean
18
20
  onSelect?: () => void
19
21
  }
20
22
 
21
- defineProps<{ items: MenuItem[] }>()
23
+ defineProps<{
24
+ items: MenuItem[]
25
+ /** A heading naming what the menu acts on; see LpContextMenu's `title`. */
26
+ title?: string
27
+ subtitle?: string
28
+ titleIcon?: string
29
+ titleImage?: string
30
+ }>()
22
31
  </script>
23
32
 
24
33
  <template>
@@ -32,12 +41,15 @@ defineProps<{ items: MenuItem[] }>()
32
41
  :side-offset="6"
33
42
  align="end"
34
43
  :class="[POPOVER_PANEL, 'z-(--z-popover) min-w-44 p-1 outline-none']"
44
+ :aria-label="title"
35
45
  >
46
+ <MenuTitle v-if="title" :title="title" :subtitle="subtitle" :icon="titleIcon" :image="titleImage" />
36
47
  <template v-for="(item, i) in items" :key="i">
37
48
  <DropdownMenuSeparator v-if="item.separatorBefore" class="my-1 h-px bg-line" />
38
49
  <DropdownMenuItem
39
- class="group/item flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[highlighted]:bg-brand-soft"
50
+ class="group/item flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-brand-soft"
40
51
  :class="item.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand'"
52
+ :disabled="item.disabled"
41
53
  @select="item.onSelect?.()"
42
54
  >
43
55
  <LpIcon
@@ -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
 
@@ -332,7 +332,7 @@ watch(
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
@@ -211,6 +211,10 @@ const railPanel = computed(() => [
211
211
  </div>
212
212
  </template>
213
213
 
214
+ <template v-if="$slots.actions" #actions>
215
+ <slot name="actions" />
216
+ </template>
217
+
214
218
  <slot />
215
219
 
216
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
  >
@@ -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)