@leavepulse/ui 0.31.1 → 0.33.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 (159) 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-oLqyjOln.js} +30 -7
  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-Cid7PGYx.js +214 -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 +80 -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/components/lightbox.d.ts +10 -0
  116. package/dist/composables/useMergedClass.d.ts +20 -0
  117. package/dist/composables/useMergedClass.js +11 -0
  118. package/dist/index.d.ts +2 -0
  119. package/dist/index.js +95 -89
  120. package/package.json +1 -3
  121. package/src/component-names.ts +1 -0
  122. package/src/components/LpAddressInput.vue +16 -1
  123. package/src/components/LpAppShell.vue +16 -1
  124. package/src/components/LpBreadcrumbs.vue +16 -1
  125. package/src/components/LpCalendar.vue +15 -1
  126. package/src/components/LpCard.vue +50 -5
  127. package/src/components/LpChart.vue +16 -1
  128. package/src/components/LpCheckbox.vue +16 -1
  129. package/src/components/LpCodeBlock.vue +16 -1
  130. package/src/components/LpDisclosure.vue +15 -1
  131. package/src/components/LpDropzone.vue +21 -6
  132. package/src/components/LpEmptyState.vue +21 -3
  133. package/src/components/LpFileTree.vue +16 -1
  134. package/src/components/LpFileTreeNode.vue +15 -1
  135. package/src/components/LpFormField.vue +16 -1
  136. package/src/components/LpImageEditor.vue +16 -1
  137. package/src/components/LpLightbox.vue +68 -3
  138. package/src/components/LpLink.vue +25 -9
  139. package/src/components/LpLogViewer.vue +20 -2
  140. package/src/components/LpMapPicker.vue +15 -1
  141. package/src/components/LpMediaGallery.vue +365 -0
  142. package/src/components/LpNumberField.vue +15 -1
  143. package/src/components/LpOtpInput.vue +15 -1
  144. package/src/components/LpPagination.vue +16 -1
  145. package/src/components/LpPhoneInput.vue +23 -8
  146. package/src/components/LpPie.vue +16 -1
  147. package/src/components/LpRadio.vue +20 -3
  148. package/src/components/LpRadioGroup.vue +15 -1
  149. package/src/components/LpSegmented.vue +21 -2
  150. package/src/components/LpSidebarNav.vue +16 -1
  151. package/src/components/LpSlider.vue +15 -1
  152. package/src/components/LpStat.vue +16 -1
  153. package/src/components/LpStepper.vue +16 -1
  154. package/src/components/LpSwitch.vue +15 -1
  155. package/src/components/LpUptimeBar.vue +16 -1
  156. package/src/components/lightbox.ts +10 -0
  157. package/src/composables/useMergedClass.ts +30 -0
  158. package/src/index.ts +2 -0
  159. package/src/tokens/tokens.css +29 -0
@@ -0,0 +1,365 @@
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
+ * An item marked `spoiler` arrives covered and takes a click to uncover. The
24
+ * cover is per image and the reveal is remembered per image, because a set is
25
+ * usually mixed — one marked screenshot among plain ones.
26
+ */
27
+ import { computed, ref } from "vue"
28
+ import type { LightboxItem } from "./lightbox"
29
+ import { useMergedAttrs } from "../composables/useMergedClass"
30
+ import LpIcon from "./LpIcon.vue"
31
+ import LpLightbox from "./LpLightbox.vue"
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ /** Images in the set. The same shape LpLightbox takes. */
36
+ items: LightboxItem[]
37
+ /**
38
+ * Thumbnails per row. A number is that many at every width; an object is
39
+ * per breakpoint, e.g. `{ base: 2, md: 3, lg: 4 }`.
40
+ */
41
+ columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }
42
+ /** Space between thumbnails, as a Tailwind gap step. */
43
+ gap?: 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4
44
+ /** Shape of each cell. `auto` lets the image decide its own height. */
45
+ aspect?: "square" | "video" | "auto"
46
+ /**
47
+ * Show at most this many, with the remainder as a "+N" badge over the last
48
+ * visible one — the usual treatment for a set too big for the space it has.
49
+ * Clicking the badge opens the lightbox at the first hidden image, because
50
+ * that is the one the count is about.
51
+ */
52
+ max?: number
53
+ /** Open the lightbox on click. Off makes the grid a pure display. */
54
+ openable?: boolean
55
+ /** Passed through to the lightbox. */
56
+ downloadable?: boolean
57
+ /** Passed through to the lightbox. */
58
+ rotatable?: boolean
59
+ /** Accessible name for the grid. */
60
+ ariaLabel?: string
61
+ }>(),
62
+ {
63
+ columns: 3,
64
+ gap: 2,
65
+ aspect: "square",
66
+ openable: true,
67
+ downloadable: true,
68
+ rotatable: true,
69
+ ariaLabel: "Media",
70
+ },
71
+ )
72
+
73
+ const emit = defineEmits<{
74
+ /** A thumbnail was activated, before the lightbox opens. */
75
+ (e: "select", item: LightboxItem, index: number): void
76
+ /** A spoiler was uncovered. */
77
+ (e: "reveal", item: LightboxItem): void
78
+ (e: "update:open", value: boolean): void
79
+ }>()
80
+
81
+ defineSlots<{
82
+ /** Replace a cell's contents. Gets the item and its index. */
83
+ item(props: { item: LightboxItem; index: number }): unknown
84
+ /** Shown instead of the grid when `items` is empty. */
85
+ empty(): unknown
86
+ }>()
87
+
88
+ const open = ref(false)
89
+ const index = ref(0)
90
+
91
+ // `max` counts what is SHOWN, so the badge reads "+N more" rather than "N
92
+ // total". A set of 7 capped at 4 shows 4 cells, the last one badged +3.
93
+ const visible = computed(() =>
94
+ props.max && props.items.length > props.max ? props.items.slice(0, props.max) : props.items,
95
+ )
96
+ const hiddenCount = computed(() => Math.max(props.items.length - visible.value.length, 0))
97
+
98
+ /*
99
+ * Column classes are written out rather than interpolated. Tailwind scans
100
+ * source text for complete class names, so `grid-cols-${n}` compiles to
101
+ * nothing — the class exists in the DOM and never in the stylesheet. This is
102
+ * the same reason the kit's other size maps are written as literals.
103
+ */
104
+ const COLS: Record<number, string> = {
105
+ 1: "grid-cols-1",
106
+ 2: "grid-cols-2",
107
+ 3: "grid-cols-3",
108
+ 4: "grid-cols-4",
109
+ 5: "grid-cols-5",
110
+ 6: "grid-cols-6",
111
+ }
112
+ const COLS_SM: Record<number, string> = {
113
+ 1: "sm:grid-cols-1",
114
+ 2: "sm:grid-cols-2",
115
+ 3: "sm:grid-cols-3",
116
+ 4: "sm:grid-cols-4",
117
+ 5: "sm:grid-cols-5",
118
+ 6: "sm:grid-cols-6",
119
+ }
120
+ const COLS_MD: Record<number, string> = {
121
+ 1: "md:grid-cols-1",
122
+ 2: "md:grid-cols-2",
123
+ 3: "md:grid-cols-3",
124
+ 4: "md:grid-cols-4",
125
+ 5: "md:grid-cols-5",
126
+ 6: "md:grid-cols-6",
127
+ }
128
+ const COLS_LG: Record<number, string> = {
129
+ 1: "lg:grid-cols-1",
130
+ 2: "lg:grid-cols-2",
131
+ 3: "lg:grid-cols-3",
132
+ 4: "lg:grid-cols-4",
133
+ 5: "lg:grid-cols-5",
134
+ 6: "lg:grid-cols-6",
135
+ }
136
+ const COLS_XL: Record<number, string> = {
137
+ 1: "xl:grid-cols-1",
138
+ 2: "xl:grid-cols-2",
139
+ 3: "xl:grid-cols-3",
140
+ 4: "xl:grid-cols-4",
141
+ 5: "xl:grid-cols-5",
142
+ 6: "xl:grid-cols-6",
143
+ }
144
+
145
+ const GAPS: Record<number, string> = {
146
+ 0: "gap-0",
147
+ 0.5: "gap-0.5",
148
+ 1: "gap-1",
149
+ 1.5: "gap-1.5",
150
+ 2: "gap-2",
151
+ 3: "gap-3",
152
+ 4: "gap-4",
153
+ }
154
+
155
+ const ASPECTS = {
156
+ square: "aspect-square",
157
+ video: "aspect-video",
158
+ auto: "",
159
+ } as const
160
+
161
+ const columnClass = computed(() => {
162
+ const c = props.columns
163
+ if (typeof c === "number") return COLS[c] ?? COLS[3]!
164
+ return [
165
+ COLS[c.base ?? 1] ?? COLS[1],
166
+ c.sm ? COLS_SM[c.sm] : "",
167
+ c.md ? COLS_MD[c.md] : "",
168
+ c.lg ? COLS_LG[c.lg] : "",
169
+ c.xl ? COLS_XL[c.xl] : "",
170
+ ]
171
+ .filter(Boolean)
172
+ .join(" ")
173
+ })
174
+
175
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
176
+ ["grid", columnClass.value, GAPS[props.gap] ?? GAPS[2]].join(" "),
177
+ )
178
+
179
+ function activate(i: number) {
180
+ const item = props.items[i]
181
+ if (!item) return
182
+ // `select` reports the position in the caller's own array, which is the one
183
+ // they can do anything with — the lightbox's filtered index is internal.
184
+ emit("select", item, i)
185
+ if (!props.openable) return
186
+ // Covered images are not in the lightbox's list, so the index has to be
187
+ // translated into it.
188
+ const at = openIndexOf(i)
189
+ if (at === -1) return
190
+ index.value = at
191
+ open.value = true
192
+ emit("update:open", true)
193
+ }
194
+
195
+ /** The badge is about the FIRST hidden image, so that is where it opens. */
196
+ function openOverflow() {
197
+ activate(visible.value.length)
198
+ }
199
+
200
+ /*
201
+ * Which spoilers have been opened.
202
+ *
203
+ * Keyed by `src` rather than by index, because the index is not a name: a set
204
+ * that gains an image at the front, or is filtered, shifts every position under
205
+ * the cursor, and a grid keyed by index would then show the wrong picture as
206
+ * already-revealed — which for a spoiler is the one failure that matters.
207
+ */
208
+ const revealed = ref(new Set<string>())
209
+
210
+ function isHidden(item: LightboxItem): boolean {
211
+ return Boolean(item.spoiler) && !revealed.value.has(item.src)
212
+ }
213
+
214
+ /*
215
+ * A covered tile takes its click to uncover itself and STOPS there — it does
216
+ * not also open the lightbox. Revealing and viewing are two separate requests,
217
+ * and running them together puts a picture nobody has agreed to see yet
218
+ * full-screen on the first click. The second click opens it like any other.
219
+ */
220
+ function reveal(item: LightboxItem) {
221
+ // A new Set, not .add(): a Set mutated in place is the same object, so a
222
+ // computed reading it never sees a change.
223
+ revealed.value = new Set(revealed.value).add(item.src)
224
+ emit("reveal", item)
225
+ }
226
+
227
+ /*
228
+ * The lightbox never receives a covered image. Paging through the set with the
229
+ * arrow keys would otherwise walk straight into one full-screen, having asked
230
+ * nobody — the grid's cover would have been for nothing. Items already revealed
231
+ * pass through untouched, so opening the one you just uncovered still lands on
232
+ * it.
233
+ */
234
+ const openItems = computed(() => props.items.filter((item) => !isHidden(item)))
235
+
236
+ /** Index within `openItems` of the grid item at `i`, or -1 when it is covered. */
237
+ function openIndexOf(i: number): number {
238
+ const item = props.items[i]
239
+ return item ? openItems.value.indexOf(item) : -1
240
+ }
241
+
242
+ defineExpose({
243
+ /** Open the lightbox at an index — for a "view all" button outside the grid. */
244
+ openAt: (i: number) => activate(i),
245
+ /** Uncover every spoiler in the set. */
246
+ revealAll: () => {
247
+ const next = new Set(revealed.value)
248
+ for (const item of props.items) if (item.spoiler) next.add(item.src)
249
+ revealed.value = next
250
+ },
251
+ })
252
+ </script>
253
+
254
+ <template>
255
+ <div v-if="!items.length">
256
+ <slot name="empty" />
257
+ </div>
258
+
259
+ <div v-else role="group" :aria-label="ariaLabel" :class="rootClass" v-bind="rest">
260
+ <button
261
+ v-for="(item, i) in visible"
262
+ :key="item.src + i"
263
+ type="button"
264
+ :class="[
265
+ 'group relative overflow-hidden rounded-card bg-surface-soft outline-none',
266
+ ASPECTS[aspect],
267
+ openable ? 'cursor-pointer' : 'cursor-default',
268
+ 'focus-visible:ring-2 focus-visible:ring-ring',
269
+ ]"
270
+ :aria-label="
271
+ isHidden(item)
272
+ ? 'Spoiler — show image'
273
+ : hiddenCount && i === visible.length - 1
274
+ ? `Show ${hiddenCount} more`
275
+ : item.title || `Image ${i + 1} of ${items.length}`
276
+ "
277
+ @click="
278
+ isHidden(item)
279
+ ? reveal(item)
280
+ : hiddenCount && i === visible.length - 1
281
+ ? openOverflow()
282
+ : activate(i)
283
+ "
284
+ >
285
+ <slot name="item" :item="item" :index="i">
286
+ <!-- decoding=async + loading=lazy: a gallery is often far down a page,
287
+ and a grid of full-size sources is the difference between a page
288
+ that settles and one that fetches a megabyte per cell on load.
289
+
290
+ On the overflow cell the image carries the blur and the dimming
291
+ itself instead of wearing a scrim — see the "+N" span below for
292
+ why. `scale-[1.06]` goes with the blur: blurring samples past the
293
+ image's own edge, so its outermost pixels fade toward transparent
294
+ and the tile gets a soft translucent rim. Growing the image a
295
+ little pushes that feathered edge outside the clip. -->
296
+ <img
297
+ :src="item.thumb || item.src"
298
+ :alt="isHidden(item) ? '' : item.title || ''"
299
+ loading="lazy"
300
+ decoding="async"
301
+ 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"
302
+ :class="[
303
+ isHidden(item)
304
+ ? 'scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]'
305
+ : '',
306
+ !isHidden(item) && hiddenCount && i === visible.length - 1
307
+ ? 'scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]'
308
+ : '',
309
+ ]"
310
+ >
311
+ </slot>
312
+
313
+ <!-- The overflow count on the LAST visible cell. Part of that cell's
314
+ button rather than a sibling, so the tile stays one tab stop and one
315
+ hit target.
316
+
317
+ The DARKENING is not here — it rides on the image's own filter (see
318
+ the `img` above). A scrim laid over the image as its own layer is the
319
+ bug LpColorPicker's s/v square documents: a rounded clip is
320
+ antialiased, so the pixels along each corner's arc get only partial
321
+ coverage of the overlay, and the UNDARKENED image shows through the
322
+ rest of the way — a bright fringe tracing the radius, faint at 100%
323
+ and obvious zoomed. Dimming the image itself means the corner clips a
324
+ finished pixel, which fades toward transparent instead of toward
325
+ something that should never be on show. -->
326
+ <!-- A covered tile says so in words. The blur alone is ambiguous — a
327
+ heavily blurred photograph reads as one that failed to load, and the
328
+ label is the difference between "broken" and "click to see". -->
329
+ <span
330
+ v-if="isHidden(item)"
331
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
332
+ >
333
+ <span
334
+ class="rounded-pill bg-black/55 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white"
335
+ >
336
+ Spoiler
337
+ </span>
338
+ </span>
339
+
340
+ <span
341
+ v-else-if="hiddenCount && i === visible.length - 1"
342
+ class="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
343
+ >
344
+ +{{ hiddenCount }}
345
+ </span>
346
+
347
+ <span
348
+ v-else-if="openable"
349
+ 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"
350
+ aria-hidden="true"
351
+ >
352
+ <LpIcon name="lucide:maximize-2" :size="18" class="text-white" />
353
+ </span>
354
+ </button>
355
+
356
+ <LpLightbox
357
+ v-if="openable"
358
+ v-model:open="open"
359
+ v-model:index="index"
360
+ :items="openItems"
361
+ :downloadable="downloadable"
362
+ :rotatable="rotatable"
363
+ />
364
+ </div>
365
+ </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>