@leavepulse/ui 0.31.0 → 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.
- package/dist/assets/{LpDropzone-DVDC-ABU.css → LpDropzone-BBoWvi-J.css} +12 -12
- package/dist/assets/{LpImageEditor-DqP5JKCy.css → LpImageEditor-BsSwej47.css} +6 -6
- package/dist/chunks/{LpAddressInput-BDkp3-Bp.js → LpAddressInput-C35TsJXt.js} +19 -14
- package/dist/chunks/{LpAppShell-Chz8Vbtu.js → LpAppShell-lVvpENMH.js} +18 -13
- package/dist/chunks/{LpBreadcrumbs-5uvViCXi.js → LpBreadcrumbs-p4jHe_V-.js} +9 -7
- package/dist/chunks/{LpCalendar-B7ZL0ty3.js → LpCalendar-B7Ko5RdR.js} +12 -7
- package/dist/chunks/{LpCard-D0M2ZCnB.js → LpCard-1SeUYPBT.js} +36 -9
- package/dist/chunks/{LpCheckbox-BO0zuuPh.js → LpCheckbox-CVccKYaH.js} +9 -4
- package/dist/chunks/{LpCodeBlock-BWX1KZwk.js → LpCodeBlock-Bo0B4TFR.js} +37 -32
- package/dist/chunks/{LpDatePicker-dY2q4dr4.js → LpDatePicker-6JFXllj5.js} +1 -1
- package/dist/chunks/{LpDisclosure-B4kVAWfg.js → LpDisclosure-y7wDBgLa.js} +11 -6
- package/dist/chunks/{LpEmptyState-BV3q7Qyi.js → LpEmptyState-Dsdqz1Wj.js} +13 -5
- package/dist/chunks/{LpFileTree-DfFqY__1.js → LpFileTree-C-NFRT0H.js} +19 -14
- package/dist/chunks/{LpFileTreeNode-COchUMNn.js → LpFileTreeNode-DNJUJiiB.js} +13 -6
- package/dist/chunks/{LpFormField-fV_2ZdCk.js → LpFormField-CazBc2-a.js} +14 -9
- package/dist/chunks/{LpInput-Bt4J71xn.js → LpInput-9EXWUM9B.js} +1 -1
- package/dist/chunks/{LpLightbox-Ci35Y_xP.js → LpLightbox-Cixfxezw.js} +29 -6
- package/dist/chunks/{LpLink-BHZ1CLlF.js → LpLink-DCND-zyu.js} +16 -9
- package/dist/chunks/{LpLogViewer-TSRyt3t9.js → LpLogViewer-y53ZqSVq.js} +20 -10
- package/dist/chunks/{LpMapPicker-CXvoTrAe.js → LpMapPicker-ul4oYno6.js} +14 -9
- package/dist/chunks/LpMediaGallery-t1z3bQcY.js +187 -0
- package/dist/chunks/{LpNotificationBell-gdvQY89m.js → LpNotificationBell-lHePf89W.js} +1 -1
- package/dist/chunks/{LpNumberField-NPE8Bumx.js → LpNumberField-CWuX08iX.js} +12 -5
- package/dist/chunks/{LpOtpInput-DqaT0Z75.js → LpOtpInput-y2v-pmiN.js} +11 -6
- package/dist/chunks/{LpPagination-CeGuNe_q.js → LpPagination-5FxjmmB5.js} +21 -19
- package/dist/chunks/{LpPasswordInput-BqMAc5HA.js → LpPasswordInput--sjYCkLS.js} +1 -1
- package/dist/chunks/{LpPhoneInput-NXXjciJJ.js → LpPhoneInput-BDSKVl_z.js} +16 -9
- package/dist/chunks/{LpPie-BL1vTDSM.js → LpPie-c2-q4DJu.js} +14 -8
- package/dist/chunks/{LpRadio-CL-pI0_O.js → LpRadio-B5AfqTpZ.js} +13 -5
- package/dist/chunks/{LpRadioGroup-B4yDTmi6.js → LpRadioGroup-xPIuVDO_.js} +12 -5
- package/dist/chunks/{LpSegmented-BmpanyAo.js → LpSegmented-fV0Sk92p.js} +16 -6
- package/dist/chunks/{LpSidebar-BiWM_lDB.js → LpSidebar-DcYavim2.js} +2 -2
- package/dist/chunks/{LpSidebarNav-CsgeuGAu.js → LpSidebarNav-CqwvT4H8.js} +21 -16
- package/dist/chunks/{LpSlider-B1SF3pRV.js → LpSlider-BwNIBkAK.js} +12 -5
- package/dist/chunks/{LpStat-CDanpzU0.js → LpStat-CQaW8R--.js} +23 -18
- package/dist/chunks/{LpStepper-Wtl-6nUh.js → LpStepper-Bk-XaQYU.js} +15 -10
- package/dist/chunks/{LpSwitch-v6fnccSM.js → LpSwitch-xgnRU2k0.js} +12 -5
- package/dist/chunks/{LpTextarea-BMIOfw5l.js → LpTextarea-t7F6gv8E.js} +1 -1
- package/dist/chunks/{LpUptimeBar-DcQ8HikG.js → LpUptimeBar-DvAMno0S.js} +26 -21
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.d.ts +34 -17
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.d.ts +65 -31
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.d.ts +12 -6
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpCalendar.vue.d.ts +15 -6
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.d.ts +26 -0
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +54 -12
- package/dist/components/LpChart.vue.js +15 -9
- package/dist/components/LpCheckbox.vue.d.ts +16 -12
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.d.ts +25 -6
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.d.ts +24 -14
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +38 -14
- package/dist/components/LpDropzone.vue.js +17 -11
- package/dist/components/LpEmptyState.vue.d.ts +15 -10
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +51 -31
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.d.ts +31 -16
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.d.ts +14 -13
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.d.ts +33 -10
- package/dist/components/LpImageEditor.vue.js +13 -7
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLink.vue.d.ts +46 -11
- package/dist/components/LpLink.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +63 -6
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +25 -10
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +76 -0
- package/dist/components/LpMediaGallery.vue.js +4 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberField.vue.d.ts +12 -6
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpOtpInput.vue.d.ts +18 -6
- package/dist/components/LpOtpInput.vue.js +1 -1
- package/dist/components/LpPagination.vue.d.ts +16 -6
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.d.ts +18 -6
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.d.ts +96 -34
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpRadio.vue.d.ts +10 -10
- package/dist/components/LpRadio.vue.js +1 -1
- package/dist/components/LpRadioGroup.vue.d.ts +12 -12
- package/dist/components/LpRadioGroup.vue.js +1 -1
- package/dist/components/LpSegmented.vue.d.ts +12 -6
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.d.ts +28 -17
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSlider.vue.d.ts +13 -6
- package/dist/components/LpSlider.vue.js +1 -1
- package/dist/components/LpStat.vue.d.ts +35 -11
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStepper.vue.d.ts +17 -5
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.d.ts +8 -6
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTable.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.d.ts +21 -5
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/composables/useMergedClass.d.ts +20 -0
- package/dist/composables/useMergedClass.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +95 -89
- package/package.json +1 -3
- package/src/component-names.ts +1 -0
- package/src/components/LpAddressInput.vue +16 -1
- package/src/components/LpAppShell.vue +16 -1
- package/src/components/LpBreadcrumbs.vue +16 -1
- package/src/components/LpCalendar.vue +15 -1
- package/src/components/LpCard.vue +50 -5
- package/src/components/LpChart.vue +16 -1
- package/src/components/LpCheckbox.vue +16 -1
- package/src/components/LpCodeBlock.vue +16 -1
- package/src/components/LpDisclosure.vue +15 -1
- package/src/components/LpDropzone.vue +21 -6
- package/src/components/LpEmptyState.vue +21 -3
- package/src/components/LpFileTree.vue +16 -1
- package/src/components/LpFileTreeNode.vue +15 -1
- package/src/components/LpFormField.vue +16 -1
- package/src/components/LpImageEditor.vue +16 -1
- package/src/components/LpLightbox.vue +54 -2
- package/src/components/LpLink.vue +25 -9
- package/src/components/LpLogViewer.vue +20 -2
- package/src/components/LpMapPicker.vue +15 -1
- package/src/components/LpMediaGallery.vue +280 -0
- package/src/components/LpNumberField.vue +15 -1
- package/src/components/LpOtpInput.vue +15 -1
- package/src/components/LpPagination.vue +16 -1
- package/src/components/LpPhoneInput.vue +23 -8
- package/src/components/LpPie.vue +16 -1
- package/src/components/LpRadio.vue +20 -3
- package/src/components/LpRadioGroup.vue +15 -1
- package/src/components/LpSegmented.vue +21 -2
- package/src/components/LpSidebarNav.vue +16 -1
- package/src/components/LpSlider.vue +15 -1
- package/src/components/LpStat.vue +16 -1
- package/src/components/LpStepper.vue +16 -1
- package/src/components/LpSwitch.vue +15 -1
- package/src/components/LpUptimeBar.vue +16 -1
- package/src/composables/useMergedClass.ts +30 -0
- package/src/index.ts +2 -0
- 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="
|
|
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="
|
|
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
|
|
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="
|
|
155
|
-
|
|
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
|
package/src/components/LpPie.vue
CHANGED
|
@@ -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"
|
|
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="
|
|
13
|
-
|
|
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="
|
|
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="
|
|
61
|
-
|
|
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"
|
|
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">
|