@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.
- 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-oLqyjOln.js} +30 -7
- 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-Cid7PGYx.js +214 -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 +80 -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/components/lightbox.d.ts +10 -0
- 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 +68 -3
- 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 +365 -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/components/lightbox.ts +10 -0
- package/src/composables/useMergedClass.ts +30 -0
- package/src/index.ts +2 -0
- 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="
|
|
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>
|