@leavepulse/ui 0.12.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Notification bell: an icon button with an unread badge that opens a popover
|
|
4
|
+
* feed on left-click and a quick-action menu on right-click. Each feed row has
|
|
5
|
+
* its own right-click menu too. Presentational and data-driven — the consumer
|
|
6
|
+
* fetches via the SDK, binds `items` / `unread-count` / `v-model:open`, and
|
|
7
|
+
* reacts to `mark-read` / `mark-unread` / `mark-all-read` / `dismiss` /
|
|
8
|
+
* `select`. Mirrors the kit convention (cf. LpSidebar): data in, events out,
|
|
9
|
+
* no data fetching inside.
|
|
10
|
+
*/
|
|
11
|
+
import { computed } from "vue"
|
|
12
|
+
import LpBadge from "./LpBadge.vue"
|
|
13
|
+
import LpButton from "./LpButton.vue"
|
|
14
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
15
|
+
import LpEmptyState from "./LpEmptyState.vue"
|
|
16
|
+
import LpIcon from "./LpIcon.vue"
|
|
17
|
+
import LpPopover from "./LpPopover.vue"
|
|
18
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
19
|
+
|
|
20
|
+
export interface NotificationItem {
|
|
21
|
+
id: string
|
|
22
|
+
title: string
|
|
23
|
+
body?: string
|
|
24
|
+
/** Optional icon name (e.g. "lucide:check-circle"). */
|
|
25
|
+
icon?: string
|
|
26
|
+
/** Optional deep link; the row becomes clickable when set. */
|
|
27
|
+
link?: string
|
|
28
|
+
/** ISO-8601 creation time; rendered as a relative label. */
|
|
29
|
+
createdAt?: string
|
|
30
|
+
/** Whether the notification has been read. */
|
|
31
|
+
read?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const props = withDefaults(
|
|
35
|
+
defineProps<{
|
|
36
|
+
items?: NotificationItem[]
|
|
37
|
+
/** Unread count for the badge; falls back to counting unread items. */
|
|
38
|
+
unreadCount?: number
|
|
39
|
+
loading?: boolean
|
|
40
|
+
emptyLabel?: string
|
|
41
|
+
title?: string
|
|
42
|
+
/** Cap the badge display (e.g. "9+"). */
|
|
43
|
+
maxBadge?: number
|
|
44
|
+
/** Right-click menu items; overrides the built-in quick actions. */
|
|
45
|
+
menuItems?: ContextMenuItemDef[]
|
|
46
|
+
}>(),
|
|
47
|
+
{ items: () => [], maxBadge: 9 },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Popover open state. A defineModel (not a plain `open` prop) gives a local
|
|
51
|
+
// fallback, so the bell still opens when the consumer only listens to
|
|
52
|
+
// @update:open without binding :open — otherwise the popover is stuck in
|
|
53
|
+
// controlled-but-uncontrolled limbo and never opens.
|
|
54
|
+
const open = defineModel<boolean>("open", { default: false })
|
|
55
|
+
|
|
56
|
+
const emit = defineEmits<{
|
|
57
|
+
(e: "markRead", id: string): void
|
|
58
|
+
(e: "markUnread", id: string): void
|
|
59
|
+
(e: "markAllRead"): void
|
|
60
|
+
(e: "dismiss", id: string): void
|
|
61
|
+
(e: "select", item: NotificationItem): void
|
|
62
|
+
}>()
|
|
63
|
+
|
|
64
|
+
const unread = computed(() =>
|
|
65
|
+
props.unreadCount ?? props.items.filter((n) => !n.read).length,
|
|
66
|
+
)
|
|
67
|
+
const badgeLabel = computed(() =>
|
|
68
|
+
unread.value > props.maxBadge ? `${props.maxBadge}+` : String(unread.value),
|
|
69
|
+
)
|
|
70
|
+
const hasUnread = computed(() => unread.value > 0)
|
|
71
|
+
|
|
72
|
+
// Right-click quick actions on the bell. "Open" is intentionally omitted — a
|
|
73
|
+
// left-click already opens the feed, so the menu only carries actions you
|
|
74
|
+
// can't get from the click itself. Because `open` is a defineModel we open on
|
|
75
|
+
// left-click ourselves (the popover is anchor-only), which frees the trigger
|
|
76
|
+
// button to also host LpContextMenu without the two fighting over the trigger.
|
|
77
|
+
const contextMenu = computed<ContextMenuItemDef[]>(() =>
|
|
78
|
+
props.menuItems ?? [
|
|
79
|
+
{
|
|
80
|
+
label: "Mark all read",
|
|
81
|
+
icon: "lucide:check-check",
|
|
82
|
+
disabled: !hasUnread.value,
|
|
83
|
+
onSelect: () => emit("markAllRead"),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
function onSelect(item: NotificationItem) {
|
|
89
|
+
if (!item.read) emit("markRead", item.id)
|
|
90
|
+
emit("select", item)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Per-row right-click actions: toggle read state, open a deep link, dismiss.
|
|
94
|
+
// Producers wire the matching @mark-read / @mark-unread / @dismiss handlers; an
|
|
95
|
+
// action only appears when it makes sense for that row.
|
|
96
|
+
function rowMenu(item: NotificationItem): ContextMenuItemDef[] {
|
|
97
|
+
const actions: ContextMenuItemDef[] = []
|
|
98
|
+
if (item.read) {
|
|
99
|
+
actions.push({
|
|
100
|
+
label: "Mark as unread",
|
|
101
|
+
icon: "lucide:dot",
|
|
102
|
+
onSelect: () => emit("markUnread", item.id),
|
|
103
|
+
})
|
|
104
|
+
} else {
|
|
105
|
+
actions.push({
|
|
106
|
+
label: "Mark as read",
|
|
107
|
+
icon: "lucide:check",
|
|
108
|
+
onSelect: () => emit("markRead", item.id),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
if (item.link) {
|
|
112
|
+
actions.push({
|
|
113
|
+
label: "Open",
|
|
114
|
+
icon: "lucide:external-link",
|
|
115
|
+
onSelect: () => emit("select", item),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
actions.push({
|
|
119
|
+
label: "Dismiss",
|
|
120
|
+
icon: "lucide:x",
|
|
121
|
+
danger: true,
|
|
122
|
+
separatorBefore: true,
|
|
123
|
+
onSelect: () => emit("dismiss", item.id),
|
|
124
|
+
})
|
|
125
|
+
return actions
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Compact relative time ("just now", "5m", "3h", "2d"); falls back to a date.
|
|
129
|
+
function timeAgo(iso?: string): string {
|
|
130
|
+
if (!iso) return ""
|
|
131
|
+
const then = new Date(iso).getTime()
|
|
132
|
+
if (Number.isNaN(then)) return ""
|
|
133
|
+
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
|
134
|
+
if (secs < 45) return "just now"
|
|
135
|
+
const mins = Math.round(secs / 60)
|
|
136
|
+
if (mins < 60) return `${mins}m`
|
|
137
|
+
const hrs = Math.round(mins / 60)
|
|
138
|
+
if (hrs < 24) return `${hrs}h`
|
|
139
|
+
const days = Math.round(hrs / 24)
|
|
140
|
+
if (days < 7) return `${days}d`
|
|
141
|
+
return new Date(then).toLocaleDateString()
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<LpPopover
|
|
147
|
+
v-model:open="open"
|
|
148
|
+
side="bottom"
|
|
149
|
+
align="end"
|
|
150
|
+
:side-offset="8"
|
|
151
|
+
panel-class="w-80 p-0"
|
|
152
|
+
>
|
|
153
|
+
<!-- `open` is controlled via defineModel, so the popover trigger is just an
|
|
154
|
+
anchor: we toggle open on click ourselves. That frees the button to
|
|
155
|
+
also be wrapped in LpContextMenu for the right-click quick-actions menu
|
|
156
|
+
(left-click → feed, right-click → menu) without two triggers clashing. -->
|
|
157
|
+
<template #trigger>
|
|
158
|
+
<LpContextMenu :items="contextMenu">
|
|
159
|
+
<LpButton
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="sm"
|
|
162
|
+
class="relative"
|
|
163
|
+
aria-label="Notifications"
|
|
164
|
+
@click="open = !open"
|
|
165
|
+
>
|
|
166
|
+
<LpIcon name="lucide:bell" :size="18" />
|
|
167
|
+
<LpBadge
|
|
168
|
+
v-if="hasUnread"
|
|
169
|
+
tone="danger"
|
|
170
|
+
class="absolute -right-1 -top-1 min-w-4 justify-center px-1 py-0 text-[10px] leading-4"
|
|
171
|
+
>
|
|
172
|
+
{{ badgeLabel }}
|
|
173
|
+
</LpBadge>
|
|
174
|
+
</LpButton>
|
|
175
|
+
</LpContextMenu>
|
|
176
|
+
</template>
|
|
177
|
+
|
|
178
|
+
<header class="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
179
|
+
<span class="text-sm font-semibold">{{ title ?? "Notifications" }}</span>
|
|
180
|
+
<button
|
|
181
|
+
v-if="hasUnread"
|
|
182
|
+
type="button"
|
|
183
|
+
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"
|
|
184
|
+
@click="emit('markAllRead')"
|
|
185
|
+
>
|
|
186
|
+
Mark all read
|
|
187
|
+
</button>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
<div v-if="loading" class="space-y-1.5 px-2 pb-2">
|
|
191
|
+
<div v-for="i in 4" :key="i" class="h-12 animate-pulse rounded-control bg-surface-soft" />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<LpEmptyState
|
|
195
|
+
v-else-if="!items.length"
|
|
196
|
+
icon="lucide:bell-off"
|
|
197
|
+
:title="emptyLabel ?? 'No notifications'"
|
|
198
|
+
class="px-3 py-10"
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<!-- The feed sits in a thin gutter; rows are self-contained tiles (rounded,
|
|
202
|
+
hover-lit) instead of full-width strips with hard dividers — lighter and
|
|
203
|
+
easier to scan. Each row carries its own right-click action menu. -->
|
|
204
|
+
<LpScrollArea v-else class="max-h-96" content-class="flex flex-col gap-0.5 px-2 pb-2">
|
|
205
|
+
<LpContextMenu v-for="item in items" :key="item.id" :items="rowMenu(item)">
|
|
206
|
+
<component
|
|
207
|
+
:is="item.link ? 'a' : 'button'"
|
|
208
|
+
:href="item.link || undefined"
|
|
209
|
+
:type="item.link ? undefined : 'button'"
|
|
210
|
+
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]"
|
|
211
|
+
:class="item.read ? '' : 'bg-brand-soft/35'"
|
|
212
|
+
@click="onSelect(item)"
|
|
213
|
+
>
|
|
214
|
+
<span
|
|
215
|
+
class="grid size-7 shrink-0 place-items-center rounded-control"
|
|
216
|
+
:class="item.read ? 'bg-surface-soft text-muted' : 'bg-brand-soft text-brand'"
|
|
217
|
+
>
|
|
218
|
+
<LpIcon :name="item.icon || 'lucide:bell'" :size="15" />
|
|
219
|
+
</span>
|
|
220
|
+
<span class="min-w-0 flex-1">
|
|
221
|
+
<span class="flex items-center gap-1.5">
|
|
222
|
+
<span class="truncate text-sm font-medium" :class="item.read ? 'text-muted-strong' : 'text-ink'">
|
|
223
|
+
{{ item.title }}
|
|
224
|
+
</span>
|
|
225
|
+
<span v-if="!item.read" class="size-1.5 shrink-0 rounded-full bg-brand" />
|
|
226
|
+
</span>
|
|
227
|
+
<span v-if="item.body" class="mt-0.5 line-clamp-2 block text-xs text-muted">
|
|
228
|
+
{{ item.body }}
|
|
229
|
+
</span>
|
|
230
|
+
<span v-if="item.createdAt" class="mt-0.5 block text-[11px] text-muted/70">
|
|
231
|
+
{{ timeAgo(item.createdAt) }}
|
|
232
|
+
</span>
|
|
233
|
+
</span>
|
|
234
|
+
</component>
|
|
235
|
+
</LpContextMenu>
|
|
236
|
+
</LpScrollArea>
|
|
237
|
+
</LpPopover>
|
|
238
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Numeric input with stepper buttons (reka NumberField). Themed via tokens.
|
|
3
|
+
import {
|
|
4
|
+
NumberFieldDecrement,
|
|
5
|
+
NumberFieldIncrement,
|
|
6
|
+
NumberFieldInput,
|
|
7
|
+
NumberFieldRoot,
|
|
8
|
+
} from "reka-ui"
|
|
9
|
+
import LpIcon from "./LpIcon.vue"
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
// null = empty field (reka's native state); consumers that need a number
|
|
13
|
+
// should default it themselves, the field doesn't invent a value.
|
|
14
|
+
modelValue?: number | null
|
|
15
|
+
min?: number
|
|
16
|
+
max?: number
|
|
17
|
+
step?: number
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
defineEmits<{ (e: "update:modelValue", value: number | null): void }>()
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<NumberFieldRoot
|
|
26
|
+
:model-value="modelValue ?? undefined"
|
|
27
|
+
:min="min"
|
|
28
|
+
:max="max"
|
|
29
|
+
:step="step"
|
|
30
|
+
:disabled="disabled"
|
|
31
|
+
class="flex h-(--size-control-md) w-full items-center rounded-control border border-line bg-surface-soft transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55"
|
|
32
|
+
@update:model-value="(v) => $emit('update:modelValue', v ?? null)"
|
|
33
|
+
>
|
|
34
|
+
<NumberFieldDecrement
|
|
35
|
+
class="group grid h-full w-8 place-items-center text-muted transition-colors duration-[var(--duration-fast)] hover:text-ink disabled:opacity-40"
|
|
36
|
+
>
|
|
37
|
+
<LpIcon
|
|
38
|
+
name="lucide:minus"
|
|
39
|
+
:size="15"
|
|
40
|
+
class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active:scale-75 motion-reduce:group-active:scale-100"
|
|
41
|
+
/>
|
|
42
|
+
</NumberFieldDecrement>
|
|
43
|
+
<NumberFieldInput
|
|
44
|
+
class="min-w-0 flex-1 bg-transparent text-center text-sm text-ink outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
|
45
|
+
/>
|
|
46
|
+
<NumberFieldIncrement
|
|
47
|
+
class="group grid h-full w-8 place-items-center text-muted transition-colors duration-[var(--duration-fast)] hover:text-ink disabled:opacity-40"
|
|
48
|
+
>
|
|
49
|
+
<LpIcon
|
|
50
|
+
name="lucide:plus"
|
|
51
|
+
:size="15"
|
|
52
|
+
class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active:scale-75 motion-reduce:group-active:scale-100"
|
|
53
|
+
/>
|
|
54
|
+
</NumberFieldIncrement>
|
|
55
|
+
</NumberFieldRoot>
|
|
56
|
+
</template>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* One-time-code input (TOTP / 2FA). A row of single-character cells with the
|
|
4
|
+
* usual OTP ergonomics — auto-advance, backspace-to-previous, and pasting the
|
|
5
|
+
* whole code spreads it across the cells — all from reka PinInput.
|
|
6
|
+
*
|
|
7
|
+
* The kit-facing model is a single STRING (the joined code), not reka's per-cell
|
|
8
|
+
* array: `v-model` is "123456", and @complete fires that string once every cell
|
|
9
|
+
* is filled. Digits-only by default (TOTP); flip `alphanumeric` for backup codes.
|
|
10
|
+
*/
|
|
11
|
+
import { PinInputInput, PinInputRoot } from "reka-ui"
|
|
12
|
+
import { computed } from "vue"
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
/** The code as one string, e.g. "123456". Bind with v-model. */
|
|
17
|
+
modelValue?: string
|
|
18
|
+
/** Number of cells. */
|
|
19
|
+
length?: number
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
/** Mask entered characters (•) — for sensitive codes. */
|
|
22
|
+
mask?: boolean
|
|
23
|
+
/** Allow letters too (backup codes). Default: digits only (TOTP). */
|
|
24
|
+
alphanumeric?: boolean
|
|
25
|
+
/** Error state — tints the cells red (e.g. wrong code). */
|
|
26
|
+
invalid?: boolean
|
|
27
|
+
size?: "sm" | "md" | "lg"
|
|
28
|
+
}>(),
|
|
29
|
+
{ length: 6, size: "md", alphanumeric: false },
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: "update:modelValue", value: string): void
|
|
34
|
+
/** Fired once every cell is filled. Carries the full joined code. */
|
|
35
|
+
(e: "complete", value: string): void
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
// reka models the value as an array of per-cell strings; we bridge to a single
|
|
39
|
+
// string at the boundary so callers never touch the array shape.
|
|
40
|
+
const cells = computed<string[]>({
|
|
41
|
+
get: () => (props.modelValue ?? "").split("").slice(0, props.length),
|
|
42
|
+
set: (arr) => emit("update:modelValue", arr.join("")),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function onComplete(arr: string[]) {
|
|
46
|
+
emit("complete", arr.join(""))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cellSize = {
|
|
50
|
+
sm: "size-9 text-base",
|
|
51
|
+
md: "size-11 text-lg",
|
|
52
|
+
lg: "size-13 text-xl",
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<PinInputRoot
|
|
58
|
+
v-model="cells"
|
|
59
|
+
:type="alphanumeric ? 'text' : 'number'"
|
|
60
|
+
:mask="mask"
|
|
61
|
+
:disabled="disabled"
|
|
62
|
+
otp
|
|
63
|
+
class="flex items-center gap-2"
|
|
64
|
+
@complete="onComplete"
|
|
65
|
+
>
|
|
66
|
+
<PinInputInput
|
|
67
|
+
v-for="i in length"
|
|
68
|
+
:key="i"
|
|
69
|
+
:index="i - 1"
|
|
70
|
+
:aria-invalid="invalid || undefined"
|
|
71
|
+
:class="[
|
|
72
|
+
cellSize[size],
|
|
73
|
+
'rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand',
|
|
74
|
+
'outline-none transition-colors duration-[var(--duration-fast)]',
|
|
75
|
+
'focus:border-brand focus:ring-2 focus:ring-ring',
|
|
76
|
+
'disabled:cursor-not-allowed disabled:opacity-55',
|
|
77
|
+
invalid ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line',
|
|
78
|
+
]"
|
|
79
|
+
/>
|
|
80
|
+
</PinInputRoot>
|
|
81
|
+
</template>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Page navigator. Pairs with LpTable but stands alone.
|
|
4
|
+
*
|
|
5
|
+
* Fixed-width window: we always render the SAME number of slots in the SAME
|
|
6
|
+
* positions — first, an inner run of (2*siblings+1) pages, last, and the two
|
|
7
|
+
* ellipsis spots. As the page changes only the *labels* in those slots change;
|
|
8
|
+
* no slot is added or removed, so the row never reflows and nothing jumps.
|
|
9
|
+
* Because slots are keyed by position (not page number), Vue reuses each node
|
|
10
|
+
* and just swaps its number — which lets the brand pill glide between slots via
|
|
11
|
+
* motion-v's shared layoutId with zero list-animation conflicts.
|
|
12
|
+
*/
|
|
13
|
+
import { Motion } from "motion-v"
|
|
14
|
+
import { computed, useId } from "vue"
|
|
15
|
+
import { usePillTransition } from "../composables/usePillTransition"
|
|
16
|
+
import LpIcon from "./LpIcon.vue"
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(
|
|
19
|
+
defineProps<{
|
|
20
|
+
/** Current page, 1-based (v-model:page). */
|
|
21
|
+
page?: number
|
|
22
|
+
/** Total item count — used with pageSize to derive the page count. */
|
|
23
|
+
total?: number
|
|
24
|
+
pageSize?: number
|
|
25
|
+
/** Direct page count; wins over total/pageSize when given. */
|
|
26
|
+
pageCount?: number
|
|
27
|
+
/** Pages shown on each side of the current one in the inner run. */
|
|
28
|
+
siblings?: number
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
}>(),
|
|
31
|
+
{ page: 1, pageSize: 10, siblings: 1 },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: "update:page", value: number): void
|
|
36
|
+
(e: "change", value: number): void
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
const count = computed(() => {
|
|
40
|
+
if (props.pageCount != null) return Math.max(1, props.pageCount)
|
|
41
|
+
if (props.total != null) return Math.max(1, Math.ceil(props.total / props.pageSize))
|
|
42
|
+
return 1
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const current = computed(() => Math.min(Math.max(1, props.page), count.value))
|
|
46
|
+
|
|
47
|
+
// A fixed run of page slots. Each slot is either a real page or a gap ("…").
|
|
48
|
+
// The number of slots is constant for a given `count`, so the row width is
|
|
49
|
+
// stable as the user pages through.
|
|
50
|
+
type Slot = { kind: "page"; page: number } | { kind: "gap" }
|
|
51
|
+
|
|
52
|
+
const slots = computed<Slot[]>(() => {
|
|
53
|
+
const c = count.value
|
|
54
|
+
const cur = current.value
|
|
55
|
+
const inner = props.siblings * 2 + 1 // pages shown around (and including) current
|
|
56
|
+
// Total = first + leftGap + inner + rightGap + last. Constant for a given
|
|
57
|
+
// count, so the row width never changes as you page through.
|
|
58
|
+
const total = inner + 4
|
|
59
|
+
|
|
60
|
+
// Few enough pages to list them all (still a fixed run = count).
|
|
61
|
+
if (c <= total) {
|
|
62
|
+
return Array.from({ length: c }, (_, i) => ({ kind: "page", page: i + 1 }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inner window [lo..hi] of length `inner`, clamped into [3 .. c-2] so there's
|
|
66
|
+
// always a slot for first(1)+page 2 on the left and page c-1+last(c) on right.
|
|
67
|
+
let lo = cur - props.siblings
|
|
68
|
+
let hi = cur + props.siblings
|
|
69
|
+
if (lo < 3) {
|
|
70
|
+
hi += 3 - lo
|
|
71
|
+
lo = 3
|
|
72
|
+
}
|
|
73
|
+
if (hi > c - 2) {
|
|
74
|
+
lo -= hi - (c - 2)
|
|
75
|
+
hi = c - 2
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const out: Slot[] = [{ kind: "page", page: 1 }]
|
|
79
|
+
// Dedicated gap slots: a gap when the window doesn't already touch the edge,
|
|
80
|
+
// otherwise the adjacent real page — so the slot COUNT stays fixed either way.
|
|
81
|
+
out.push(lo > 3 ? { kind: "gap" } : { kind: "page", page: 2 })
|
|
82
|
+
for (let p = lo; p <= hi; p++) out.push({ kind: "page", page: p })
|
|
83
|
+
out.push(hi < c - 2 ? { kind: "gap" } : { kind: "page", page: c - 1 })
|
|
84
|
+
out.push({ kind: "page", page: c })
|
|
85
|
+
return out
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function go(p: number) {
|
|
89
|
+
if (props.disabled) return
|
|
90
|
+
const next = Math.min(Math.max(1, p), count.value)
|
|
91
|
+
if (next === current.value) return
|
|
92
|
+
emit("update:page", next)
|
|
93
|
+
emit("change", next)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Geometry + interaction. No text/bg colour here — the pill paints the active
|
|
97
|
+
// fill, the per-state text colour below sits on top.
|
|
98
|
+
const navBase =
|
|
99
|
+
"relative flex size-(--size-control-sm) items-center justify-center rounded-control text-sm outline-none transition-[color,border-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] active:scale-95 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100"
|
|
100
|
+
|
|
101
|
+
const navArrow =
|
|
102
|
+
"border border-line text-muted hover:border-line-strong hover:text-ink disabled:hover:border-line disabled:hover:text-muted"
|
|
103
|
+
|
|
104
|
+
// The sliding brand pill (shared layoutId) glides under the current page.
|
|
105
|
+
const pillId = `lp-page-indicator-${useId()}`
|
|
106
|
+
const pillTransition = usePillTransition()
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<nav class="flex items-center gap-1.5" aria-label="Pagination">
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
:class="[navBase, navArrow]"
|
|
114
|
+
:disabled="disabled || current === 1"
|
|
115
|
+
aria-label="Previous page"
|
|
116
|
+
@click="go(current - 1)"
|
|
117
|
+
>
|
|
118
|
+
<LpIcon name="lucide:chevron-left" :size="16" />
|
|
119
|
+
</button>
|
|
120
|
+
|
|
121
|
+
<!-- Keyed by POSITION (slot-i), not page number, so Vue reuses each node and
|
|
122
|
+
only swaps the label — the row never re-creates buttons, so nothing
|
|
123
|
+
flickers and the pill can travel smoothly between slots. -->
|
|
124
|
+
<template v-for="(slot, i) in slots" :key="`slot-${i}`">
|
|
125
|
+
<span
|
|
126
|
+
v-if="slot.kind === 'gap'"
|
|
127
|
+
class="flex size-(--size-control-sm) items-center justify-center text-sm text-muted select-none"
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
>…</span
|
|
130
|
+
>
|
|
131
|
+
<button
|
|
132
|
+
v-else
|
|
133
|
+
type="button"
|
|
134
|
+
:class="[
|
|
135
|
+
navBase,
|
|
136
|
+
'min-w-(--size-control-sm) px-2',
|
|
137
|
+
slot.page === current
|
|
138
|
+
? 'text-ink-inverse'
|
|
139
|
+
: 'border border-line text-muted hover:border-line-strong hover:text-ink',
|
|
140
|
+
]"
|
|
141
|
+
:disabled="disabled"
|
|
142
|
+
:aria-current="slot.page === current ? 'page' : undefined"
|
|
143
|
+
@click="go(slot.page)"
|
|
144
|
+
>
|
|
145
|
+
<Motion
|
|
146
|
+
v-if="slot.page === current"
|
|
147
|
+
:layout-id="pillId"
|
|
148
|
+
:transition="pillTransition"
|
|
149
|
+
class="absolute inset-0 z-0 rounded-control bg-brand"
|
|
150
|
+
/>
|
|
151
|
+
<span class="relative z-10">{{ slot.page }}</span>
|
|
152
|
+
</button>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
:class="[navBase, navArrow]"
|
|
158
|
+
:disabled="disabled || current === count"
|
|
159
|
+
aria-label="Next page"
|
|
160
|
+
@click="go(current + 1)"
|
|
161
|
+
>
|
|
162
|
+
<LpIcon name="lucide:chevron-right" :size="16" />
|
|
163
|
+
</button>
|
|
164
|
+
</nav>
|
|
165
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export default { inheritAttrs: false }
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
// Password field = LpInput + a show/hide toggle in its trailing slot.
|
|
7
|
+
// No duplicated field styling — the shell lives in LpInput.
|
|
8
|
+
import { ref } from "vue"
|
|
9
|
+
import LpIcon from "./LpIcon.vue"
|
|
10
|
+
import LpInput from "./LpInput.vue"
|
|
11
|
+
|
|
12
|
+
defineProps<{ modelValue?: string; placeholder?: string; invalid?: boolean; disabled?: boolean }>()
|
|
13
|
+
defineEmits<{ (e: "update:modelValue", value: string): void }>()
|
|
14
|
+
|
|
15
|
+
const visible = ref(false)
|
|
16
|
+
const inner = ref<InstanceType<typeof LpInput> | null>(null)
|
|
17
|
+
defineExpose({
|
|
18
|
+
focus: (options?: FocusOptions) => inner.value?.focus(options),
|
|
19
|
+
blur: () => inner.value?.blur(),
|
|
20
|
+
select: () => inner.value?.select(),
|
|
21
|
+
})
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<LpInput
|
|
26
|
+
ref="inner"
|
|
27
|
+
:model-value="modelValue"
|
|
28
|
+
:type="visible ? 'text' : 'password'"
|
|
29
|
+
:placeholder="placeholder"
|
|
30
|
+
:invalid="invalid"
|
|
31
|
+
:disabled="disabled"
|
|
32
|
+
@update:model-value="(v) => $emit('update:modelValue', v)"
|
|
33
|
+
>
|
|
34
|
+
<template v-if="$slots.leading" #leading><slot name="leading" /></template>
|
|
35
|
+
<template #trailing>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
class="group rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:text-ink"
|
|
39
|
+
:aria-label="visible ? 'Hide password' : 'Show password'"
|
|
40
|
+
@click="visible = !visible"
|
|
41
|
+
>
|
|
42
|
+
<LpIcon
|
|
43
|
+
:name="visible ? 'lucide:eye-off' : 'lucide:eye'"
|
|
44
|
+
:size="16"
|
|
45
|
+
class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active:scale-90 motion-reduce:group-active:scale-100"
|
|
46
|
+
/>
|
|
47
|
+
</button>
|
|
48
|
+
</template>
|
|
49
|
+
</LpInput>
|
|
50
|
+
</template>
|