@leavepulse/ui 0.24.0 → 0.25.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/LpNumberFlow-rZkwQwlU.css +116 -0
- package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
- package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
- package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
- package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
- package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
- package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
- package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
- package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
- package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
- package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
- package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
- package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
- package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
- package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
- package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
- package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
- package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
- package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
- package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
- package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
- package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
- package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
- package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +88 -0
- package/dist/components/LpChart.vue.js +4 -0
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +12 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberFlow.vue.d.ts +35 -0
- package/dist/components/LpNumberFlow.vue.js +4 -0
- package/dist/components/LpPie.vue.d.ts +134 -0
- package/dist/components/LpPie.vue.js +4 -0
- package/dist/components/LpProgress.vue.d.ts +56 -2
- package/dist/components/LpProgress.vue.js +1 -1
- package/dist/components/LpRollingText.vue.d.ts +33 -0
- package/dist/components/LpRollingText.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSparkline.vue.d.ts +48 -0
- package/dist/components/LpSparkline.vue.js +4 -0
- package/dist/components/LpStat.vue.d.ts +25 -2
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTooltip.vue.d.ts +24 -3
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.d.ts +180 -0
- package/dist/components/chart.js +353 -0
- package/dist/composables/easing.d.ts +46 -0
- package/dist/composables/easing.js +23 -0
- package/dist/composables/usePillTransition.d.ts +13 -1
- package/dist/composables/usePillTransition.js +22 -2
- package/dist/composables/useReveal.d.ts +26 -0
- package/dist/composables/useReveal.js +45 -0
- package/dist/composables/useSettled.js +2 -2
- package/dist/composables/useShift.js +2 -4
- package/dist/index.d.ts +14 -0
- package/dist/index.js +126 -103
- package/package.json +2 -1
- package/src/component-names.ts +5 -0
- package/src/components/LpChart.vue +846 -0
- package/src/components/LpCodeBlock.vue +1 -1
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpDisclosure.vue +1 -1
- package/src/components/LpFileTreeNode.vue +9 -10
- package/src/components/LpLogViewer.vue +2 -3
- package/src/components/LpMapPicker.vue +2 -5
- package/src/components/LpNotificationBell.vue +76 -15
- package/src/components/LpNumberFlow.vue +524 -0
- package/src/components/LpPie.vue +778 -0
- package/src/components/LpProgress.vue +283 -6
- package/src/components/LpRollingText.vue +157 -0
- package/src/components/LpSidebarNav.vue +1 -1
- package/src/components/LpSparkline.vue +312 -0
- package/src/components/LpStat.vue +52 -4
- package/src/components/LpTooltip.vue +185 -3
- package/src/components/LpUptimeBar.vue +150 -53
- package/src/components/chart.ts +686 -0
- package/src/composables/easing.ts +88 -0
- package/src/composables/usePillTransition.ts +71 -6
- package/src/composables/useReveal.ts +113 -0
- package/src/composables/useSettled.ts +2 -4
- package/src/composables/useShift.ts +2 -8
- package/src/index.ts +19 -0
- package/src/theme/useTheme.ts +2 -8
- package/src/tokens/tokens.css +36 -0
- package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
- package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
- package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
|
@@ -268,7 +268,7 @@ function onInput(e: Event) {
|
|
|
268
268
|
v-else
|
|
269
269
|
><button
|
|
270
270
|
type="button"
|
|
271
|
-
class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
271
|
+
class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
272
272
|
:aria-expanded="openState[seg.id]"
|
|
273
273
|
@click="toggleFold(seg.id)"
|
|
274
274
|
><LpIcon
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Confirmation dialog built on LpModal. Resolves an action via confirm/cancel.
|
|
3
|
+
import { computed } from "vue"
|
|
3
4
|
import LpButton from "./LpButton.vue"
|
|
5
|
+
import LpIcon from "./LpIcon.vue"
|
|
4
6
|
import LpModal from "./LpModal.vue"
|
|
5
7
|
|
|
6
8
|
const props = withDefaults(
|
|
@@ -10,7 +12,23 @@ const props = withDefaults(
|
|
|
10
12
|
description?: string
|
|
11
13
|
confirmLabel?: string
|
|
12
14
|
cancelLabel?: string
|
|
15
|
+
/**
|
|
16
|
+
* The action destroys something. Colours the confirm button, and marks the
|
|
17
|
+
* title with a warning glyph — a destructive prompt should be recognisable
|
|
18
|
+
* before the words are read, since it is the one dialog people dismiss on
|
|
19
|
+
* reflex.
|
|
20
|
+
*/
|
|
13
21
|
danger?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Override the marker: any iconify name, or `""` to leave the title plain —
|
|
24
|
+
* for a `danger` action whose dialog body already carries the warning and
|
|
25
|
+
* would otherwise state it twice.
|
|
26
|
+
*
|
|
27
|
+
* A string rather than `string | false`, deliberately: a union including
|
|
28
|
+
* Boolean makes Vue boolean-cast the prop, so an absent `icon` would arrive
|
|
29
|
+
* as `false` and be indistinguishable from a caller suppressing the marker.
|
|
30
|
+
*/
|
|
31
|
+
icon?: string
|
|
14
32
|
/**
|
|
15
33
|
* The confirm action is in flight: the button disables and swaps to
|
|
16
34
|
* `loadingLabel`, and confirming no longer closes the dialog — the owner
|
|
@@ -28,6 +46,22 @@ const emit = defineEmits<{
|
|
|
28
46
|
(e: "cancel"): void
|
|
29
47
|
}>()
|
|
30
48
|
|
|
49
|
+
/*
|
|
50
|
+
* Only a destructive prompt is marked by default. A routine confirmation
|
|
51
|
+
* ("Save these changes?") with a warning badge cries wolf, and the badge stops
|
|
52
|
+
* meaning anything on the dialog where it matters.
|
|
53
|
+
*
|
|
54
|
+
* "Not given" cannot be tested with `undefined` here. A prop whose type union
|
|
55
|
+
* includes Boolean is boolean-cast by Vue, so an absent `icon` arrives as
|
|
56
|
+
* `false` — the same value the caller passes to suppress the marker — and the
|
|
57
|
+
* default silently never applied. A string is an override, and everything else
|
|
58
|
+
* (absent, or an explicit false) falls through to the danger default.
|
|
59
|
+
*/
|
|
60
|
+
const marker = computed<string>(() => {
|
|
61
|
+
if (props.icon !== undefined) return props.icon
|
|
62
|
+
return props.danger ? "lucide:triangle-alert" : ""
|
|
63
|
+
})
|
|
64
|
+
|
|
31
65
|
function confirm() {
|
|
32
66
|
emit("confirm")
|
|
33
67
|
// An async confirm owns its own dismissal; closing here would tear the
|
|
@@ -47,6 +81,21 @@ function cancel() {
|
|
|
47
81
|
:description="description"
|
|
48
82
|
@update:open="(v) => $emit('update:open', v)"
|
|
49
83
|
>
|
|
84
|
+
<template v-if="marker" #title>
|
|
85
|
+
<span class="inline-flex items-center gap-2.5">
|
|
86
|
+
<span
|
|
87
|
+
class="flex size-9 shrink-0 items-center justify-center rounded-full"
|
|
88
|
+
:class="
|
|
89
|
+
danger
|
|
90
|
+
? 'border border-danger/30 bg-danger-soft text-danger'
|
|
91
|
+
: 'border border-line bg-surface-soft text-muted-strong'
|
|
92
|
+
"
|
|
93
|
+
>
|
|
94
|
+
<LpIcon :name="marker" :size="20" />
|
|
95
|
+
</span>
|
|
96
|
+
{{ title }}
|
|
97
|
+
</span>
|
|
98
|
+
</template>
|
|
50
99
|
<slot />
|
|
51
100
|
<template #footer>
|
|
52
101
|
<LpButton variant="ghost" :disabled="loading" @click="cancel">
|
|
@@ -61,7 +61,7 @@ function onToggle(value: boolean) {
|
|
|
61
61
|
@update:open="onToggle"
|
|
62
62
|
>
|
|
63
63
|
<CollapsibleTrigger
|
|
64
|
-
class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
64
|
+
class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
65
65
|
>
|
|
66
66
|
<LpIcon v-if="icon" :name="icon" :size="14" class="text-muted" />
|
|
67
67
|
<span class="min-w-0 flex-1 text-left">
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Tab enters the tree once, arrows move within it. The parent owns that state.
|
|
11
11
|
*/
|
|
12
12
|
import { computed } from "vue"
|
|
13
|
+
import { easingToken, prefersReducedMotion } from "../composables/easing"
|
|
13
14
|
import {
|
|
14
15
|
fileIcon,
|
|
15
16
|
formatModified,
|
|
@@ -110,35 +111,30 @@ function rowDelay(index: number): string {
|
|
|
110
111
|
return `${Math.min(index * 18, 180)}ms`
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
/** Respect the OS setting — skip the height animation entirely. */
|
|
114
|
-
function reducedMotion(): boolean {
|
|
115
|
-
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
|
|
116
|
-
}
|
|
117
|
-
|
|
118
114
|
// Expand/collapse the branch by animating from 0 to its measured height. The
|
|
119
115
|
// Web Animations API is used directly (rather than a CSS class) because the
|
|
120
116
|
// target height isn't known until the rows are in the DOM.
|
|
121
117
|
function onBranchEnter(el: Element, done: () => void) {
|
|
122
|
-
if (
|
|
118
|
+
if (prefersReducedMotion()) return done()
|
|
123
119
|
const height = (el as HTMLElement).scrollHeight
|
|
124
120
|
el.animate(
|
|
125
121
|
[
|
|
126
122
|
{ height: "0px", opacity: 0 },
|
|
127
123
|
{ height: `${height}px`, opacity: 1 },
|
|
128
124
|
],
|
|
129
|
-
{ duration: 220, easing: "
|
|
125
|
+
{ duration: 220, easing: easingToken("ease-emphasized") },
|
|
130
126
|
).onfinish = () => done()
|
|
131
127
|
}
|
|
132
128
|
|
|
133
129
|
function onBranchLeave(el: Element, done: () => void) {
|
|
134
|
-
if (
|
|
130
|
+
if (prefersReducedMotion()) return done()
|
|
135
131
|
const height = (el as HTMLElement).scrollHeight
|
|
136
132
|
el.animate(
|
|
137
133
|
[
|
|
138
134
|
{ height: `${height}px`, opacity: 1 },
|
|
139
135
|
{ height: "0px", opacity: 0 },
|
|
140
136
|
],
|
|
141
|
-
{ duration: 180, easing: "
|
|
137
|
+
{ duration: 180, easing: easingToken("ease-emphasized") },
|
|
142
138
|
).onfinish = () => done()
|
|
143
139
|
}
|
|
144
140
|
|
|
@@ -163,10 +159,13 @@ function onActivate() {
|
|
|
163
159
|
>
|
|
164
160
|
<!-- Passthrough when the node has no menu, so the row keeps the native one. -->
|
|
165
161
|
<LpContextMenu :items="node.menu ?? []">
|
|
162
|
+
<!-- The ring is drawn inset, like LpTable's rows: a row spans the full
|
|
163
|
+
width of the branch, and a branch clips its overflow to animate its
|
|
164
|
+
height, so an outset ring loses its left and right edge. -->
|
|
166
165
|
<div
|
|
167
166
|
:data-node-id="node.id"
|
|
168
167
|
:tabindex="isFocused ? 0 : -1"
|
|
169
|
-
class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
168
|
+
class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
170
169
|
:class="[
|
|
171
170
|
node.disabled
|
|
172
171
|
? 'cursor-not-allowed text-muted/50'
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* needs.
|
|
9
9
|
*/
|
|
10
10
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, TransitionGroup, watch } from "vue"
|
|
11
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
11
12
|
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
12
13
|
import LpIcon from "./LpIcon.vue"
|
|
13
14
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
@@ -466,9 +467,7 @@ function restoreScrollAnchor() {
|
|
|
466
467
|
function rideToBottom(smooth = true, force = false) {
|
|
467
468
|
const el = viewport()
|
|
468
469
|
if (!el) return
|
|
469
|
-
const reduce =
|
|
470
|
-
typeof window !== "undefined" &&
|
|
471
|
-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
470
|
+
const reduce = prefersReducedMotion()
|
|
472
471
|
const far = !force && el.scrollHeight - el.scrollTop - el.clientHeight > el.clientHeight
|
|
473
472
|
el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce && !far ? "smooth" : "auto" })
|
|
474
473
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* is how the zoom reads as continuous instead of as a hard cut between levels.
|
|
15
15
|
*/
|
|
16
16
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
17
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
17
18
|
import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
|
|
18
19
|
import LpIcon from "./LpIcon.vue"
|
|
19
20
|
|
|
@@ -335,7 +336,7 @@ function onPointerUp(e: PointerEvent) {
|
|
|
335
336
|
* rather than per-frame, which keeps the feel identical at 60 and 144 Hz.
|
|
336
337
|
*/
|
|
337
338
|
function glide() {
|
|
338
|
-
if (
|
|
339
|
+
if (prefersReducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
|
|
339
340
|
velocity = { x: 0, y: 0 }
|
|
340
341
|
return
|
|
341
342
|
}
|
|
@@ -355,10 +356,6 @@ function glide() {
|
|
|
355
356
|
glideFrame = requestAnimationFrame(step)
|
|
356
357
|
}
|
|
357
358
|
|
|
358
|
-
function reducedMotion() {
|
|
359
|
-
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
360
|
-
}
|
|
361
|
-
|
|
362
359
|
function panBy(dx: number, dy: number) {
|
|
363
360
|
mapCenter.value = unproject(
|
|
364
361
|
origin.value.x + dx + viewWidth.value / 2,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `select`. Mirrors the kit convention (cf. LpSidebar): data in, events out,
|
|
9
9
|
* no data fetching inside.
|
|
10
10
|
*/
|
|
11
|
-
import { computed } from "vue"
|
|
11
|
+
import { computed, getCurrentInstance } from "vue"
|
|
12
12
|
import LpBadge from "./LpBadge.vue"
|
|
13
13
|
import LpButton from "./LpButton.vue"
|
|
14
14
|
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
@@ -24,6 +24,15 @@ export interface NotificationItem {
|
|
|
24
24
|
body?: string
|
|
25
25
|
/** Optional icon name (e.g. "lucide:check-circle"). */
|
|
26
26
|
icon?: string
|
|
27
|
+
/**
|
|
28
|
+
* Colours the row's icon by what KIND of notification it is — a billing
|
|
29
|
+
* notice and a security one should not look alike at a glance.
|
|
30
|
+
*
|
|
31
|
+
* A named tone rather than a class, so the categories stay themeable:
|
|
32
|
+
* consumers were mapping their own types onto raw palette classes, which is
|
|
33
|
+
* the one place a theme cannot follow them.
|
|
34
|
+
*/
|
|
35
|
+
tone?: "brand" | "action" | "danger" | "accent" | "muted"
|
|
27
36
|
/** Optional deep link; the row becomes clickable when set. */
|
|
28
37
|
link?: string
|
|
29
38
|
/** ISO-8601 creation time; rendered as a relative label. */
|
|
@@ -40,6 +49,7 @@ export interface NotificationItem {
|
|
|
40
49
|
export interface NotificationLabels {
|
|
41
50
|
title?: string
|
|
42
51
|
markAllRead?: string
|
|
52
|
+
clear?: string
|
|
43
53
|
markAsRead?: string
|
|
44
54
|
markAsUnread?: string
|
|
45
55
|
open?: string
|
|
@@ -76,6 +86,7 @@ const props = withDefaults(
|
|
|
76
86
|
const l = computed(() => ({
|
|
77
87
|
title: props.title ?? props.labels?.title ?? "Notifications",
|
|
78
88
|
markAllRead: props.labels?.markAllRead ?? "Mark all read",
|
|
89
|
+
clear: props.labels?.clear ?? "Clear",
|
|
79
90
|
markAsRead: props.labels?.markAsRead ?? "Mark as read",
|
|
80
91
|
markAsUnread: props.labels?.markAsUnread ?? "Mark as unread",
|
|
81
92
|
open: props.labels?.open ?? "Open",
|
|
@@ -94,6 +105,8 @@ const emit = defineEmits<{
|
|
|
94
105
|
(e: "markRead", id: string): void
|
|
95
106
|
(e: "markUnread", id: string): void
|
|
96
107
|
(e: "markAllRead"): void
|
|
108
|
+
/** Empty the whole feed. The header button only appears when this is bound. */
|
|
109
|
+
(e: "clear"): void
|
|
97
110
|
(e: "dismiss", id: string): void
|
|
98
111
|
(e: "select", item: NotificationItem): void
|
|
99
112
|
}>()
|
|
@@ -106,21 +119,52 @@ const badgeLabel = computed(() =>
|
|
|
106
119
|
)
|
|
107
120
|
const hasUnread = computed(() => unread.value > 0)
|
|
108
121
|
|
|
122
|
+
/** Icon tints for an unread row, keyed by the item's category tone. */
|
|
123
|
+
const TONE_CLASS: Record<string, string> = {
|
|
124
|
+
brand: "bg-brand-soft text-brand",
|
|
125
|
+
action: "bg-action-soft text-action",
|
|
126
|
+
danger: "bg-danger-soft text-danger",
|
|
127
|
+
accent: "bg-accent-soft text-accent",
|
|
128
|
+
muted: "bg-surface-soft text-muted-strong",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
* Which optional actions the consumer actually handles. A button for an event
|
|
133
|
+
* nobody listens to is a button that does nothing when pressed.
|
|
134
|
+
*
|
|
135
|
+
* NOT `useAttrs()`: a listener for a DECLARED emit is removed from `$attrs`, so
|
|
136
|
+
* an `@clear` handler never appears there. The raw vnode props are where it
|
|
137
|
+
* survives.
|
|
138
|
+
*/
|
|
139
|
+
const instance = getCurrentInstance()
|
|
140
|
+
const handlesClear = computed(() => Boolean(instance?.vnode.props?.onClear))
|
|
141
|
+
|
|
109
142
|
// Right-click quick actions on the bell. "Open" is intentionally omitted — a
|
|
110
143
|
// left-click already opens the feed, so the menu only carries actions you
|
|
111
144
|
// can't get from the click itself. Because `open` is a defineModel we open on
|
|
112
145
|
// left-click ourselves (the popover is anchor-only), which frees the trigger
|
|
113
146
|
// button to also host LpContextMenu without the two fighting over the trigger.
|
|
114
|
-
const contextMenu = computed<ContextMenuItemDef[]>(() =>
|
|
115
|
-
props.menuItems
|
|
147
|
+
const contextMenu = computed<ContextMenuItemDef[]>(() => {
|
|
148
|
+
if (props.menuItems) return props.menuItems
|
|
149
|
+
const items: ContextMenuItemDef[] = [
|
|
116
150
|
{
|
|
117
151
|
label: l.value.markAllRead,
|
|
118
152
|
icon: "lucide:check-check",
|
|
119
153
|
disabled: !hasUnread.value,
|
|
120
154
|
onSelect: () => emit("markAllRead"),
|
|
121
155
|
},
|
|
122
|
-
]
|
|
123
|
-
|
|
156
|
+
]
|
|
157
|
+
// Same rule as the header button: offered only when the caller can act on it.
|
|
158
|
+
if (handlesClear.value) {
|
|
159
|
+
items.push({
|
|
160
|
+
label: l.value.clear,
|
|
161
|
+
icon: "lucide:trash-2",
|
|
162
|
+
disabled: !props.items.length,
|
|
163
|
+
onSelect: () => emit("clear"),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return items
|
|
167
|
+
})
|
|
124
168
|
|
|
125
169
|
function onSelect(item: NotificationItem) {
|
|
126
170
|
if (!item.read) emit("markRead", item.id)
|
|
@@ -216,14 +260,28 @@ function timeAgo(iso?: string): string {
|
|
|
216
260
|
|
|
217
261
|
<header class="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
218
262
|
<span class="text-sm font-semibold">{{ l.title }}</span>
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
263
|
+
<span class="flex items-center gap-0.5">
|
|
264
|
+
<!-- Emptying the feed is a different act from reading it, and it is
|
|
265
|
+
destructive, so it is muted and sits away from the primary action.
|
|
266
|
+
Only offered when a listener is bound: a caller with no way to
|
|
267
|
+
clear its feed must not be given a button that does nothing. -->
|
|
268
|
+
<button
|
|
269
|
+
v-if="items.length && handlesClear"
|
|
270
|
+
type="button"
|
|
271
|
+
class="rounded-md px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors hover:bg-surface-soft hover:text-ink focus-visible:bg-surface-soft"
|
|
272
|
+
@click="emit('clear')"
|
|
273
|
+
>
|
|
274
|
+
{{ l.clear }}
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
v-if="hasUnread"
|
|
278
|
+
type="button"
|
|
279
|
+
class="rounded-md px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
|
|
280
|
+
@click="emit('markAllRead')"
|
|
281
|
+
>
|
|
282
|
+
{{ l.markAllRead }}
|
|
283
|
+
</button>
|
|
284
|
+
</span>
|
|
227
285
|
</header>
|
|
228
286
|
|
|
229
287
|
<!-- Skeleton → empty → feed are three different heights, so the panel
|
|
@@ -250,13 +308,16 @@ function timeAgo(iso?: string): string {
|
|
|
250
308
|
:is="item.link ? 'a' : 'button'"
|
|
251
309
|
:href="item.link || undefined"
|
|
252
310
|
: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-
|
|
311
|
+
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-surface-soft focus-visible:bg-surface-soft"
|
|
254
312
|
:class="item.read ? '' : 'bg-brand-soft/35'"
|
|
255
313
|
@click="onSelect(item)"
|
|
256
314
|
>
|
|
315
|
+
<!-- A read notification is always muted: the tone says what kind of
|
|
316
|
+
thing it is, and that only needs to compete for attention while
|
|
317
|
+
it is still unread. -->
|
|
257
318
|
<span
|
|
258
319
|
class="grid size-7 shrink-0 place-items-center rounded-control"
|
|
259
|
-
:class="item.read ? 'bg-surface-soft text-muted' : '
|
|
320
|
+
:class="item.read ? 'bg-surface-soft text-muted' : TONE_CLASS[item.tone ?? 'brand']"
|
|
260
321
|
>
|
|
261
322
|
<LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
|
|
262
323
|
</span>
|