@leavepulse/ui 0.31.1 → 0.32.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 (157) hide show
  1. package/dist/assets/{LpDropzone-DVDC-ABU.css → LpDropzone-BBoWvi-J.css} +12 -12
  2. package/dist/assets/{LpImageEditor-DqP5JKCy.css → LpImageEditor-BsSwej47.css} +6 -6
  3. package/dist/chunks/{LpAddressInput-BDkp3-Bp.js → LpAddressInput-C35TsJXt.js} +19 -14
  4. package/dist/chunks/{LpAppShell-Chz8Vbtu.js → LpAppShell-lVvpENMH.js} +18 -13
  5. package/dist/chunks/{LpBreadcrumbs-5uvViCXi.js → LpBreadcrumbs-p4jHe_V-.js} +9 -7
  6. package/dist/chunks/{LpCalendar-B7ZL0ty3.js → LpCalendar-B7Ko5RdR.js} +12 -7
  7. package/dist/chunks/{LpCard-D0M2ZCnB.js → LpCard-1SeUYPBT.js} +36 -9
  8. package/dist/chunks/{LpCheckbox-BO0zuuPh.js → LpCheckbox-CVccKYaH.js} +9 -4
  9. package/dist/chunks/{LpCodeBlock-BWX1KZwk.js → LpCodeBlock-Bo0B4TFR.js} +37 -32
  10. package/dist/chunks/{LpDatePicker-dY2q4dr4.js → LpDatePicker-6JFXllj5.js} +1 -1
  11. package/dist/chunks/{LpDisclosure-B4kVAWfg.js → LpDisclosure-y7wDBgLa.js} +11 -6
  12. package/dist/chunks/{LpEmptyState-BV3q7Qyi.js → LpEmptyState-Dsdqz1Wj.js} +13 -5
  13. package/dist/chunks/{LpFileTree-DfFqY__1.js → LpFileTree-C-NFRT0H.js} +19 -14
  14. package/dist/chunks/{LpFileTreeNode-COchUMNn.js → LpFileTreeNode-DNJUJiiB.js} +13 -6
  15. package/dist/chunks/{LpFormField-fV_2ZdCk.js → LpFormField-CazBc2-a.js} +14 -9
  16. package/dist/chunks/{LpInput-Bt4J71xn.js → LpInput-9EXWUM9B.js} +1 -1
  17. package/dist/chunks/{LpLightbox-Ci35Y_xP.js → LpLightbox-Cixfxezw.js} +29 -6
  18. package/dist/chunks/{LpLink-BHZ1CLlF.js → LpLink-DCND-zyu.js} +16 -9
  19. package/dist/chunks/{LpLogViewer-TSRyt3t9.js → LpLogViewer-y53ZqSVq.js} +20 -10
  20. package/dist/chunks/{LpMapPicker-CXvoTrAe.js → LpMapPicker-ul4oYno6.js} +14 -9
  21. package/dist/chunks/LpMediaGallery-t1z3bQcY.js +187 -0
  22. package/dist/chunks/{LpNotificationBell-gdvQY89m.js → LpNotificationBell-lHePf89W.js} +1 -1
  23. package/dist/chunks/{LpNumberField-NPE8Bumx.js → LpNumberField-CWuX08iX.js} +12 -5
  24. package/dist/chunks/{LpOtpInput-DqaT0Z75.js → LpOtpInput-y2v-pmiN.js} +11 -6
  25. package/dist/chunks/{LpPagination-CeGuNe_q.js → LpPagination-5FxjmmB5.js} +21 -19
  26. package/dist/chunks/{LpPasswordInput-BqMAc5HA.js → LpPasswordInput--sjYCkLS.js} +1 -1
  27. package/dist/chunks/{LpPhoneInput-NXXjciJJ.js → LpPhoneInput-BDSKVl_z.js} +16 -9
  28. package/dist/chunks/{LpPie-BL1vTDSM.js → LpPie-c2-q4DJu.js} +14 -8
  29. package/dist/chunks/{LpRadio-CL-pI0_O.js → LpRadio-B5AfqTpZ.js} +13 -5
  30. package/dist/chunks/{LpRadioGroup-B4yDTmi6.js → LpRadioGroup-xPIuVDO_.js} +12 -5
  31. package/dist/chunks/{LpSegmented-BmpanyAo.js → LpSegmented-fV0Sk92p.js} +16 -6
  32. package/dist/chunks/{LpSidebar-BiWM_lDB.js → LpSidebar-DcYavim2.js} +2 -2
  33. package/dist/chunks/{LpSidebarNav-CsgeuGAu.js → LpSidebarNav-CqwvT4H8.js} +21 -16
  34. package/dist/chunks/{LpSlider-B1SF3pRV.js → LpSlider-BwNIBkAK.js} +12 -5
  35. package/dist/chunks/{LpStat-CDanpzU0.js → LpStat-CQaW8R--.js} +23 -18
  36. package/dist/chunks/{LpStepper-Wtl-6nUh.js → LpStepper-Bk-XaQYU.js} +15 -10
  37. package/dist/chunks/{LpSwitch-v6fnccSM.js → LpSwitch-xgnRU2k0.js} +12 -5
  38. package/dist/chunks/{LpTextarea-BMIOfw5l.js → LpTextarea-t7F6gv8E.js} +1 -1
  39. package/dist/chunks/{LpUptimeBar-DcQ8HikG.js → LpUptimeBar-DvAMno0S.js} +26 -21
  40. package/dist/component-names.d.ts +1 -1
  41. package/dist/components/LpAddressInput.vue.d.ts +34 -17
  42. package/dist/components/LpAddressInput.vue.js +1 -1
  43. package/dist/components/LpAppShell.vue.d.ts +65 -31
  44. package/dist/components/LpAppShell.vue.js +1 -1
  45. package/dist/components/LpBreadcrumbs.vue.d.ts +12 -6
  46. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  47. package/dist/components/LpCalendar.vue.d.ts +15 -6
  48. package/dist/components/LpCalendar.vue.js +1 -1
  49. package/dist/components/LpCard.vue.d.ts +26 -0
  50. package/dist/components/LpCard.vue.js +1 -1
  51. package/dist/components/LpChart.vue.d.ts +54 -12
  52. package/dist/components/LpChart.vue.js +15 -9
  53. package/dist/components/LpCheckbox.vue.d.ts +16 -12
  54. package/dist/components/LpCheckbox.vue.js +1 -1
  55. package/dist/components/LpCodeBlock.vue.d.ts +25 -6
  56. package/dist/components/LpCodeBlock.vue.js +1 -1
  57. package/dist/components/LpDatePicker.vue.js +1 -1
  58. package/dist/components/LpDisclosure.vue.d.ts +24 -14
  59. package/dist/components/LpDisclosure.vue.js +1 -1
  60. package/dist/components/LpDropzone.vue.d.ts +38 -14
  61. package/dist/components/LpDropzone.vue.js +17 -11
  62. package/dist/components/LpEmptyState.vue.d.ts +15 -10
  63. package/dist/components/LpEmptyState.vue.js +1 -1
  64. package/dist/components/LpFileTree.vue.d.ts +51 -31
  65. package/dist/components/LpFileTree.vue.js +1 -1
  66. package/dist/components/LpFileTreeNode.vue.d.ts +31 -16
  67. package/dist/components/LpFileTreeNode.vue.js +1 -1
  68. package/dist/components/LpFormField.vue.d.ts +14 -13
  69. package/dist/components/LpFormField.vue.js +1 -1
  70. package/dist/components/LpImageEditor.vue.d.ts +33 -10
  71. package/dist/components/LpImageEditor.vue.js +13 -7
  72. package/dist/components/LpInput.vue.js +1 -1
  73. package/dist/components/LpLightbox.vue.js +1 -1
  74. package/dist/components/LpLink.vue.d.ts +46 -11
  75. package/dist/components/LpLink.vue.js +1 -1
  76. package/dist/components/LpLogViewer.vue.d.ts +63 -6
  77. package/dist/components/LpLogViewer.vue.js +1 -1
  78. package/dist/components/LpMapPicker.vue.d.ts +25 -10
  79. package/dist/components/LpMapPicker.vue.js +1 -1
  80. package/dist/components/LpMediaGallery.vue.d.ts +76 -0
  81. package/dist/components/LpMediaGallery.vue.js +4 -0
  82. package/dist/components/LpNotificationBell.vue.js +1 -1
  83. package/dist/components/LpNumberField.vue.d.ts +12 -6
  84. package/dist/components/LpNumberField.vue.js +1 -1
  85. package/dist/components/LpOtpInput.vue.d.ts +18 -6
  86. package/dist/components/LpOtpInput.vue.js +1 -1
  87. package/dist/components/LpPagination.vue.d.ts +16 -6
  88. package/dist/components/LpPagination.vue.js +1 -1
  89. package/dist/components/LpPasswordInput.vue.js +1 -1
  90. package/dist/components/LpPhoneInput.vue.d.ts +18 -6
  91. package/dist/components/LpPhoneInput.vue.js +1 -1
  92. package/dist/components/LpPie.vue.d.ts +96 -34
  93. package/dist/components/LpPie.vue.js +1 -1
  94. package/dist/components/LpRadio.vue.d.ts +10 -10
  95. package/dist/components/LpRadio.vue.js +1 -1
  96. package/dist/components/LpRadioGroup.vue.d.ts +12 -12
  97. package/dist/components/LpRadioGroup.vue.js +1 -1
  98. package/dist/components/LpSegmented.vue.d.ts +12 -6
  99. package/dist/components/LpSegmented.vue.js +1 -1
  100. package/dist/components/LpSidebar.vue.js +1 -1
  101. package/dist/components/LpSidebarNav.vue.d.ts +28 -17
  102. package/dist/components/LpSidebarNav.vue.js +1 -1
  103. package/dist/components/LpSlider.vue.d.ts +13 -6
  104. package/dist/components/LpSlider.vue.js +1 -1
  105. package/dist/components/LpStat.vue.d.ts +35 -11
  106. package/dist/components/LpStat.vue.js +1 -1
  107. package/dist/components/LpStepper.vue.d.ts +17 -5
  108. package/dist/components/LpStepper.vue.js +1 -1
  109. package/dist/components/LpSwitch.vue.d.ts +8 -6
  110. package/dist/components/LpSwitch.vue.js +1 -1
  111. package/dist/components/LpTable.vue.js +1 -1
  112. package/dist/components/LpTextarea.vue.js +1 -1
  113. package/dist/components/LpUptimeBar.vue.d.ts +21 -5
  114. package/dist/components/LpUptimeBar.vue.js +1 -1
  115. package/dist/composables/useMergedClass.d.ts +20 -0
  116. package/dist/composables/useMergedClass.js +11 -0
  117. package/dist/index.d.ts +2 -0
  118. package/dist/index.js +95 -89
  119. package/package.json +1 -3
  120. package/src/component-names.ts +1 -0
  121. package/src/components/LpAddressInput.vue +16 -1
  122. package/src/components/LpAppShell.vue +16 -1
  123. package/src/components/LpBreadcrumbs.vue +16 -1
  124. package/src/components/LpCalendar.vue +15 -1
  125. package/src/components/LpCard.vue +50 -5
  126. package/src/components/LpChart.vue +16 -1
  127. package/src/components/LpCheckbox.vue +16 -1
  128. package/src/components/LpCodeBlock.vue +16 -1
  129. package/src/components/LpDisclosure.vue +15 -1
  130. package/src/components/LpDropzone.vue +21 -6
  131. package/src/components/LpEmptyState.vue +21 -3
  132. package/src/components/LpFileTree.vue +16 -1
  133. package/src/components/LpFileTreeNode.vue +15 -1
  134. package/src/components/LpFormField.vue +16 -1
  135. package/src/components/LpImageEditor.vue +16 -1
  136. package/src/components/LpLightbox.vue +54 -2
  137. package/src/components/LpLink.vue +25 -9
  138. package/src/components/LpLogViewer.vue +20 -2
  139. package/src/components/LpMapPicker.vue +15 -1
  140. package/src/components/LpMediaGallery.vue +280 -0
  141. package/src/components/LpNumberField.vue +15 -1
  142. package/src/components/LpOtpInput.vue +15 -1
  143. package/src/components/LpPagination.vue +16 -1
  144. package/src/components/LpPhoneInput.vue +23 -8
  145. package/src/components/LpPie.vue +16 -1
  146. package/src/components/LpRadio.vue +20 -3
  147. package/src/components/LpRadioGroup.vue +15 -1
  148. package/src/components/LpSegmented.vue +21 -2
  149. package/src/components/LpSidebarNav.vue +16 -1
  150. package/src/components/LpSlider.vue +15 -1
  151. package/src/components/LpStat.vue +16 -1
  152. package/src/components/LpStepper.vue +16 -1
  153. package/src/components/LpSwitch.vue +15 -1
  154. package/src/components/LpUptimeBar.vue +16 -1
  155. package/src/composables/useMergedClass.ts +30 -0
  156. package/src/index.ts +2 -0
  157. package/src/tokens/tokens.css +29 -0
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Collapsible "show more" section built on reka Collapsible. The trigger is a
@@ -11,6 +18,7 @@
11
18
  import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
12
19
  import { type ComponentPublicInstance, computed, getCurrentInstance, nextTick, ref } from "vue"
13
20
  import LpIcon from "./LpIcon.vue"
21
+ import { useMergedAttrs } from "../composables/useMergedClass"
14
22
 
15
23
  const props = withDefaults(
16
24
  defineProps<{
@@ -65,6 +73,11 @@ function onToggle(value: boolean) {
65
73
  })
66
74
  }
67
75
  }
76
+
77
+ // The disclosure carries a bordered control frame; stacking several into one list means removing the per-item border from the outside.
78
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
79
+ "overflow-hidden rounded-control border border-line bg-surface-soft",
80
+ )
68
81
  </script>
69
82
 
70
83
  <template>
@@ -72,7 +85,8 @@ function onToggle(value: boolean) {
72
85
  ref="root"
73
86
  :open="rootOpen"
74
87
  :default-open="defaultOpen"
75
- class="overflow-hidden rounded-control border border-line bg-surface-soft"
88
+ :class="rootClass"
89
+ v-bind="rest"
76
90
  @update:open="onToggle"
77
91
  >
78
92
  <CollapsibleTrigger
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * A place to put files: drop them, click to browse, or paste from the
@@ -17,6 +24,7 @@
17
24
  * the file dialog unreachable without a mouse.
18
25
  */
19
26
  import { onBeforeUnmount, onMounted, ref } from "vue"
27
+ import { useMergedAttrs } from "../composables/useMergedClass"
20
28
  import { useReveal } from "../composables/useReveal"
21
29
  import LpIcon from "./LpIcon.vue"
22
30
 
@@ -173,6 +181,17 @@ onMounted(() => {
173
181
  onBeforeUnmount(() => window.removeEventListener("paste", windowPaste))
174
182
 
175
183
  defineExpose({ browse: () => input.value?.click() })
184
+
185
+ // The zone is a full-width block with generous padding by default, which is
186
+ // the right shape on a page and the wrong one in a sidebar — `class="w-40"`
187
+ // and `class="py-2"` have to be able to say so. The drag/reveal state classes
188
+ // go through the same merge so there is a single class source on the root.
189
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() => [
190
+ "lp-dropzone relative flex w-full flex-col items-center gap-2 rounded-card px-6 py-8 text-center transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55",
191
+ over.value ? "bg-brand-soft" : TONES[props.tone],
192
+ over.value ? "lp-dropzone--over" : "",
193
+ props.animate && !revealed.value ? "lp-dropzone--waiting" : "",
194
+ ].join(" "))
176
195
  </script>
177
196
 
178
197
  <template>
@@ -180,12 +199,8 @@ defineExpose({ browse: () => input.value?.click() })
180
199
  ref="revealAnchor"
181
200
  type="button"
182
201
  :disabled="disabled"
183
- class="lp-dropzone relative flex w-full flex-col items-center gap-2 rounded-card px-6 py-8 text-center transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
184
- :class="[
185
- over ? 'bg-brand-soft' : TONES[tone],
186
- over ? 'lp-dropzone--over' : '',
187
- animate && !revealed ? 'lp-dropzone--waiting' : '',
188
- ]"
202
+ :class="rootClass"
203
+ v-bind="rest"
189
204
  @click="input?.click()"
190
205
  @paste="onPaste"
191
206
  @dragenter.prevent="onDragOver"
@@ -1,7 +1,15 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own rather than appended to
3
+ // it, so opt out of the automatic pass-through that would add it a second
4
+ // time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  import LpIcon from "./LpIcon.vue"
10
+ import { useMergedAttrs } from "../composables/useMergedClass"
3
11
 
4
- defineProps<{
12
+ const props = defineProps<{
5
13
  icon?: string
6
14
  title: string
7
15
  description?: string
@@ -11,12 +19,22 @@ defineProps<{
11
19
  */
12
20
  compact?: boolean
13
21
  }>()
22
+
23
+ // The empty state pads itself generously, which is right on a page and wrong
24
+ // inside a small card — `class="py-2"` has to reach the root rather than lose
25
+ // a coin toss with stylesheet order.
26
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
27
+ [
28
+ "flex flex-col items-center px-6 text-center",
29
+ props.compact ? "gap-2 py-6" : "gap-3 py-12",
30
+ ].join(" "),
31
+ )
14
32
  </script>
15
33
 
16
34
  <template>
17
35
  <div
18
- class="flex flex-col items-center px-6 text-center"
19
- :class="compact ? 'gap-2 py-6' : 'gap-3 py-12'"
36
+ :class="rootClass"
37
+ v-bind="rest"
20
38
  >
21
39
  <div
22
40
  v-if="icon"
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * File/folder tree — the kit convention for browsing a hierarchy of files so
@@ -30,6 +37,7 @@ import {
30
37
  import LpEmptyState from "./LpEmptyState.vue"
31
38
  import LpFileTreeNode from "./LpFileTreeNode.vue"
32
39
  import LpScrollArea from "./LpScrollArea.vue"
40
+ import { useMergedAttrs } from "../composables/useMergedClass"
33
41
 
34
42
  export type { FileNode } from "./fileTree"
35
43
 
@@ -476,10 +484,17 @@ function collapseAll() {
476
484
  }
477
485
 
478
486
  defineExpose({ reveal, expandAll, collapseAll, checkAll, clearChecked, summary })
487
+
488
+ // The tree fills its container; a consumer giving it a fixed height or width in a split pane must be able to say so.
489
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
490
+ "flex h-full w-full min-w-0 flex-col gap-1 overflow-hidden",
491
+ )
479
492
  </script>
480
493
 
481
494
  <template>
482
- <div v-if="loading" class="flex h-full w-full min-w-0 flex-col gap-1 overflow-hidden">
495
+ <div v-if="loading"
496
+ :class="rootClass"
497
+ v-bind="rest">
483
498
  <div
484
499
  v-for="n in skeletonRows"
485
500
  :key="n"
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * One row of LpFileTree plus its recursively rendered children. Split out so the
@@ -22,6 +29,7 @@ import {
22
29
  import LpCheckbox from "./LpCheckbox.vue"
23
30
  import LpContextMenu from "./LpContextMenu.vue"
24
31
  import LpIcon from "./LpIcon.vue"
32
+ import { useMergedAttrs } from "../composables/useMergedClass"
25
33
 
26
34
  const props = defineProps<{
27
35
  node: FileNode
@@ -146,12 +154,18 @@ function onActivate() {
146
154
  emit("select", props.node)
147
155
  if (isDir.value) emit("toggle", props.node)
148
156
  }
157
+
158
+ // A node is a list row whose min-width the consumer may need to release when the tree scrolls horizontally.
159
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
160
+ "min-w-0",
161
+ )
149
162
  </script>
150
163
 
151
164
  <template>
152
165
  <li
153
166
  role="treeitem"
154
- class="min-w-0"
167
+ :class="rootClass"
168
+ v-bind="rest"
155
169
  :aria-level="depth + 1"
156
170
  :aria-selected="isSelected"
157
171
  :aria-expanded="canExpand ? isOpen : undefined"
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Form-control wrapper: label + the control (default slot) + hint/error.
@@ -11,6 +18,7 @@
11
18
  import { Label } from "reka-ui"
12
19
  import { useId } from "vue"
13
20
  import LpShift from "./LpShift.vue"
21
+ import { useMergedAttrs } from "../composables/useMergedClass"
14
22
 
15
23
  defineProps<{
16
24
  label?: string
@@ -20,6 +28,11 @@ defineProps<{
20
28
  }>()
21
29
 
22
30
  const id = useId()
31
+
32
+ // The field stacks label and control with a fixed gap; a denser form needs that gap smaller.
33
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
34
+ "flex min-w-0 flex-col gap-1.5",
35
+ )
23
36
  </script>
24
37
 
25
38
  <template>
@@ -28,7 +41,9 @@ const id = useId()
28
41
  to `min-width: auto` and so refuse to go narrower than the widest label or
29
42
  placeholder inside them — which is how a two-column form ended up wider
30
43
  than the phone holding it. -->
31
- <div class="flex min-w-0 flex-col gap-1.5">
44
+ <div
45
+ :class="rootClass"
46
+ v-bind="rest">
32
47
  <Label v-if="label" :for="id" class="text-sm font-medium text-ink">
33
48
  {{ label }}
34
49
  <span v-if="required" class="text-danger">*</span>
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Straighten-and-crop for a picture on its way to being uploaded.
@@ -23,6 +30,7 @@ import { useZoomPan } from "../composables/useZoomPan"
23
30
  import LpButton from "./LpButton.vue"
24
31
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
25
32
  import LpIcon from "./LpIcon.vue"
33
+ import { useMergedAttrs } from "../composables/useMergedClass"
26
34
 
27
35
  const props = withDefaults(
28
36
  defineProps<{
@@ -364,10 +372,17 @@ const SHORTCUTS = [
364
372
  ] as const
365
373
 
366
374
  defineExpose({ export: exportImage, reset, edited, shortcuts: SHORTCUTS })
375
+
376
+ // The editor lays out canvas and controls on a fixed gap, which a narrow panel has to tighten.
377
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
378
+ "flex flex-col gap-3",
379
+ )
367
380
  </script>
368
381
 
369
382
  <template>
370
- <div ref="revealAnchor" class="flex flex-col gap-3">
383
+ <div ref="revealAnchor"
384
+ :class="rootClass"
385
+ v-bind="rest">
371
386
  <!-- The frame is the crop. The picture moves under it, which is the
372
387
  interaction every avatar cropper already taught people. -->
373
388
  <!-- Focusable, because everything here is otherwise a mouse gesture: a
@@ -2,7 +2,8 @@
2
2
  /*
3
3
  * Full-screen image viewer — the Discord-style lightbox: click a thumbnail, the
4
4
  * image fills the screen, arrows step through the set, the wheel zooms toward
5
- * the cursor, dragging pans, and the backdrop or Esc closes it.
5
+ * the cursor over the picture and pages through the set off it, dragging pans,
6
+ * and the backdrop or Esc closes it.
6
7
  *
7
8
  * Built on reka's Dialog so focus trapping, Esc and scroll locking come from the
8
9
  * same primitive the rest of the kit's overlays use. The zoom/pan maths lives in
@@ -114,6 +115,57 @@ function go(next: number) {
114
115
  const canPrev = computed(() => total.value > 1 && (props.loop || current.value > 0))
115
116
  const canNext = computed(() => total.value > 1 && (props.loop || current.value < total.value - 1))
116
117
 
118
+ /*
119
+ * Wheel over the PICTURE zooms; wheel anywhere else in the stage pages.
120
+ *
121
+ * The stage is mostly empty space — an image is letterboxed inside it, so the
122
+ * bands above and below a wide photo are a large part of what the pointer is
123
+ * actually over. Zooming there is a gesture aimed at nothing, while paging is
124
+ * what a set of images invites. Over the image itself the old behaviour stands:
125
+ * that is where "look closer" is unambiguous.
126
+ *
127
+ * A zoomed-in image is the exception. Once it is scaled up, the wheel is the
128
+ * only way back out, and the picture no longer fits its own box — so while
129
+ * zoomed, the whole stage zooms rather than stranding the viewer at 4x if they
130
+ * drift off the edge.
131
+ */
132
+ const wheelAccum = ref(0)
133
+ let wheelTimer: ReturnType<typeof setTimeout> | undefined
134
+
135
+ /* A mouse notch arrives as one large delta; a trackpad sends a stream of small
136
+ * ones. Stepping per event would fling a flick through the whole set, so the
137
+ * deltas accumulate and a step is taken per threshold's worth of travel. */
138
+ const WHEEL_STEP = 60
139
+
140
+ function onStageWheel(e: WheelEvent) {
141
+ const overImage = (e.target as HTMLElement | null)?.closest("[data-lightbox-image]")
142
+ if (overImage || isZoomed.value) {
143
+ zoom.onWheel(e)
144
+ return
145
+ }
146
+ if (total.value < 2) return
147
+
148
+ // The stage does not scroll, so this only stops the PAGE behind the dialog
149
+ // from taking the gesture.
150
+ e.preventDefault()
151
+
152
+ wheelAccum.value += e.deltaY
153
+ // Idle between gestures clears the remainder: a half-step left over from one
154
+ // flick must not add itself to the next one minutes later.
155
+ clearTimeout(wheelTimer)
156
+ wheelTimer = setTimeout(() => (wheelAccum.value = 0), 200)
157
+
158
+ while (Math.abs(wheelAccum.value) >= WHEEL_STEP) {
159
+ const dir = wheelAccum.value > 0 ? 1 : -1
160
+ wheelAccum.value -= dir * WHEEL_STEP
161
+ if (dir > 0 ? !canNext.value : !canPrev.value) {
162
+ wheelAccum.value = 0
163
+ break
164
+ }
165
+ go(current.value + dir)
166
+ }
167
+ }
168
+
117
169
  // Reset the view whenever the image changes — carrying a 4x zoom onto the next
118
170
  // picture means landing on a random crop of it.
119
171
  watch(
@@ -336,7 +388,7 @@ const ARROW =
336
388
  ref="viewport"
337
389
  class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden"
338
390
  :class="isPanning ? 'cursor-grabbing' : isZoomed ? 'cursor-grab' : ''"
339
- @wheel="zoom.onWheel"
391
+ @wheel="onStageWheel"
340
392
  @pointerdown="onPointerDown"
341
393
  @pointermove="zoom.onPointerMove"
342
394
  @pointerup="onPointerUp"
@@ -1,5 +1,13 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own rather than appended to
3
+ // it, so opt out of the automatic pass-through that would add it a second
4
+ // time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  import { computed, type Component } from "vue"
10
+ import { useMergedAttrs } from "../composables/useMergedClass"
3
11
 
4
12
  const props = withDefaults(
5
13
  defineProps<{
@@ -94,6 +102,21 @@ const REVEAL_TRIGGER = {
94
102
  const revealClass = computed(
95
103
  () => `${REVEAL_BASE} ${REVEAL_TRIGGER[props.revealOn]}`,
96
104
  )
105
+
106
+ // A link is inline text: the gap to its icon and the underline treatment are
107
+ // the parts a consumer restyles, and `class="gap-0"` or `class="no-underline"`
108
+ // must beat the default rather than depend on stylesheet order.
109
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
110
+ [
111
+ "inline-flex items-center gap-1 underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
112
+ colorClass.value,
113
+ props.variant === "reveal"
114
+ ? revealClass.value
115
+ : props.variant === "underline"
116
+ ? "underline decoration-current/40 hover:decoration-current"
117
+ : "hover:underline",
118
+ ].join(" "),
119
+ )
97
120
  </script>
98
121
 
99
122
  <template>
@@ -102,15 +125,8 @@ const revealClass = computed(
102
125
  :href="isAnchor ? href : undefined"
103
126
  :target="target"
104
127
  :rel="rel"
105
- class="inline-flex items-center gap-1 underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
106
- :class="[
107
- colorClass,
108
- variant === 'reveal'
109
- ? revealClass
110
- : variant === 'underline'
111
- ? 'underline decoration-current/40 hover:decoration-current'
112
- : 'hover:underline',
113
- ]"
128
+ :class="rootClass"
129
+ v-bind="rest"
114
130
  >
115
131
  <slot />
116
132
  </component>
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own rather than appended to
3
+ // it, so opt out of the automatic pass-through that would add it a second
4
+ // time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Terminal-flavoured log stream. Monospace rows, a tonal level gutter, optional
@@ -23,6 +30,7 @@ import { useHotkeys } from "../composables/useHotkeys"
23
30
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
24
31
  import LpIcon from "./LpIcon.vue"
25
32
  import LpScrollArea from "./LpScrollArea.vue"
33
+ import { useMergedAttrs } from "../composables/useMergedClass"
26
34
 
27
35
  export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "success"
28
36
 
@@ -632,14 +640,24 @@ const transitionProps = {
632
640
  "leave-to-class": "max-h-0 -translate-y-1 opacity-0",
633
641
  "move-class": "transition-transform duration-200 ease-[var(--ease-emphasized)]",
634
642
  }
643
+
644
+ // The viewer draws its own card frame and hides its overflow; a consumer
645
+ // fitting it into a panel that already has a border, or giving it a fixed
646
+ // height, needs those to give way rather than lose to stylesheet order.
647
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
648
+ [
649
+ "relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed",
650
+ frozen.value ? "ring-1 ring-brand" : "",
651
+ ].join(" "),
652
+ )
635
653
  </script>
636
654
 
637
655
  <template>
638
656
  <div
639
657
  ref="root"
640
658
  tabindex="-1"
641
- class="relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed"
642
- :class="frozen ? 'ring-1 ring-brand' : ''"
659
+ :class="rootClass"
660
+ v-bind="rest"
643
661
  @pointerenter="hovered = true"
644
662
  @pointerleave="hovered = false"
645
663
  >
@@ -1,3 +1,10 @@
1
+ <script lang="ts">
2
+ // The consumer's `class` is merged into the root's own (below) rather than
3
+ // appended to it, so opt out of the automatic pass-through that would add it
4
+ // a second time, unmerged.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
1
8
  <script setup lang="ts">
2
9
  /*
3
10
  * Point picker on a raster tile map — drag to pan, wheel or the buttons to
@@ -17,6 +24,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
17
24
  import { prefersReducedMotion } from "../composables/easing"
18
25
  import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
19
26
  import LpIcon from "./LpIcon.vue"
27
+ import { useMergedAttrs } from "../composables/useMergedClass"
20
28
 
21
29
  const props = withDefaults(
22
30
  defineProps<{
@@ -536,11 +544,17 @@ defineExpose({ zoomIn: () => zoomBy(1), zoomOut: () => zoomBy(-1), recentre, loc
536
544
 
537
545
  const CONTROL =
538
546
  "flex size-8 items-center justify-center rounded-control bg-black/45 text-white/90 outline-none backdrop-blur-sm transition-[background-color,scale] duration-[var(--duration-fast)] hover:bg-black/65 hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
547
+
548
+ // The picker is a bordered box with a fixed frame; a consumer fitting it into a panel of their own needs the border and radius to be overridable.
549
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
550
+ "relative overflow-hidden rounded-control border border-line bg-surface-soft",
551
+ )
539
552
  </script>
540
553
 
541
554
  <template>
542
555
  <div
543
- class="relative overflow-hidden rounded-control border border-line bg-surface-soft"
556
+ :class="rootClass"
557
+ v-bind="rest"
544
558
  :style="{ height }"
545
559
  >
546
560
  <div