@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
@@ -0,0 +1,280 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A grid of thumbnails that opens into the lightbox.
4
+ *
5
+ * The pairing is the point: LpLightbox already knows how to page, zoom, rotate
6
+ * and download a set of images, but it has no way in — something has to show
7
+ * the set and say which one was clicked. That something is otherwise rewritten
8
+ * per feature, each time with its own idea of what a missing image looks like
9
+ * and whether the keyboard can reach it.
10
+ *
11
+ * Deliberately nothing but a grid. Column counts and gaps are all it decides;
12
+ * a layout where the first image is large and the next two are narrow is a
13
+ * composition, and it belongs to the feature that wants it — which can lay its
14
+ * own elements over this grid, or pass `columns` per breakpoint and be done.
15
+ * The kit's job is the part that repeats: the thumbnails, the focus ring, the
16
+ * counter on an overflowing set, and the wiring to the lightbox.
17
+ *
18
+ * Items are LightboxItem, not a shape of this component's own, so a set can go
19
+ * straight from here into the lightbox with nothing in between. `thumb` is the
20
+ * small source when one exists; without it the grid shows `src`, which is
21
+ * correct but heavier, and that is the caller's call to make.
22
+ */
23
+ import { computed, ref } from "vue"
24
+ import type { LightboxItem } from "./lightbox"
25
+ import { useMergedAttrs } from "../composables/useMergedClass"
26
+ import LpIcon from "./LpIcon.vue"
27
+ import LpLightbox from "./LpLightbox.vue"
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Images in the set. The same shape LpLightbox takes. */
32
+ items: LightboxItem[]
33
+ /**
34
+ * Thumbnails per row. A number is that many at every width; an object is
35
+ * per breakpoint, e.g. `{ base: 2, md: 3, lg: 4 }`.
36
+ */
37
+ columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }
38
+ /** Space between thumbnails, as a Tailwind gap step. */
39
+ gap?: 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4
40
+ /** Shape of each cell. `auto` lets the image decide its own height. */
41
+ aspect?: "square" | "video" | "auto"
42
+ /**
43
+ * Show at most this many, with the remainder as a "+N" badge over the last
44
+ * visible one — the usual treatment for a set too big for the space it has.
45
+ * Clicking the badge opens the lightbox at the first hidden image, because
46
+ * that is the one the count is about.
47
+ */
48
+ max?: number
49
+ /** Open the lightbox on click. Off makes the grid a pure display. */
50
+ openable?: boolean
51
+ /** Passed through to the lightbox. */
52
+ downloadable?: boolean
53
+ /** Passed through to the lightbox. */
54
+ rotatable?: boolean
55
+ /** Accessible name for the grid. */
56
+ ariaLabel?: string
57
+ }>(),
58
+ {
59
+ columns: 3,
60
+ gap: 2,
61
+ aspect: "square",
62
+ openable: true,
63
+ downloadable: true,
64
+ rotatable: true,
65
+ ariaLabel: "Media",
66
+ },
67
+ )
68
+
69
+ const emit = defineEmits<{
70
+ /** A thumbnail was activated, before the lightbox opens. */
71
+ (e: "select", item: LightboxItem, index: number): void
72
+ (e: "update:open", value: boolean): void
73
+ }>()
74
+
75
+ defineSlots<{
76
+ /** Replace a cell's contents. Gets the item and its index. */
77
+ item(props: { item: LightboxItem; index: number }): unknown
78
+ /** Shown instead of the grid when `items` is empty. */
79
+ empty(): unknown
80
+ }>()
81
+
82
+ const open = ref(false)
83
+ const index = ref(0)
84
+
85
+ // `max` counts what is SHOWN, so the badge reads "+N more" rather than "N
86
+ // total". A set of 7 capped at 4 shows 4 cells, the last one badged +3.
87
+ const visible = computed(() =>
88
+ props.max && props.items.length > props.max ? props.items.slice(0, props.max) : props.items,
89
+ )
90
+ const hiddenCount = computed(() => Math.max(props.items.length - visible.value.length, 0))
91
+
92
+ /*
93
+ * Column classes are written out rather than interpolated. Tailwind scans
94
+ * source text for complete class names, so `grid-cols-${n}` compiles to
95
+ * nothing — the class exists in the DOM and never in the stylesheet. This is
96
+ * the same reason the kit's other size maps are written as literals.
97
+ */
98
+ const COLS: Record<number, string> = {
99
+ 1: "grid-cols-1",
100
+ 2: "grid-cols-2",
101
+ 3: "grid-cols-3",
102
+ 4: "grid-cols-4",
103
+ 5: "grid-cols-5",
104
+ 6: "grid-cols-6",
105
+ }
106
+ const COLS_SM: Record<number, string> = {
107
+ 1: "sm:grid-cols-1",
108
+ 2: "sm:grid-cols-2",
109
+ 3: "sm:grid-cols-3",
110
+ 4: "sm:grid-cols-4",
111
+ 5: "sm:grid-cols-5",
112
+ 6: "sm:grid-cols-6",
113
+ }
114
+ const COLS_MD: Record<number, string> = {
115
+ 1: "md:grid-cols-1",
116
+ 2: "md:grid-cols-2",
117
+ 3: "md:grid-cols-3",
118
+ 4: "md:grid-cols-4",
119
+ 5: "md:grid-cols-5",
120
+ 6: "md:grid-cols-6",
121
+ }
122
+ const COLS_LG: Record<number, string> = {
123
+ 1: "lg:grid-cols-1",
124
+ 2: "lg:grid-cols-2",
125
+ 3: "lg:grid-cols-3",
126
+ 4: "lg:grid-cols-4",
127
+ 5: "lg:grid-cols-5",
128
+ 6: "lg:grid-cols-6",
129
+ }
130
+ const COLS_XL: Record<number, string> = {
131
+ 1: "xl:grid-cols-1",
132
+ 2: "xl:grid-cols-2",
133
+ 3: "xl:grid-cols-3",
134
+ 4: "xl:grid-cols-4",
135
+ 5: "xl:grid-cols-5",
136
+ 6: "xl:grid-cols-6",
137
+ }
138
+
139
+ const GAPS: Record<number, string> = {
140
+ 0: "gap-0",
141
+ 0.5: "gap-0.5",
142
+ 1: "gap-1",
143
+ 1.5: "gap-1.5",
144
+ 2: "gap-2",
145
+ 3: "gap-3",
146
+ 4: "gap-4",
147
+ }
148
+
149
+ const ASPECTS = {
150
+ square: "aspect-square",
151
+ video: "aspect-video",
152
+ auto: "",
153
+ } as const
154
+
155
+ const columnClass = computed(() => {
156
+ const c = props.columns
157
+ if (typeof c === "number") return COLS[c] ?? COLS[3]!
158
+ return [
159
+ COLS[c.base ?? 1] ?? COLS[1],
160
+ c.sm ? COLS_SM[c.sm] : "",
161
+ c.md ? COLS_MD[c.md] : "",
162
+ c.lg ? COLS_LG[c.lg] : "",
163
+ c.xl ? COLS_XL[c.xl] : "",
164
+ ]
165
+ .filter(Boolean)
166
+ .join(" ")
167
+ })
168
+
169
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
170
+ ["grid", columnClass.value, GAPS[props.gap] ?? GAPS[2]].join(" "),
171
+ )
172
+
173
+ function activate(i: number) {
174
+ const item = props.items[i]
175
+ if (!item) return
176
+ emit("select", item, i)
177
+ if (!props.openable) return
178
+ index.value = i
179
+ open.value = true
180
+ emit("update:open", true)
181
+ }
182
+
183
+ /** The badge is about the FIRST hidden image, so that is where it opens. */
184
+ function openOverflow() {
185
+ activate(visible.value.length)
186
+ }
187
+
188
+ defineExpose({
189
+ /** Open the lightbox at an index — for a "view all" button outside the grid. */
190
+ openAt: (i: number) => activate(i),
191
+ })
192
+ </script>
193
+
194
+ <template>
195
+ <div v-if="!items.length">
196
+ <slot name="empty" />
197
+ </div>
198
+
199
+ <div v-else role="group" :aria-label="ariaLabel" :class="rootClass" v-bind="rest">
200
+ <button
201
+ v-for="(item, i) in visible"
202
+ :key="item.src + i"
203
+ type="button"
204
+ :class="[
205
+ 'group relative overflow-hidden rounded-card bg-surface-soft outline-none',
206
+ ASPECTS[aspect],
207
+ openable ? 'cursor-pointer' : 'cursor-default',
208
+ 'focus-visible:ring-2 focus-visible:ring-ring',
209
+ ]"
210
+ :aria-label="
211
+ hiddenCount && i === visible.length - 1
212
+ ? `Show ${hiddenCount} more`
213
+ : item.title || `Image ${i + 1} of ${items.length}`
214
+ "
215
+ @click="hiddenCount && i === visible.length - 1 ? openOverflow() : activate(i)"
216
+ >
217
+ <slot name="item" :item="item" :index="i">
218
+ <!-- decoding=async + loading=lazy: a gallery is often far down a page,
219
+ and a grid of full-size sources is the difference between a page
220
+ that settles and one that fetches a megabyte per cell on load.
221
+
222
+ On the overflow cell the image carries the blur and the dimming
223
+ itself instead of wearing a scrim — see the "+N" span below for
224
+ why. `scale-[1.06]` goes with the blur: blurring samples past the
225
+ image's own edge, so its outermost pixels fade toward transparent
226
+ and the tile gets a soft translucent rim. Growing the image a
227
+ little pushes that feathered edge outside the clip. -->
228
+ <img
229
+ :src="item.thumb || item.src"
230
+ :alt="item.title || ''"
231
+ loading="lazy"
232
+ decoding="async"
233
+ class="size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
234
+ :class="
235
+ hiddenCount && i === visible.length - 1
236
+ ? 'scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]'
237
+ : ''
238
+ "
239
+ >
240
+ </slot>
241
+
242
+ <!-- The overflow count on the LAST visible cell. Part of that cell's
243
+ button rather than a sibling, so the tile stays one tab stop and one
244
+ hit target.
245
+
246
+ The DARKENING is not here — it rides on the image's own filter (see
247
+ the `img` above). A scrim laid over the image as its own layer is the
248
+ bug LpColorPicker's s/v square documents: a rounded clip is
249
+ antialiased, so the pixels along each corner's arc get only partial
250
+ coverage of the overlay, and the UNDARKENED image shows through the
251
+ rest of the way — a bright fringe tracing the radius, faint at 100%
252
+ and obvious zoomed. Dimming the image itself means the corner clips a
253
+ finished pixel, which fades toward transparent instead of toward
254
+ something that should never be on show. -->
255
+ <span
256
+ v-if="hiddenCount && i === visible.length - 1"
257
+ class="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
258
+ >
259
+ +{{ hiddenCount }}
260
+ </span>
261
+
262
+ <span
263
+ v-else-if="openable"
264
+ class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-[background-color,opacity] duration-[var(--duration-fast)] group-hover:bg-black/25 group-hover:opacity-100 motion-reduce:transition-none"
265
+ aria-hidden="true"
266
+ >
267
+ <LpIcon name="lucide:maximize-2" :size="18" class="text-white" />
268
+ </span>
269
+ </button>
270
+
271
+ <LpLightbox
272
+ v-if="openable"
273
+ v-model:open="open"
274
+ v-model:index="index"
275
+ :items="items"
276
+ :downloadable="downloadable"
277
+ :rotatable="rotatable"
278
+ />
279
+ </div>
280
+ </template>
@@ -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
  // Numeric input with stepper buttons (reka NumberField). Themed via tokens.
3
10
  import {
@@ -8,6 +15,7 @@ import {
8
15
  } from "reka-ui"
9
16
  import { useId } from "vue"
10
17
  import LpIcon from "./LpIcon.vue"
18
+ import { useMergedAttrs } from "../composables/useMergedClass"
11
19
 
12
20
  defineProps<{
13
21
  // null = empty field (reka's native state); consumers that need a number
@@ -30,6 +38,11 @@ defineEmits<{ (e: "update:modelValue", value: number | null): void }>()
30
38
  // screen the two read as one field, and without this a reader announces "380"
31
39
  // and leaves out the milliamps that make it mean anything.
32
40
  const unitId = useId()
41
+
42
+ // The field is full-width with its own control frame — the two things a consumer laying out a compact numeric column needs to change.
43
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
44
+ "flex h-(--size-control-md) w-full items-center rounded-control border border-line bg-surface-soft transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55",
45
+ )
33
46
  </script>
34
47
 
35
48
  <template>
@@ -40,7 +53,8 @@ const unitId = useId()
40
53
  :step="step"
41
54
  :disabled="disabled"
42
55
  data-lp-ring-owner
43
- class="flex h-(--size-control-md) w-full items-center rounded-control border border-line bg-surface-soft transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55"
56
+ :class="rootClass"
57
+ v-bind="rest"
44
58
  @update:model-value="(v) => $emit('update:modelValue', v ?? null)"
45
59
  >
46
60
  <NumberFieldDecrement
@@ -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-time-code input (TOTP / 2FA). A row of single-character cells with the
@@ -10,6 +17,7 @@
10
17
  */
11
18
  import { PinInputInput, PinInputRoot } from "reka-ui"
12
19
  import { computed } from "vue"
20
+ import { useMergedAttrs } from "../composables/useMergedClass"
13
21
 
14
22
  const props = withDefaults(
15
23
  defineProps<{
@@ -51,6 +59,11 @@ const cellSize = {
51
59
  md: "size-11 text-lg",
52
60
  lg: "size-13 text-xl",
53
61
  }
62
+
63
+ // The boxes sit on a fixed gap that a narrow form has to tighten.
64
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
65
+ "flex items-center gap-2",
66
+ )
54
67
  </script>
55
68
 
56
69
  <template>
@@ -60,7 +73,8 @@ const cellSize = {
60
73
  :mask="mask"
61
74
  :disabled="disabled"
62
75
  otp
63
- class="flex items-center gap-2"
76
+ :class="rootClass"
77
+ v-bind="rest"
64
78
  @complete="onComplete"
65
79
  >
66
80
  <PinInputInput
@@ -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
  * Page navigator. Pairs with LpTable but stands alone.
@@ -14,6 +21,7 @@ import { Motion } from "motion-v"
14
21
  import { computed, useId } from "vue"
15
22
  import { usePillTransition } from "../composables/usePillTransition"
16
23
  import LpIcon from "./LpIcon.vue"
24
+ import { useMergedAttrs } from "../composables/useMergedClass"
17
25
 
18
26
  const props = withDefaults(
19
27
  defineProps<{
@@ -104,10 +112,17 @@ const navArrow =
104
112
  // The sliding brand pill (shared layoutId) glides under the current page.
105
113
  const pillId = `lp-page-indicator-${useId()}`
106
114
  const pillTransition = usePillTransition()
115
+
116
+ // Pagination is a nav row with its own spacing, which a compact table footer has to tighten.
117
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
118
+ "flex items-center gap-1.5",
119
+ )
107
120
  </script>
108
121
 
109
122
  <template>
110
- <nav class="flex items-center gap-1.5" aria-label="Pagination">
123
+ <nav
124
+ :class="rootClass"
125
+ v-bind="rest" aria-label="Pagination">
111
126
  <button
112
127
  type="button"
113
128
  :class="[navBase, navArrow]"
@@ -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
  * Phone number field — a country picker (flag + dial code) fused with a free-text
@@ -25,6 +32,7 @@ import { useInputFilter } from "../composables/useInputFilter"
25
32
  import { type Country, flagEmoji, loadCountries, matchCountryByValue } from "./countries"
26
33
  import { OPTION_ITEM, POPOVER_PANEL } from "./dropdown"
27
34
  import LpIcon from "./LpIcon.vue"
35
+ import { useMergedAttrs } from "../composables/useMergedClass"
28
36
 
29
37
  export interface PhoneDetail {
30
38
  /** Matched country, or undefined when the prefix matches none. */
@@ -146,19 +154,26 @@ const sizeClass = {
146
154
  md: "h-(--size-control-md) text-sm",
147
155
  lg: "h-(--size-control-lg) text-sm",
148
156
  }
157
+
158
+ // The control is full-width with its own frame — the two things a consumer
159
+ // placing it in a narrow column has to change, so `class="w-48"` must win.
160
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
161
+ [
162
+ "flex w-full items-stretch rounded-control border bg-surface-soft text-ink transition-colors duration-[var(--duration-fast)] focus-within:ring-2 focus-within:ring-ring",
163
+ sizeClass[props.size],
164
+ props.invalid
165
+ ? "border-danger focus-within:border-danger focus-within:ring-danger-soft"
166
+ : "border-line focus-within:border-brand",
167
+ props.disabled ? "cursor-not-allowed opacity-55" : "",
168
+ ].join(" "),
169
+ )
149
170
  </script>
150
171
 
151
172
  <template>
152
173
  <div
153
174
  data-lp-ring-owner
154
- class="flex w-full items-stretch rounded-control border bg-surface-soft text-ink transition-colors duration-[var(--duration-fast)] focus-within:ring-2 focus-within:ring-ring"
155
- :class="[
156
- sizeClass[size],
157
- invalid
158
- ? 'border-danger focus-within:border-danger focus-within:ring-danger-soft'
159
- : 'border-line focus-within:border-brand',
160
- disabled ? 'cursor-not-allowed opacity-55' : '',
161
- ]"
175
+ :class="rootClass"
176
+ v-bind="rest"
162
177
  >
163
178
  <!-- reset-search-term-on-{select,blur}: as in LpSelect, the trigger shows
164
179
  the selection and the box only filters, so reka seeding the chosen value
@@ -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
  * Shares of a whole, as a pie or a donut. Same SVG-and-tokens approach as
@@ -23,6 +30,7 @@ import LpNumberFlow from "./LpNumberFlow.vue"
23
30
  import LpRollingText from "./LpRollingText.vue"
24
31
  import LpShift from "./LpShift.vue"
25
32
  import LpTooltip from "./LpTooltip.vue"
33
+ import { useMergedAttrs } from "../composables/useMergedClass"
26
34
 
27
35
  /** A slice, as reported by every interaction event. */
28
36
  export interface SliceEvent {
@@ -545,10 +553,17 @@ const centreLabel = computed(() => {
545
553
  if (selected.value.size > 1) return `${selected.value.size} selected`
546
554
  return "total"
547
555
  })
556
+
557
+ // The pie sets its own gap between chart and legend, which a narrow card has to close up.
558
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
559
+ "flex flex-col gap-3",
560
+ )
548
561
  </script>
549
562
 
550
563
  <template>
551
- <div ref="revealAnchor" class="flex flex-col gap-3">
564
+ <div ref="revealAnchor"
565
+ :class="rootClass"
566
+ v-bind="rest">
552
567
  <div v-if="title || subtitle" class="flex flex-col gap-0.5">
553
568
  <span v-if="title" class="text-sm font-medium text-ink">{{ title }}</span>
554
569
  <span v-if="subtitle" class="text-xs text-muted">{{ subtitle }}</span>
@@ -1,16 +1,33 @@
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 { RadioGroupIndicator, RadioGroupItem } from "reka-ui"
10
+ import { useMergedAttrs } from "../composables/useMergedClass"
3
11
 
4
12
  // A single radio item with a slot for rich content (icon, badges, multi-line).
5
13
  // Must be placed inside an <LpRadioGroup> (or a reka-ui RadioGroupRoot), which
6
14
  // owns the selected value. The default slot replaces the plain label.
7
- defineProps<{ value: string; label?: string; disabled?: boolean }>()
15
+ const props = defineProps<{ value: string; label?: string; disabled?: boolean }>()
16
+
17
+ // The label sits a fixed distance from the control, which a dense list of
18
+ // options needs to close up — `class="gap-1"` should win over the default.
19
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
20
+ [
21
+ "group inline-flex cursor-pointer items-center gap-2 text-sm text-ink",
22
+ props.disabled ? "cursor-not-allowed opacity-55" : "",
23
+ ].join(" "),
24
+ )
8
25
  </script>
9
26
 
10
27
  <template>
11
28
  <label
12
- class="group inline-flex cursor-pointer items-center gap-2 text-sm text-ink"
13
- :class="disabled && 'cursor-not-allowed opacity-55'"
29
+ :class="rootClass"
30
+ v-bind="rest"
14
31
  >
15
32
  <RadioGroupItem
16
33
  :value="value"
@@ -1,5 +1,13 @@
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
  import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "reka-ui"
10
+ import { useMergedAttrs } from "../composables/useMergedClass"
3
11
 
4
12
  export interface RadioOption {
5
13
  value: string
@@ -11,13 +19,19 @@ export interface RadioOption {
11
19
  // the same RadioGroupRoot, so selection still flows through v-model.
12
20
  defineProps<{ modelValue?: string; options?: RadioOption[]; disabled?: boolean }>()
13
21
  defineEmits<{ (e: "update:modelValue", value: string): void }>()
22
+
23
+ // A radio group stacks with a fixed gap; a dense settings list needs it smaller, and a horizontal layout needs it gone.
24
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
25
+ "flex flex-col gap-2",
26
+ )
14
27
  </script>
15
28
 
16
29
  <template>
17
30
  <RadioGroupRoot
18
31
  :model-value="modelValue"
19
32
  :disabled="disabled"
20
- class="flex flex-col gap-2"
33
+ :class="rootClass"
34
+ v-bind="rest"
21
35
  @update:model-value="(v) => $emit('update:modelValue', v as string)"
22
36
  >
23
37
  <slot>
@@ -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
  * Segmented control — a compact bar of mutually-exclusive options with a sliding
@@ -9,6 +16,7 @@
9
16
  import { Motion } from "motion-v"
10
17
  import { ToggleGroupItem, ToggleGroupRoot } from "reka-ui"
11
18
  import { useId } from "vue"
19
+ import { useMergedAttrs } from "../composables/useMergedClass"
12
20
  import { usePillTransition } from "../composables/usePillTransition"
13
21
  import LpIcon from "./LpIcon.vue"
14
22
 
@@ -47,6 +55,17 @@ const sizeCls = {
47
55
  md: "h-(--size-control-md) text-sm",
48
56
  }
49
57
 
58
+ // The bar sizes itself from `size` and `block`, which is right until a consumer
59
+ // needs it flush against something — `class="p-0"` and `class="rounded-none"`
60
+ // have to reach the root rather than losing a coin toss with stylesheet order.
61
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
62
+ [
63
+ "inline-flex items-center gap-1 rounded-control border border-line bg-surface-soft p-1",
64
+ sizeCls[props.size],
65
+ props.block ? "flex w-full" : "",
66
+ ].join(" "),
67
+ )
68
+
50
69
  const pillId = `lp-segmented-${useId()}`
51
70
  const pillTransition = usePillTransition()
52
71
  </script>
@@ -57,8 +76,8 @@ const pillTransition = usePillTransition()
57
76
  type="single"
58
77
  :disabled="disabled"
59
78
  :rovingFocus="true"
60
- class="inline-flex items-center gap-1 rounded-control border border-line bg-surface-soft p-1"
61
- :class="[sizeCls[size], block ? 'flex w-full' : '']"
79
+ :class="rootClass"
80
+ v-bind="rest"
62
81
  @update:model-value="onChange"
63
82
  >
64
83
  <ToggleGroupItem
@@ -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
  * Inner body of LpSidebar: the section/item list plus the loading skeleton and
@@ -14,6 +21,7 @@ import LpContextMenu from "./LpContextMenu.vue"
14
21
  import LpIcon from "./LpIcon.vue"
15
22
  import LpScrollArea from "./LpScrollArea.vue"
16
23
  import type { SidebarItem, SidebarSection } from "./sidebar"
24
+ import { useMergedAttrs } from "../composables/useMergedClass"
17
25
 
18
26
  const props = withDefaults(
19
27
  defineProps<{
@@ -66,11 +74,18 @@ function isOpen(section: SidebarSection): boolean {
66
74
  }
67
75
 
68
76
  const pillTransition = usePillTransition()
77
+
78
+ // The nav fills the remaining height with a fixed gap between groups; a short sidebar needs both under the consumer's control.
79
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
80
+ "flex min-h-0 flex-1 flex-col gap-4",
81
+ )
69
82
  </script>
70
83
 
71
84
  <template>
72
85
  <!-- Loading skeleton: an optional identity row + a run of item rows. -->
73
- <div v-if="loading" class="flex min-h-0 flex-1 flex-col gap-4">
86
+ <div v-if="loading"
87
+ :class="rootClass"
88
+ v-bind="rest">
74
89
  <div v-if="skeletonHeader" class="flex items-center gap-3 px-1">
75
90
  <div class="size-10 shrink-0 animate-pulse rounded-pill bg-surface-soft" />
76
91
  <div class="flex-1 space-y-2">