@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/component-names.d.ts +1 -0
  4. package/dist/components/LayoutCanvas.vue.d.ts +28 -0
  5. package/dist/components/LayoutNode.vue.d.ts +40 -0
  6. package/dist/components/LpAlert.vue.d.ts +44 -0
  7. package/dist/components/LpAppShell.vue.d.ts +83 -0
  8. package/dist/components/LpAutocomplete.vue.d.ts +50 -0
  9. package/dist/components/LpAvatar.vue.d.ts +20 -0
  10. package/dist/components/LpBadge.vue.d.ts +47 -0
  11. package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
  12. package/dist/components/LpButton.vue.d.ts +113 -0
  13. package/dist/components/LpCalendar.vue.d.ts +20 -0
  14. package/dist/components/LpCard.vue.d.ts +29 -0
  15. package/dist/components/LpCheckbox.vue.d.ts +28 -0
  16. package/dist/components/LpCodeBlock.vue.d.ts +39 -0
  17. package/dist/components/LpCommandPalette.vue.d.ts +42 -0
  18. package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
  19. package/dist/components/LpContextMenu.vue.d.ts +30 -0
  20. package/dist/components/LpDatePicker.vue.d.ts +25 -0
  21. package/dist/components/LpDisclosure.vue.d.ts +37 -0
  22. package/dist/components/LpDivider.vue.d.ts +17 -0
  23. package/dist/components/LpDrawer.vue.d.ts +88 -0
  24. package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
  25. package/dist/components/LpEmptyState.vue.d.ts +18 -0
  26. package/dist/components/LpFormField.vue.d.ts +21 -0
  27. package/dist/components/LpIcon.vue.d.ts +8 -0
  28. package/dist/components/LpInfraNode.vue.d.ts +21 -0
  29. package/dist/components/LpInput.vue.d.ts +68 -0
  30. package/dist/components/LpLaneNode.vue.d.ts +24 -0
  31. package/dist/components/LpLink.vue.d.ts +27 -0
  32. package/dist/components/LpLogViewer.vue.d.ts +92 -0
  33. package/dist/components/LpModal.vue.d.ts +43 -0
  34. package/dist/components/LpNotificationBell.vue.d.ts +51 -0
  35. package/dist/components/LpNumberField.vue.d.ts +14 -0
  36. package/dist/components/LpOtpInput.vue.d.ts +27 -0
  37. package/dist/components/LpPagination.vue.d.ts +25 -0
  38. package/dist/components/LpPasswordInput.vue.d.ts +28 -0
  39. package/dist/components/LpPhoneInput.vue.d.ts +37 -0
  40. package/dist/components/LpPopover.vue.d.ts +34 -0
  41. package/dist/components/LpProgress.vue.d.ts +12 -0
  42. package/dist/components/LpRadio.vue.d.ts +18 -0
  43. package/dist/components/LpRadioGroup.vue.d.ts +26 -0
  44. package/dist/components/LpScrollArea.vue.d.ts +24 -0
  45. package/dist/components/LpSegmented.vue.d.ts +24 -0
  46. package/dist/components/LpSelect.vue.d.ts +45 -0
  47. package/dist/components/LpServiceNode.vue.d.ts +14 -0
  48. package/dist/components/LpSidebar.vue.d.ts +85 -0
  49. package/dist/components/LpSidebarNav.vue.d.ts +34 -0
  50. package/dist/components/LpSkeleton.vue.d.ts +6 -0
  51. package/dist/components/LpSlider.vue.d.ts +23 -0
  52. package/dist/components/LpStat.vue.d.ts +22 -0
  53. package/dist/components/LpStepper.vue.d.ts +10 -0
  54. package/dist/components/LpSwitch.vue.d.ts +11 -0
  55. package/dist/components/LpTable.vue.d.ts +63 -0
  56. package/dist/components/LpTableOfContents.vue.d.ts +19 -0
  57. package/dist/components/LpTabs.vue.d.ts +45 -0
  58. package/dist/components/LpTextarea.vue.d.ts +17 -0
  59. package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
  60. package/dist/components/LpToaster.vue.d.ts +3 -0
  61. package/dist/components/LpTooltip.vue.d.ts +19 -0
  62. package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
  63. package/dist/components/LpUptimeBar.vue.d.ts +30 -0
  64. package/dist/components/codeHighlight.d.ts +8 -0
  65. package/dist/components/countries-data.d.ts +2 -0
  66. package/dist/components/countries.d.ts +20 -0
  67. package/dist/components/dropdown.d.ts +29 -0
  68. package/dist/components/sidebar.d.ts +18 -0
  69. package/dist/composables/useInputFilter.d.ts +14 -0
  70. package/dist/composables/usePillTransition.d.ts +9 -0
  71. package/dist/composables/useToast.d.ts +39 -0
  72. package/dist/countries-data-9rgqJWHo.js +203 -0
  73. package/dist/icons.d.ts +1 -0
  74. package/dist/index.css +1 -0
  75. package/dist/index.d.ts +100 -0
  76. package/dist/index.js +17600 -0
  77. package/dist/layout/registry.d.ts +26 -0
  78. package/dist/layout/tree.d.ts +75 -0
  79. package/dist/layout/useLayout.d.ts +39 -0
  80. package/dist/theme/config.d.ts +16 -0
  81. package/dist/theme/define.d.ts +12 -0
  82. package/dist/theme/presets.d.ts +20 -0
  83. package/dist/theme/tokens.d.ts +72 -0
  84. package/dist/theme/useTheme.d.ts +36 -0
  85. package/package.json +93 -0
  86. package/src/canvas.css +67 -0
  87. package/src/component-names.ts +65 -0
  88. package/src/components/LayoutCanvas.vue +76 -0
  89. package/src/components/LayoutNode.vue +248 -0
  90. package/src/components/LpAlert.vue +59 -0
  91. package/src/components/LpAppShell.vue +210 -0
  92. package/src/components/LpAutocomplete.vue +244 -0
  93. package/src/components/LpAvatar.vue +67 -0
  94. package/src/components/LpBadge.vue +32 -0
  95. package/src/components/LpBreadcrumbs.vue +35 -0
  96. package/src/components/LpButton.vue +111 -0
  97. package/src/components/LpCalendar.vue +134 -0
  98. package/src/components/LpCard.vue +71 -0
  99. package/src/components/LpCheckbox.vue +44 -0
  100. package/src/components/LpCodeBlock.vue +298 -0
  101. package/src/components/LpCommandPalette.vue +323 -0
  102. package/src/components/LpConfirmDialog.vue +49 -0
  103. package/src/components/LpContextMenu.vue +111 -0
  104. package/src/components/LpDatePicker.vue +117 -0
  105. package/src/components/LpDisclosure.vue +92 -0
  106. package/src/components/LpDivider.vue +20 -0
  107. package/src/components/LpDrawer.vue +402 -0
  108. package/src/components/LpDropdownMenu.vue +54 -0
  109. package/src/components/LpEmptyState.vue +21 -0
  110. package/src/components/LpFormField.vue +31 -0
  111. package/src/components/LpIcon.vue +49 -0
  112. package/src/components/LpInfraNode.vue +108 -0
  113. package/src/components/LpInput.vue +147 -0
  114. package/src/components/LpLaneNode.vue +48 -0
  115. package/src/components/LpLink.vue +48 -0
  116. package/src/components/LpLogViewer.vue +569 -0
  117. package/src/components/LpModal.vue +123 -0
  118. package/src/components/LpNotificationBell.vue +238 -0
  119. package/src/components/LpNumberField.vue +56 -0
  120. package/src/components/LpOtpInput.vue +81 -0
  121. package/src/components/LpPagination.vue +165 -0
  122. package/src/components/LpPasswordInput.vue +50 -0
  123. package/src/components/LpPhoneInput.vue +241 -0
  124. package/src/components/LpPopover.vue +42 -0
  125. package/src/components/LpProgress.vue +30 -0
  126. package/src/components/LpRadio.vue +28 -0
  127. package/src/components/LpRadioGroup.vue +43 -0
  128. package/src/components/LpScrollArea.vue +82 -0
  129. package/src/components/LpSegmented.vue +83 -0
  130. package/src/components/LpSelect.vue +0 -0
  131. package/src/components/LpServiceNode.vue +57 -0
  132. package/src/components/LpSidebar.vue +200 -0
  133. package/src/components/LpSidebarNav.vue +118 -0
  134. package/src/components/LpSkeleton.vue +16 -0
  135. package/src/components/LpSlider.vue +53 -0
  136. package/src/components/LpStat.vue +90 -0
  137. package/src/components/LpStepper.vue +46 -0
  138. package/src/components/LpSwitch.vue +19 -0
  139. package/src/components/LpTable.vue +252 -0
  140. package/src/components/LpTableOfContents.vue +183 -0
  141. package/src/components/LpTabs.vue +120 -0
  142. package/src/components/LpTextarea.vue +39 -0
  143. package/src/components/LpThemeSwitcher.vue +168 -0
  144. package/src/components/LpToaster.vue +216 -0
  145. package/src/components/LpTooltip.vue +30 -0
  146. package/src/components/LpTopologyCanvas.vue +370 -0
  147. package/src/components/LpUptimeBar.vue +126 -0
  148. package/src/components/codeHighlight.ts +137 -0
  149. package/src/components/countries-data.ts +207 -0
  150. package/src/components/countries.ts +67 -0
  151. package/src/components/dropdown.ts +64 -0
  152. package/src/components/sidebar.ts +26 -0
  153. package/src/composables/useInputFilter.ts +89 -0
  154. package/src/composables/usePillTransition.ts +19 -0
  155. package/src/composables/useToast.ts +92 -0
  156. package/src/icons.ts +11 -0
  157. package/src/index.css +21 -0
  158. package/src/index.ts +146 -0
  159. package/src/layout/registry.ts +44 -0
  160. package/src/layout/tree.ts +288 -0
  161. package/src/layout/useLayout.ts +134 -0
  162. package/src/nuxt.ts +22 -0
  163. package/src/theme/config.ts +48 -0
  164. package/src/theme/define.ts +50 -0
  165. package/src/theme/presets.ts +279 -0
  166. package/src/theme/tokens.ts +151 -0
  167. package/src/theme/useTheme.ts +288 -0
  168. package/src/tokens/scrollbar.css +138 -0
  169. package/src/tokens/tokens.css +333 -0
  170. 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>