@leavepulse/ui 0.22.21 → 0.23.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/chunks/{LpAddressInput-CxKwIG6T.js → LpAddressInput-BTqnUXZG.js} +3 -3
- package/dist/chunks/{LpAppShell-D8CAP9oF.js → LpAppShell-CaQYQKZV.js} +25 -14
- package/dist/chunks/{LpAvatar-B1Ez0ZAx.js → LpAvatar-D-jHoHR7.js} +15 -5
- package/dist/chunks/{LpConfirmDialog-CmgeAdCh.js → LpConfirmDialog-CObRah9c.js} +11 -6
- package/dist/chunks/{LpFileTree-Cw50eG_y.js → LpFileTree-CN0IOIY7.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-DFg4PhOK.js → LpFileTreeNode-D7WgUPcJ.js} +23 -21
- package/dist/chunks/{LpFormField-Bn-ZwC6z.js → LpFormField-CesaPWPO.js} +12 -6
- package/dist/chunks/{LpInput-BkomKdqQ.js → LpInput-jwqXlAy0.js} +6 -3
- package/dist/chunks/{LpLink-DHwOEp4e.js → LpLink-DU9kkKGW.js} +25 -10
- package/dist/chunks/{LpModal-CNNSf5pv.js → LpModal-D_o-56To.js} +13 -76
- package/dist/chunks/{LpNotificationBell-DWDEkmdA.js → LpNotificationBell-a08cl0yY.js} +67 -61
- package/dist/chunks/{LpPasswordInput-DgUC2Ur7.js → LpPasswordInput-Bmo6q9Kd.js} +11 -3
- package/dist/chunks/LpShift-DXNsauQZ.js +40 -0
- package/dist/chunks/{LpTextarea-CRAkhemZ.js → LpTextarea-1ENb3ZAO.js} +7 -4
- package/dist/chunks/{LpThemeSwitcher-B_kj0T2b.js → LpThemeSwitcher-BgpJgrnP.js} +27 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.d.ts +6 -6
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAvatar.vue.d.ts +10 -0
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +8 -0
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpInput.vue.d.ts +12 -0
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLink.vue.d.ts +20 -2
- package/dist/components/LpLink.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.d.ts +8 -0
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpShift.vue.d.ts +36 -0
- package/dist/components/LpShift.vue.js +4 -0
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpTextarea.vue.d.ts +5 -0
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/composables/useShift.d.ts +35 -0
- package/dist/composables/useShift.js +112 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +48 -44
- package/dist/theme/tokens.d.ts +3 -0
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpAppShell.vue +27 -7
- package/src/components/LpAvatar.vue +17 -5
- package/src/components/LpConfirmDialog.vue +21 -6
- package/src/components/LpFileTreeNode.vue +11 -2
- package/src/components/LpFormField.vue +19 -2
- package/src/components/LpInput.vue +8 -0
- package/src/components/LpLink.vue +40 -8
- package/src/components/LpModal.vue +20 -128
- package/src/components/LpNotificationBell.vue +48 -42
- package/src/components/LpPasswordInput.vue +16 -1
- package/src/components/LpShift.vue +90 -0
- package/src/components/LpTextarea.vue +7 -0
- package/src/composables/useShift.ts +236 -0
- package/src/index.ts +3 -0
- package/src/theme/presets.ts +24 -0
- package/src/theme/tokens.ts +6 -0
- package/src/tokens/tokens.css +3 -0
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* Form-control wrapper: label + the control (default slot) + hint/error.
|
|
4
4
|
* Error takes priority over hint. Uses reka Label for proper association.
|
|
5
|
+
*
|
|
6
|
+
* The message line eases its height (LpShift): validation messages arrive
|
|
7
|
+
* and clear as the user types, and each one appearing under the control shoved
|
|
8
|
+
* every field below it down a line. The control itself is left outside the
|
|
9
|
+
* eased box, so the thing being typed into never moves.
|
|
5
10
|
*/
|
|
6
11
|
import { Label } from "reka-ui"
|
|
7
12
|
import { useId } from "vue"
|
|
13
|
+
import LpShift from "./LpShift.vue"
|
|
8
14
|
|
|
9
15
|
defineProps<{
|
|
10
16
|
label?: string
|
|
@@ -17,6 +23,7 @@ const id = useId()
|
|
|
17
23
|
</script>
|
|
18
24
|
|
|
19
25
|
<template>
|
|
26
|
+
<!-- `gap` covers label→control only; the message carries its own spacing. -->
|
|
20
27
|
<div class="flex flex-col gap-1.5">
|
|
21
28
|
<Label v-if="label" :for="id" class="text-sm font-medium text-ink">
|
|
22
29
|
{{ label }}
|
|
@@ -25,7 +32,17 @@ const id = useId()
|
|
|
25
32
|
|
|
26
33
|
<slot :id="id" />
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
<!-- Swapping hint→error changes the text but not the height, so the eased
|
|
36
|
+
box only ever moves when a message appears or clears.
|
|
37
|
+
|
|
38
|
+
The gap that used to separate the message from the control is carried
|
|
39
|
+
INSIDE the eased box as padding, not by the parent's `gap`: a flex gap
|
|
40
|
+
applies to the wrapper whether or not it holds a message, which would
|
|
41
|
+
leave every message-less field a row taller than before — and would be
|
|
42
|
+
a step the easing could not cover, since it exists even at height 0. -->
|
|
43
|
+
<LpShift axis="height">
|
|
44
|
+
<p v-if="error" class="pt-1.5 text-xs text-danger">{{ error }}</p>
|
|
45
|
+
<p v-else-if="hint" class="pt-1.5 text-xs text-muted">{{ hint }}</p>
|
|
46
|
+
</LpShift>
|
|
30
47
|
</div>
|
|
31
48
|
</template>
|
|
@@ -54,6 +54,12 @@ const props = withDefaults(
|
|
|
54
54
|
hint?: string
|
|
55
55
|
error?: string
|
|
56
56
|
icon?: string
|
|
57
|
+
/**
|
|
58
|
+
* Marks the label with the required indicator. The attribute still reaches
|
|
59
|
+
* the underlying <input> for native validation; this only makes the field
|
|
60
|
+
* read as required.
|
|
61
|
+
*/
|
|
62
|
+
required?: boolean
|
|
57
63
|
/**
|
|
58
64
|
* RegExp/string the whole value must match to be valid. Validation only —
|
|
59
65
|
* never blocks typing; a non-matching non-empty value shows the invalid
|
|
@@ -118,6 +124,7 @@ defineExpose({
|
|
|
118
124
|
:label="hasField ? label : undefined"
|
|
119
125
|
:hint="hasField ? hint : undefined"
|
|
120
126
|
:error="hasField ? error : undefined"
|
|
127
|
+
:required="hasField ? required : undefined"
|
|
121
128
|
:class="hasField ? rootClass : undefined"
|
|
122
129
|
:style="hasField ? rootStyle : undefined"
|
|
123
130
|
>
|
|
@@ -136,6 +143,7 @@ defineExpose({
|
|
|
136
143
|
:value="modelValue"
|
|
137
144
|
:placeholder="placeholder"
|
|
138
145
|
:disabled="disabled"
|
|
146
|
+
:required="required || undefined"
|
|
139
147
|
:aria-invalid="isInvalidState || undefined"
|
|
140
148
|
:class="[
|
|
141
149
|
'lp-autofill min-w-0 flex-1 self-stretch bg-transparent outline-none placeholder:text-muted',
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from "vue"
|
|
2
|
+
import { computed, type Component } from "vue"
|
|
3
3
|
|
|
4
4
|
const props = withDefaults(
|
|
5
5
|
defineProps<{
|
|
6
6
|
href?: string
|
|
7
7
|
external?: boolean
|
|
8
|
+
/** Shorthand for `tone="muted"`. */
|
|
8
9
|
muted?: boolean
|
|
10
|
+
/**
|
|
11
|
+
* "brand" — the accent link colour (default).
|
|
12
|
+
* "ink" — inherits body text colour; for links that sit inside prose or
|
|
13
|
+
* chrome where an accent would shout (breadcrumbs, footers, nav).
|
|
14
|
+
* "muted" / "soft" — progressively quieter, both resolving to ink on hover.
|
|
15
|
+
*/
|
|
16
|
+
tone?: "brand" | "ink" | "muted" | "soft"
|
|
17
|
+
/**
|
|
18
|
+
* Root element/component. Defaults to a native <a>. Pass a router link
|
|
19
|
+
* component (e.g. NuxtLink or RouterLink) to keep client-side navigation;
|
|
20
|
+
* the kit stays DOM-agnostic, the consumer supplies the link component.
|
|
21
|
+
* Router links take their destination through their own prop (`to`), which
|
|
22
|
+
* falls through as a plain attribute.
|
|
23
|
+
*/
|
|
24
|
+
as?: string | Component
|
|
9
25
|
/**
|
|
10
26
|
* "default" — plain hover underline.
|
|
11
27
|
* "reveal" — an underline bar that grows in from the left on hover
|
|
@@ -14,15 +30,30 @@ const props = withDefaults(
|
|
|
14
30
|
*/
|
|
15
31
|
variant?: "default" | "reveal"
|
|
16
32
|
}>(),
|
|
17
|
-
{ variant: "default" },
|
|
33
|
+
{ variant: "default", as: "a" },
|
|
18
34
|
)
|
|
19
35
|
|
|
36
|
+
// A router link component owns its destination through its own prop (`to`),
|
|
37
|
+
// so `href` is only bound when this really renders an anchor. `target`/`rel`
|
|
38
|
+
// stay in play either way — router links honour them for external targets.
|
|
39
|
+
const isAnchor = computed(() => props.as === "a")
|
|
40
|
+
|
|
20
41
|
const rel = computed(() => (props.external ? "noopener noreferrer" : undefined))
|
|
21
42
|
const target = computed(() => (props.external ? "_blank" : undefined))
|
|
22
43
|
|
|
23
|
-
const colorClass = computed(() =>
|
|
24
|
-
props.muted ? "
|
|
25
|
-
)
|
|
44
|
+
const colorClass = computed(() => {
|
|
45
|
+
const tone = props.tone ?? (props.muted ? "muted" : "brand")
|
|
46
|
+
switch (tone) {
|
|
47
|
+
case "ink":
|
|
48
|
+
return "text-ink"
|
|
49
|
+
case "muted":
|
|
50
|
+
return "text-muted-strong hover:text-ink"
|
|
51
|
+
case "soft":
|
|
52
|
+
return "text-muted hover:text-ink"
|
|
53
|
+
default:
|
|
54
|
+
return "text-brand hover:text-brand-hover"
|
|
55
|
+
}
|
|
56
|
+
})
|
|
26
57
|
|
|
27
58
|
// The reveal bar: an ::after pseudo-element pinned to the baseline, full-width
|
|
28
59
|
// but scaled to 0 on the x-axis from the left, growing to 1 on hover/focus. The
|
|
@@ -33,8 +64,9 @@ const revealClass =
|
|
|
33
64
|
</script>
|
|
34
65
|
|
|
35
66
|
<template>
|
|
36
|
-
<
|
|
37
|
-
:
|
|
67
|
+
<component
|
|
68
|
+
:is="as"
|
|
69
|
+
:href="isAnchor ? href : undefined"
|
|
38
70
|
:target="target"
|
|
39
71
|
:rel="rel"
|
|
40
72
|
class="inline-flex items-center gap-1 underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
@@ -44,5 +76,5 @@ const revealClass =
|
|
|
44
76
|
]"
|
|
45
77
|
>
|
|
46
78
|
<slot />
|
|
47
|
-
</
|
|
79
|
+
</component>
|
|
48
80
|
</template>
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
DialogTitle,
|
|
10
10
|
} from "reka-ui"
|
|
11
11
|
import type { ComponentPublicInstance } from "vue"
|
|
12
|
-
import { computed,
|
|
12
|
+
import { computed, ref, useSlots, watch } from "vue"
|
|
13
|
+
import { useShift } from "../composables/useShift"
|
|
13
14
|
import { CLOSE_ICON } from "./dropdown"
|
|
14
15
|
import LpIcon from "./LpIcon.vue"
|
|
15
16
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
@@ -86,20 +87,14 @@ const bodyPad = computed(() =>
|
|
|
86
87
|
|
|
87
88
|
/*
|
|
88
89
|
* Height is content-derived, so a body that arrives late — a placeholder swapped
|
|
89
|
-
* for the loaded thing —
|
|
90
|
-
* the open animation.
|
|
91
|
-
* interpolable, and the keyword-interpolation escape hatches
|
|
92
|
-
* (`interpolate-size`, `calc-size()`) are Chromium-only, so they would do
|
|
93
|
-
* nothing in the WebKitGTK launcher. Measuring and pinning a pixel height is
|
|
94
|
-
* what works on every engine.
|
|
90
|
+
* for the loaded thing — would resize the panel in a single frame, right on top
|
|
91
|
+
* of the open animation. The easing is set up further down.
|
|
95
92
|
*
|
|
96
93
|
* The pin is always the panel's own natural height, so `max-h` still clips and
|
|
97
94
|
* the body keeps `flex-1 min-h-0` — an over-tall panel hands its overflow to the
|
|
98
95
|
* scroll area exactly as before. A panel whose size never moves is pinned once
|
|
99
96
|
* and never transitions.
|
|
100
97
|
*/
|
|
101
|
-
// reka renders a real element but exposes it as a component instance, so the
|
|
102
|
-
// panel is reached through $el.
|
|
103
98
|
const panelRef = ref<ComponentPublicInstance | null>(null)
|
|
104
99
|
|
|
105
100
|
/**
|
|
@@ -156,130 +151,27 @@ function onOpenAutoFocus(event: Event) {
|
|
|
156
151
|
return
|
|
157
152
|
}
|
|
158
153
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
/** Where the panel last settled, so a retune has a height to ease FROM. */
|
|
170
|
-
let lastHeight = 0
|
|
171
|
-
/** True while a tween owns the height, so observers don't cancel it midway. */
|
|
172
|
-
let inFlight = false
|
|
173
|
-
|
|
174
|
-
// The height is driven imperatively rather than through a style binding. By the
|
|
175
|
-
// time replacement content is in the DOM the panel has already reflowed to its
|
|
176
|
-
// new size, so a bound value would only ever be written once, with no earlier
|
|
177
|
-
// height for the transition to start from. Writing the OLD height, forcing a
|
|
178
|
-
// reflow, then writing the new one gives the transition both ends.
|
|
179
|
-
const retune = (el: HTMLElement) => {
|
|
180
|
-
// One swap wakes both observers — the mutation as content lands, the resize as
|
|
181
|
-
// it reflows — and the second call would clear the pin the first just wrote.
|
|
182
|
-
if (inFlight) return
|
|
183
|
-
// Remembered, not measured: by the time either observer fires the panel has
|
|
184
|
-
// already reflowed, so the element can only report where it is going.
|
|
185
|
-
const from = lastHeight
|
|
186
|
-
el.style.height = ""
|
|
187
|
-
// `max-h` comes from a class, so the panel already reports the clipped figure
|
|
188
|
-
// once the cap bites. Pinning that would leave a short panel sitting over
|
|
189
|
-
// content it can no longer grow to fit, so the natural height is measured with
|
|
190
|
-
// the cap lifted and the clamp is left to CSS afterwards.
|
|
191
|
-
el.style.maxHeight = "none"
|
|
192
|
-
const to = el.offsetHeight
|
|
193
|
-
el.style.maxHeight = ""
|
|
194
|
-
lastHeight = to
|
|
195
|
-
// Nothing moved, so there is nothing to ease — and leaving the height unset
|
|
196
|
-
// keeps the panel free to size itself.
|
|
197
|
-
if (to === 0 || from === 0 || to === from) return
|
|
198
|
-
el.style.height = `${from}px`
|
|
199
|
-
void el.offsetHeight
|
|
200
|
-
el.style.height = `${to}px`
|
|
201
|
-
|
|
202
|
-
inFlight = true
|
|
203
|
-
// While the panel is still catching up, the body is shorter than the content
|
|
204
|
-
// it already holds, so the scroll area would flash a bar for an overflow that
|
|
205
|
-
// resolves itself the moment the transition lands.
|
|
206
|
-
resizing.value = true
|
|
207
|
-
clearTimeout(settleTimer)
|
|
208
|
-
settleTimer = setTimeout(() => {
|
|
209
|
-
inFlight = false
|
|
210
|
-
resizing.value = false
|
|
211
|
-
// Released once the panel has arrived. A height left pinned in pixels stops
|
|
212
|
-
// being a starting point and becomes a cap: content that grows afterwards
|
|
213
|
-
// is clipped, and `max-h` can no longer size the panel itself.
|
|
214
|
-
el.style.height = ""
|
|
215
|
-
// Re-read rather than trusting `to`: `max-h` may have clamped the panel
|
|
216
|
-
// short of it, and the next tween has to start from where it really sits.
|
|
217
|
-
lastHeight = el.offsetHeight
|
|
218
|
-
}, TWEEN_MS)
|
|
219
|
-
}
|
|
154
|
+
// The panel's height eases between content-derived sizes — see
|
|
155
|
+
// `useShift`, which owns the measure/pin/tween mechanism and is shared
|
|
156
|
+
// with LpShift. Gated on `open`, because the panel is portalled and remounts
|
|
157
|
+
// on every open: a remount must measure afresh rather than ease in from the
|
|
158
|
+
// size the previous incarnation happened to hold.
|
|
159
|
+
const {
|
|
160
|
+
el: sizedEl,
|
|
161
|
+
tweening,
|
|
162
|
+
resizing,
|
|
163
|
+
} = useShift({ axis: "height", enabled: () => props.open })
|
|
220
164
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
165
|
+
// reka renders a real element but exposes it as a component instance, so the
|
|
166
|
+
// sized element is handed over through $el once the panel has mounted.
|
|
223
167
|
watch(
|
|
224
|
-
() => props.open,
|
|
225
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
ro = undefined
|
|
229
|
-
mo = undefined
|
|
230
|
-
cancelAnimationFrame(raf)
|
|
231
|
-
clearTimeout(settleTimer)
|
|
232
|
-
tweening.value = false
|
|
233
|
-
resizing.value = false
|
|
234
|
-
|
|
235
|
-
lastHeight = 0
|
|
236
|
-
inFlight = false
|
|
237
|
-
|
|
238
|
-
const el = panelRef.value?.$el
|
|
239
|
-
if (!isOpen || !(el instanceof HTMLElement)) return
|
|
240
|
-
|
|
241
|
-
lastHeight = el.offsetHeight
|
|
242
|
-
el.style.height = `${el.offsetHeight}px`
|
|
243
|
-
// The opening panel must not animate its height in from the pre-content box,
|
|
244
|
-
// so the transition only arms once that first pin has painted. The pin is
|
|
245
|
-
// dropped in the same frame: it exists to give the first `retune` something
|
|
246
|
-
// to start from, and holding it would cap the panel at its opening size.
|
|
247
|
-
raf = requestAnimationFrame(() => {
|
|
248
|
-
tweening.value = true
|
|
249
|
-
el.style.height = ""
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// Two triggers, since either can move the height alone: the resize observer
|
|
253
|
-
// catches a child growing in place (an image decoding, a list filling in),
|
|
254
|
-
// the mutation observer catches one subtree swapped for another, where every
|
|
255
|
-
// observed box may keep its size. Children are observed, never the panel —
|
|
256
|
-
// that would feed the pin straight back in.
|
|
257
|
-
const watchChildren = () => {
|
|
258
|
-
if (!ro) return
|
|
259
|
-
ro.disconnect()
|
|
260
|
-
for (const child of Array.from(el.children)) ro.observe(child)
|
|
261
|
-
}
|
|
262
|
-
if (typeof ResizeObserver !== "undefined") {
|
|
263
|
-
ro = new ResizeObserver(() => retune(el))
|
|
264
|
-
watchChildren()
|
|
265
|
-
}
|
|
266
|
-
if (typeof MutationObserver !== "undefined") {
|
|
267
|
-
mo = new MutationObserver(() => {
|
|
268
|
-
watchChildren()
|
|
269
|
-
retune(el)
|
|
270
|
-
})
|
|
271
|
-
mo.observe(el, { childList: true, subtree: true, characterData: true })
|
|
272
|
-
}
|
|
168
|
+
() => (props.open ? panelRef.value : null),
|
|
169
|
+
(panel) => {
|
|
170
|
+
const el = panel?.$el
|
|
171
|
+
sizedEl.value = el instanceof HTMLElement ? el : null
|
|
273
172
|
},
|
|
274
173
|
{ flush: "post" },
|
|
275
174
|
)
|
|
276
|
-
|
|
277
|
-
onBeforeUnmount(() => {
|
|
278
|
-
ro?.disconnect()
|
|
279
|
-
mo?.disconnect()
|
|
280
|
-
cancelAnimationFrame(raf)
|
|
281
|
-
clearTimeout(settleTimer)
|
|
282
|
-
})
|
|
283
175
|
</script>
|
|
284
176
|
|
|
285
177
|
<template>
|
|
@@ -16,6 +16,7 @@ import LpEmptyState from "./LpEmptyState.vue"
|
|
|
16
16
|
import LpIcon from "./LpIcon.vue"
|
|
17
17
|
import LpPopover from "./LpPopover.vue"
|
|
18
18
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
19
|
+
import LpShift from "./LpShift.vue"
|
|
19
20
|
|
|
20
21
|
export interface NotificationItem {
|
|
21
22
|
id: string
|
|
@@ -225,52 +226,57 @@ function timeAgo(iso?: string): string {
|
|
|
225
226
|
</button>
|
|
226
227
|
</header>
|
|
227
228
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
<!-- Skeleton → empty → feed are three different heights, so the panel
|
|
230
|
+
jumped as the list loaded and again whenever it emptied. Easing the
|
|
231
|
+
swap keeps the popover from snapping under the bell. -->
|
|
232
|
+
<LpShift axis="height">
|
|
233
|
+
<div v-if="loading" class="space-y-1.5 px-2 pb-2">
|
|
234
|
+
<div v-for="i in 4" :key="i" class="h-12 animate-pulse rounded-control bg-surface-soft" />
|
|
235
|
+
</div>
|
|
231
236
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
<LpEmptyState
|
|
238
|
+
v-else-if="!items.length"
|
|
239
|
+
icon="lucide:bell-off"
|
|
240
|
+
:title="l.empty"
|
|
241
|
+
class="px-3 py-10"
|
|
242
|
+
/>
|
|
238
243
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
>
|
|
252
|
-
<span
|
|
253
|
-
class="grid size-7 shrink-0 place-items-center rounded-control"
|
|
254
|
-
:class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
|
|
244
|
+
<!-- The feed sits in a thin gutter; rows are self-contained tiles (rounded,
|
|
245
|
+
hover-lit) instead of full-width strips with hard dividers — lighter and
|
|
246
|
+
easier to scan. Each row carries its own right-click action menu. -->
|
|
247
|
+
<LpScrollArea v-else class="max-h-96" content-class="flex flex-col gap-0.5 px-2 pb-2">
|
|
248
|
+
<LpContextMenu v-for="item in items" :key="item.id" :items="rowMenu(item)">
|
|
249
|
+
<component
|
|
250
|
+
:is="item.link ? 'a' : 'button'"
|
|
251
|
+
:href="item.link || undefined"
|
|
252
|
+
:type="item.link ? undefined : 'button'"
|
|
253
|
+
class="group/notif flex w-full items-start gap-2.5 rounded-control px-2 py-2 text-left outline-none transition-colors hover:bg-white/[0.05] focus-visible:bg-white/[0.05]"
|
|
254
|
+
:class="item.read ? '' : 'bg-brand-soft/35'"
|
|
255
|
+
@click="onSelect(item)"
|
|
255
256
|
>
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<
|
|
261
|
-
{{ item.title }}
|
|
262
|
-
</span>
|
|
263
|
-
<span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
|
|
257
|
+
<span
|
|
258
|
+
class="grid size-7 shrink-0 place-items-center rounded-control"
|
|
259
|
+
:class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
|
|
260
|
+
>
|
|
261
|
+
<LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
|
|
264
262
|
</span>
|
|
265
|
-
<span
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
263
|
+
<span class="min-w-0 flex-1">
|
|
264
|
+
<span class="flex items-center gap-1.5">
|
|
265
|
+
<span class="truncate text-sm font-medium" :class="item.read ? 'text-muted-strong' : 'text-ink'">
|
|
266
|
+
{{ item.title }}
|
|
267
|
+
</span>
|
|
268
|
+
<span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
|
|
269
|
+
</span>
|
|
270
|
+
<span v-if="item.body" class="mt-0.5 line-clamp-2 block text-xs text-muted">
|
|
271
|
+
{{ item.body }}
|
|
272
|
+
</span>
|
|
273
|
+
<span v-if="item.createdAt" class="mt-0.5 block text-[11px] text-muted/70">
|
|
274
|
+
{{ timeAgo(item.createdAt) }}
|
|
275
|
+
</span>
|
|
270
276
|
</span>
|
|
271
|
-
</
|
|
272
|
-
</
|
|
273
|
-
</
|
|
274
|
-
</
|
|
277
|
+
</component>
|
|
278
|
+
</LpContextMenu>
|
|
279
|
+
</LpScrollArea>
|
|
280
|
+
</LpShift>
|
|
275
281
|
</LpPopover>
|
|
276
282
|
</template>
|
|
@@ -9,7 +9,18 @@ import { ref } from "vue"
|
|
|
9
9
|
import LpIcon from "./LpIcon.vue"
|
|
10
10
|
import LpInput from "./LpInput.vue"
|
|
11
11
|
|
|
12
|
-
defineProps<{
|
|
12
|
+
defineProps<{
|
|
13
|
+
modelValue?: string
|
|
14
|
+
placeholder?: string
|
|
15
|
+
invalid?: boolean
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
// Field wrapping mirrors LpInput: passing any of these wraps the control in
|
|
18
|
+
// LpFormField, so a password field labels itself like every other field.
|
|
19
|
+
label?: string
|
|
20
|
+
hint?: string
|
|
21
|
+
error?: string
|
|
22
|
+
required?: boolean
|
|
23
|
+
}>()
|
|
13
24
|
defineEmits<{ (e: "update:modelValue", value: string): void }>()
|
|
14
25
|
|
|
15
26
|
const visible = ref(false)
|
|
@@ -29,6 +40,10 @@ defineExpose({
|
|
|
29
40
|
:placeholder="placeholder"
|
|
30
41
|
:invalid="invalid"
|
|
31
42
|
:disabled="disabled"
|
|
43
|
+
:label="label"
|
|
44
|
+
:hint="hint"
|
|
45
|
+
:error="error"
|
|
46
|
+
:required="required"
|
|
32
47
|
@update:model-value="(v) => $emit('update:modelValue', v)"
|
|
33
48
|
>
|
|
34
49
|
<template v-if="$slots.leading" #leading><slot name="leading" /></template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Wraps content whose size is derived from what's inside it, so that everything
|
|
4
|
+
* around it shifts smoothly instead of jumping.
|
|
5
|
+
*
|
|
6
|
+
* Reach for it wherever a change of content changes a box's measurements: a
|
|
7
|
+
* toolbar that gains or loses a button, a label swapped by a translation, a
|
|
8
|
+
* placeholder replaced by the loaded thing, a validation message appearing
|
|
9
|
+
* under a field. Those all resize in a single frame otherwise, and everything
|
|
10
|
+
* they push jolts with them.
|
|
11
|
+
*
|
|
12
|
+
* <LpShift axis="width">
|
|
13
|
+
* <slot name="actions" />
|
|
14
|
+
* </LpShift>
|
|
15
|
+
*
|
|
16
|
+
* Note it is the NEIGHBOURS that motivate this, not the box: a fixed-height
|
|
17
|
+
* header never resizes, yet its controls jump whenever one of them changes
|
|
18
|
+
* width. Easing that one box is what makes the row glide.
|
|
19
|
+
*
|
|
20
|
+
* Content is measured through a wrapper that is never itself pinned, so the
|
|
21
|
+
* slot keeps whatever layout it brings (flex row, grid, plain text). `axis`
|
|
22
|
+
* picks which dimension is content-derived: pin only the one that actually
|
|
23
|
+
* moves, so the other stays free to respond to the surrounding layout.
|
|
24
|
+
*
|
|
25
|
+
* The mechanism — measure the natural size, pin it in pixels, tween — lives in
|
|
26
|
+
* `useShift`, and LpModal drives its panel with the same composable.
|
|
27
|
+
*/
|
|
28
|
+
import { computed } from "vue"
|
|
29
|
+
import { useShift, type ShiftAxis } from "../composables/useShift"
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(
|
|
32
|
+
defineProps<{
|
|
33
|
+
/**
|
|
34
|
+
* Which dimension eases. `"width"` for a row that gains/loses controls,
|
|
35
|
+
* `"height"` for a stack whose content grows, `"both"` when either can.
|
|
36
|
+
*/
|
|
37
|
+
axis?: ShiftAxis
|
|
38
|
+
/** Tween length in ms. Defaults to the kit's `--duration-fast`. */
|
|
39
|
+
duration?: number
|
|
40
|
+
/**
|
|
41
|
+
* Turn the easing off entirely — the wrapper stays, the box resizes
|
|
42
|
+
* instantly. For a box that is being driven by something else (a drag, a
|
|
43
|
+
* canvas), where a tween would fight the thing already animating it.
|
|
44
|
+
*/
|
|
45
|
+
disabled?: boolean
|
|
46
|
+
}>(),
|
|
47
|
+
{ axis: "height", disabled: false },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const { el, tweening, resizing } = useShift({
|
|
51
|
+
axis: () => props.axis,
|
|
52
|
+
duration: () => props.duration,
|
|
53
|
+
enabled: () => !props.disabled,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Only the pinned axis is transitioned: naming the other one would animate a
|
|
57
|
+
// dimension the layout is still free to set, so a flex row settling into place
|
|
58
|
+
// would ease sideways for no reason.
|
|
59
|
+
//
|
|
60
|
+
// Spelled out as whole literals rather than built from `axis`. The consuming
|
|
61
|
+
// app's Tailwind scans this file as TEXT, so a class assembled at runtime is a
|
|
62
|
+
// class it never sees — the utility simply wouldn't be emitted.
|
|
63
|
+
const TRANSITION_CLASS: Record<ShiftAxis, string> = {
|
|
64
|
+
height: "transition-[height]",
|
|
65
|
+
width: "transition-[width]",
|
|
66
|
+
both: "transition-[height,width]",
|
|
67
|
+
}
|
|
68
|
+
const transitionClass = computed(() => TRANSITION_CLASS[props.axis])
|
|
69
|
+
|
|
70
|
+
defineExpose({ resizing })
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<!-- `overflow-hidden` only while a tween owns the size: the box is briefly
|
|
75
|
+
smaller than the content it already holds, and without the clip that
|
|
76
|
+
content spills past the easing edge. Dropped once settled, so nothing
|
|
77
|
+
that legitimately overflows — a dropdown, a focus ring — is clipped at
|
|
78
|
+
rest. -->
|
|
79
|
+
<div
|
|
80
|
+
ref="el"
|
|
81
|
+
:class="[
|
|
82
|
+
tweening && !disabled
|
|
83
|
+
? `${transitionClass} duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none`
|
|
84
|
+
: '',
|
|
85
|
+
resizing ? 'overflow-hidden' : '',
|
|
86
|
+
]"
|
|
87
|
+
>
|
|
88
|
+
<slot :resizing="resizing" />
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
@@ -11,6 +11,11 @@ const props = defineProps<{
|
|
|
11
11
|
label?: string
|
|
12
12
|
hint?: string
|
|
13
13
|
error?: string
|
|
14
|
+
/**
|
|
15
|
+
* Marks the label with the required indicator; the attribute still reaches
|
|
16
|
+
* the <textarea> for native validation.
|
|
17
|
+
*/
|
|
18
|
+
required?: boolean
|
|
14
19
|
}>()
|
|
15
20
|
|
|
16
21
|
defineEmits<{ (e: "update:modelValue", value: string): void }>()
|
|
@@ -24,6 +29,7 @@ const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
|
|
|
24
29
|
:label="hasField ? label : undefined"
|
|
25
30
|
:hint="hasField ? hint : undefined"
|
|
26
31
|
:error="hasField ? error : undefined"
|
|
32
|
+
:required="hasField ? required : undefined"
|
|
27
33
|
:class="hasField ? undefined : 'contents'"
|
|
28
34
|
>
|
|
29
35
|
<textarea
|
|
@@ -31,6 +37,7 @@ const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
|
|
|
31
37
|
:placeholder="placeholder"
|
|
32
38
|
:rows="rows ?? 4"
|
|
33
39
|
:disabled="disabled"
|
|
40
|
+
:required="required || undefined"
|
|
34
41
|
class="w-full resize-y rounded-control border bg-surface-soft px-3 py-2 text-sm text-ink outline-none transition-colors duration-[var(--duration-fast)] placeholder:text-muted focus:border-brand focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
|
|
35
42
|
:class="(invalid || error) ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line'"
|
|
36
43
|
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|