@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,244 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Free-text autocomplete — a text field that suggests as you type but never
4
+ * forces a choice. Unlike LpSelect (which binds to an option value and requires
5
+ * picking from the list), the typed text IS the model here: suggestions are
6
+ * just fill-in helpers. Use it for tags, hostnames, search-with-history, etc.
7
+ *
8
+ * Built on reka Combobox so we inherit the accessible listbox wiring (roving
9
+ * focus, aria-activedescendant, type-ahead). The list is decoupled from the
10
+ * value: choosing a suggestion only fills the text, and free input always wins.
11
+ *
12
+ * `restrict`/`pattern` reuse the same cursor- and IME-safe filter as LpInput.
13
+ */
14
+ import {
15
+ ComboboxAnchor,
16
+ ComboboxContent,
17
+ ComboboxEmpty,
18
+ ComboboxInput,
19
+ ComboboxItem,
20
+ ComboboxPortal,
21
+ ComboboxRoot,
22
+ ComboboxViewport,
23
+ } from "reka-ui"
24
+ import { computed, ref, watch } from "vue"
25
+ import { useInputFilter } from "../composables/useInputFilter"
26
+ import { CLOSE_ICON, OPTION_ITEM, POPOVER_PANEL } from "./dropdown"
27
+ import LpIcon from "./LpIcon.vue"
28
+
29
+ export interface AutocompleteOption {
30
+ value: string
31
+ label?: string
32
+ description?: string
33
+ }
34
+
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ /** The free text. This is the model — bind with v-model. */
38
+ modelValue?: string
39
+ /** Suggestions. Strings or {value,label,description}. */
40
+ options?: (AutocompleteOption | string)[]
41
+ placeholder?: string
42
+ disabled?: boolean
43
+ clearable?: boolean
44
+ size?: "sm" | "md" | "lg"
45
+ icon?: string
46
+ invalid?: boolean
47
+ emptyText?: string
48
+ loading?: boolean
49
+ loadingText?: string
50
+ /**
51
+ * Filter suggestions client-side against the typed text. Off when the
52
+ * parent fetches matches itself (server-side) — then pass the already
53
+ * narrowed `options` and leave this false.
54
+ */
55
+ filter?: boolean
56
+ /** Min chars before the list opens. 0 = open on focus. */
57
+ minChars?: number
58
+ /** Validation RegExp/string for the whole value (see LpInput). */
59
+ pattern?: RegExp | string
60
+ /** Allowed-character RegExp; hard-blocks other input (see LpInput). */
61
+ restrict?: RegExp
62
+ }>(),
63
+ {
64
+ placeholder: "Type…",
65
+ size: "md",
66
+ clearable: false,
67
+ filter: true,
68
+ minChars: 1,
69
+ emptyText: "No suggestions",
70
+ loading: false,
71
+ loadingText: "Searching…",
72
+ },
73
+ )
74
+
75
+ const emit = defineEmits<{
76
+ (e: "update:modelValue", value: string): void
77
+ /** Fired when a suggestion is explicitly chosen (not on free typing). */
78
+ (e: "select", value: string): void
79
+ }>()
80
+
81
+ const { isInvalid, onBeforeInput, onPaste } = useInputFilter({
82
+ pattern: () => props.pattern,
83
+ restrict: () => props.restrict,
84
+ })
85
+
86
+ const text = computed<string>({
87
+ get: () => props.modelValue ?? "",
88
+ set: (v) => emit("update:modelValue", v),
89
+ })
90
+
91
+ const isInvalidState = computed(() => props.invalid || isInvalid(props.modelValue))
92
+
93
+ // Normalise string|option to a stable shape.
94
+ const items = computed<AutocompleteOption[]>(() =>
95
+ (props.options ?? []).map((o) =>
96
+ typeof o === "string" ? { value: o } : o,
97
+ ),
98
+ )
99
+
100
+ const filtered = computed(() => {
101
+ const q = text.value.trim().toLowerCase()
102
+ if (!props.filter || !q) return items.value
103
+ return items.value.filter((o) => {
104
+ const hay = `${o.label ?? o.value} ${o.description ?? ""}`.toLowerCase()
105
+ return hay.includes(q)
106
+ })
107
+ })
108
+
109
+ const open = ref(false)
110
+ const focused = ref(false)
111
+
112
+ // Threshold met AND something to show → the list is allowed to open.
113
+ const canOpen = computed(
114
+ () =>
115
+ text.value.length >= props.minChars &&
116
+ (props.loading || filtered.value.length > 0),
117
+ )
118
+
119
+ // Drive `open` REACTIVELY from canOpen instead of reading it inside @input — the
120
+ // input event fires before the value round-trips into `text`, so reading canOpen
121
+ // there is one keystroke stale (type one char → nothing; type a second → the
122
+ // list for the first appears). Watching it recomputes after `text` settles.
123
+ //
124
+ // Open-only: once the user is focused and the list has opened we DON'T auto-close
125
+ // it when the text is cleared — it stays up until Esc, blur, or a selection. So
126
+ // we only ever flip `open` to true here, never back to false.
127
+ watch(canOpen, (can) => {
128
+ if (focused.value && can) open.value = true
129
+ })
130
+
131
+ const hasValue = computed(() => (props.modelValue ?? "").length > 0)
132
+
133
+ const anchorSize = {
134
+ sm: "h-(--size-control-sm) text-xs",
135
+ md: "h-(--size-control-md) text-sm",
136
+ lg: "h-(--size-control-lg) text-sm",
137
+ }
138
+
139
+ function onFocus() {
140
+ focused.value = true
141
+ if (canOpen.value) open.value = true
142
+ }
143
+
144
+ function choose(opt: AutocompleteOption) {
145
+ emit("update:modelValue", opt.value)
146
+ emit("select", opt.value)
147
+ open.value = false
148
+ }
149
+
150
+ function clear() {
151
+ emit("update:modelValue", "")
152
+ open.value = false
153
+ }
154
+ </script>
155
+
156
+ <template>
157
+ <ComboboxRoot
158
+ v-model:open="open"
159
+ :ignore-filter="true"
160
+ :reset-search-term-on-blur="false"
161
+ :reset-search-term-on-select="false"
162
+ :disabled="disabled"
163
+ class="relative"
164
+ >
165
+ <ComboboxAnchor
166
+ class="flex w-full items-center gap-2 rounded-control border bg-surface-soft px-3 text-ink transition-colors duration-[var(--duration-fast)] focus-within:ring-2 focus-within:ring-ring data-[disabled]:cursor-not-allowed data-[disabled]:opacity-55"
167
+ :class="[
168
+ anchorSize[size],
169
+ isInvalidState
170
+ ? 'border-danger focus-within:border-danger focus-within:ring-danger-soft'
171
+ : 'border-line focus-within:border-brand',
172
+ ]"
173
+ >
174
+ <LpIcon v-if="icon" :name="icon" :size="16" class="shrink-0 text-muted" />
175
+ <!--
176
+ Bind value one-way and update from the raw DOM event, NOT via reka's
177
+ ComboboxInput v-model: reka defers the value/search update to nextTick
178
+ when the list is closed (see processInputValue), which makes the first
179
+ keystroke lag by one char. Reading event.target.value here is synchronous
180
+ so `text` (and the reactive `open`) track the field exactly.
181
+ -->
182
+ <ComboboxInput
183
+ :model-value="text"
184
+ :placeholder="placeholder"
185
+ :disabled="disabled"
186
+ :aria-invalid="isInvalidState || undefined"
187
+ auto-focus
188
+ class="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted"
189
+ @beforeinput="onBeforeInput"
190
+ @paste="onPaste"
191
+ @input="text = ($event.target as HTMLInputElement).value"
192
+ @focus="onFocus"
193
+ @blur="focused = false"
194
+ />
195
+ <button
196
+ v-if="clearable && hasValue"
197
+ type="button"
198
+ class="group flex shrink-0 items-center text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
199
+ aria-label="Clear"
200
+ @pointerdown.prevent="clear"
201
+ >
202
+ <LpIcon
203
+ name="lucide:x"
204
+ :size="16"
205
+ :class="CLOSE_ICON"
206
+ />
207
+ </button>
208
+ </ComboboxAnchor>
209
+
210
+ <ComboboxPortal>
211
+ <ComboboxContent
212
+ position="popper"
213
+ :side-offset="6"
214
+ :class="[POPOVER_PANEL, 'z-(--z-popover) max-h-72 w-(--reka-combobox-trigger-width) overflow-hidden']"
215
+ @open-auto-focus.prevent
216
+ >
217
+ <ComboboxViewport class="max-h-72 overflow-y-auto p-1">
218
+ <div v-if="loading" class="px-3 py-4 text-center text-sm italic text-muted">
219
+ {{ loadingText }}
220
+ </div>
221
+ <ComboboxEmpty v-else class="px-3 py-4 text-center text-sm italic text-muted">
222
+ {{ emptyText }}
223
+ </ComboboxEmpty>
224
+
225
+ <ComboboxItem
226
+ v-for="(opt, i) in filtered"
227
+ :key="opt.value"
228
+ :value="opt.value"
229
+ :style="{ animationDelay: `${Math.min(i, 12) * 22}ms` }"
230
+ :class="[OPTION_ITEM, 'justify-between']"
231
+ @select.prevent="choose(opt)"
232
+ >
233
+ <span class="flex min-w-0 flex-col">
234
+ <span class="truncate">{{ opt.label ?? opt.value }}</span>
235
+ <span v-if="opt.description" class="truncate text-xs text-muted">
236
+ {{ opt.description }}
237
+ </span>
238
+ </span>
239
+ </ComboboxItem>
240
+ </ComboboxViewport>
241
+ </ComboboxContent>
242
+ </ComboboxPortal>
243
+ </ComboboxRoot>
244
+ </template>
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ // Opt out of auto attr inheritance: the template has two root branches (menu /
3
+ // no-menu), so fallthrough class/attrs/events are bound explicitly onto
4
+ // AvatarRoot via v-bind="$attrs" in both.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
8
+ <script setup lang="ts">
9
+ import { AvatarFallback, AvatarImage, AvatarRoot } from "reka-ui"
10
+ import { tv, type VariantProps } from "tailwind-variants"
11
+ import { computed, onMounted, ref } from "vue"
12
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
13
+
14
+ const avatar = tv({
15
+ base: "inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-pill bg-surface-soft font-medium text-muted-strong",
16
+ variants: {
17
+ size: {
18
+ sm: "size-7 text-xs",
19
+ md: "size-9 text-sm",
20
+ lg: "size-12 text-base",
21
+ },
22
+ },
23
+ defaultVariants: { size: "md" },
24
+ })
25
+
26
+ type AvatarVariants = VariantProps<typeof avatar>
27
+
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ src?: string
31
+ alt?: string
32
+ fallback?: string
33
+ size?: AvatarVariants["size"]
34
+ /** Right-click menu (e.g. account quick actions). Consumer-supplied. */
35
+ menuItems?: ContextMenuItemDef[]
36
+ }>(),
37
+ { size: "md" },
38
+ )
39
+
40
+ const initials = computed(
41
+ () => props.fallback ?? props.alt?.slice(0, 2).toUpperCase() ?? "?",
42
+ )
43
+
44
+ // reka's AvatarImage resolves the image load on the client, so SSR renders the
45
+ // fallback while the client swaps in <img> — a hydration mismatch for every
46
+ // consumer. Gate the image on mount: server and first client render both show
47
+ // the fallback (identical markup), then the image fades in post-hydration. This
48
+ // keeps the fix in one place instead of each call site wrapping us in
49
+ // <ClientOnly>.
50
+ const mounted = ref(false)
51
+ onMounted(() => {
52
+ mounted.value = true
53
+ })
54
+ </script>
55
+
56
+ <template>
57
+ <LpContextMenu v-if="menuItems?.length" :items="menuItems">
58
+ <AvatarRoot :class="avatar({ size })" v-bind="$attrs">
59
+ <AvatarImage v-if="src && mounted" :src="src" :alt="alt" class="size-full object-cover" />
60
+ <AvatarFallback>{{ initials }}</AvatarFallback>
61
+ </AvatarRoot>
62
+ </LpContextMenu>
63
+ <AvatarRoot v-else :class="avatar({ size })" v-bind="$attrs">
64
+ <AvatarImage v-if="src" :src="src" :alt="alt" class="size-full object-cover" />
65
+ <AvatarFallback>{{ initials }}</AvatarFallback>
66
+ </AvatarRoot>
67
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { tv, type VariantProps } from "tailwind-variants"
3
+ import { computed } from "vue"
4
+
5
+ const badge = tv({
6
+ base: "inline-flex items-center gap-1.5 rounded-pill px-2.5 py-0.5 text-xs font-medium",
7
+ variants: {
8
+ tone: {
9
+ neutral: "bg-surface-soft text-muted-strong",
10
+ brand: "bg-brand-soft text-brand",
11
+ action: "bg-action/15 text-action",
12
+ success: "bg-action/15 text-action",
13
+ danger: "bg-danger-soft text-danger",
14
+ outline: "border border-line text-ink",
15
+ },
16
+ },
17
+ defaultVariants: { tone: "neutral" },
18
+ })
19
+
20
+ type BadgeVariants = VariantProps<typeof badge>
21
+
22
+ const props = defineProps<{ tone?: BadgeVariants["tone"]; dot?: boolean }>()
23
+
24
+ const classes = computed(() => badge({ tone: props.tone }))
25
+ </script>
26
+
27
+ <template>
28
+ <span :class="classes">
29
+ <span v-if="dot" class="size-1.5 rounded-full bg-current" />
30
+ <slot />
31
+ </span>
32
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import LpIcon from "./LpIcon.vue"
3
+
4
+ export interface Crumb {
5
+ label: string
6
+ href?: string
7
+ }
8
+
9
+ defineProps<{ items: Crumb[] }>()
10
+ defineEmits<{ (e: "navigate", item: Crumb, index: number): void }>()
11
+ </script>
12
+
13
+ <template>
14
+ <nav class="flex items-center gap-1.5 text-sm" aria-label="Breadcrumb">
15
+ <template v-for="(item, i) in items" :key="i">
16
+ <LpIcon
17
+ v-if="i > 0"
18
+ name="lucide:chevron-right"
19
+ :size="14"
20
+ class="text-muted"
21
+ />
22
+ <a
23
+ v-if="item.href && i < items.length - 1"
24
+ :href="item.href"
25
+ class="rounded-xs text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
26
+ @click="$emit('navigate', item, i)"
27
+ >
28
+ {{ item.label }}
29
+ </a>
30
+ <span v-else :class="i === items.length - 1 ? 'text-ink' : 'text-muted'">
31
+ {{ item.label }}
32
+ </span>
33
+ </template>
34
+ </nav>
35
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { tv, type VariantProps } from "tailwind-variants"
3
+ import { computed, type Component } from "vue"
4
+
5
+ const button = tv({
6
+ base: [
7
+ "inline-flex select-none items-center justify-center gap-2 font-semibold",
8
+ "rounded-control transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]",
9
+ // tactile press: dips slightly on click, springs back on release.
10
+ "active:scale-[0.97] motion-reduce:active:scale-100",
11
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring",
12
+ "disabled:cursor-not-allowed disabled:opacity-55 aria-disabled:cursor-not-allowed aria-disabled:opacity-55",
13
+ ],
14
+ variants: {
15
+ variant: {
16
+ solid: "bg-brand text-ink-inverse hover:bg-brand-hover",
17
+ action:
18
+ "bg-action text-ink-inverse shadow-[0_14px_28px_color-mix(in_srgb,var(--color-action)_24%,transparent)] hover:bg-action-hover",
19
+ outline: "border border-line-strong text-ink hover:border-brand hover:bg-white/[0.04]",
20
+ // raised surface tile with a border — a visible, neutral filled button
21
+ soft: "border border-line bg-surface-raised/80 text-ink hover:border-line-strong hover:bg-surface-soft",
22
+ ghost: "text-ink hover:bg-white/[0.06]",
23
+ muted: "text-muted hover:bg-white/[0.04] hover:text-ink",
24
+ // high-contrast light fill (e.g. a "download" CTA on a dark hero)
25
+ light: "border border-white/20 bg-white text-ink-inverse hover:bg-white/90",
26
+ danger: "bg-danger text-ink-inverse hover:bg-danger-hover",
27
+ },
28
+ size: {
29
+ xs: "h-8 px-2.5 text-xs",
30
+ sm: "h-(--size-control-sm) px-3 text-xs",
31
+ md: "h-(--size-control-md) px-4 text-sm",
32
+ lg: "h-(--size-control-lg) px-5 text-sm",
33
+ },
34
+ block: { true: "w-full" },
35
+ // icon-only square button — equal sides, no horizontal padding
36
+ square: { true: "aspect-square p-0" },
37
+ // soft elevation glow keyed to the variant's colour
38
+ glow: { true: "" },
39
+ },
40
+ compoundVariants: [
41
+ { square: true, size: "xs", class: "size-8" },
42
+ { square: true, size: "sm", class: "size-(--size-control-sm)" },
43
+ { square: true, size: "md", class: "size-(--size-control-md)" },
44
+ { square: true, size: "lg", class: "size-(--size-control-lg)" },
45
+ {
46
+ glow: true,
47
+ variant: "solid",
48
+ class:
49
+ "shadow-[0_8px_22px_color-mix(in_srgb,var(--color-brand)_28%,transparent)] hover:shadow-[0_12px_28px_color-mix(in_srgb,var(--color-brand)_36%,transparent)]",
50
+ },
51
+ {
52
+ glow: true,
53
+ variant: "danger",
54
+ class:
55
+ "shadow-[0_8px_22px_color-mix(in_srgb,var(--color-danger)_28%,transparent)] hover:shadow-[0_12px_28px_color-mix(in_srgb,var(--color-danger)_36%,transparent)]",
56
+ },
57
+ {
58
+ glow: true,
59
+ variant: "light",
60
+ class:
61
+ "shadow-[0_8px_18px_rgba(255,255,255,0.2)] hover:shadow-[0_12px_24px_rgba(255,255,255,0.3)]",
62
+ },
63
+ ],
64
+ defaultVariants: { variant: "solid", size: "md" },
65
+ })
66
+
67
+ type ButtonVariants = VariantProps<typeof button>
68
+
69
+ const props = withDefaults(
70
+ defineProps<{
71
+ variant?: ButtonVariants["variant"]
72
+ size?: ButtonVariants["size"]
73
+ block?: boolean
74
+ square?: boolean
75
+ glow?: boolean
76
+ /**
77
+ * Root element/component. Defaults to a native <button>. Pass a router link
78
+ * component (e.g. NuxtLink) or "a" to render a link-styled button; the kit
79
+ * stays DOM-agnostic, the consumer supplies the link component.
80
+ */
81
+ as?: string | Component
82
+ type?: "button" | "submit" | "reset"
83
+ disabled?: boolean
84
+ }>(),
85
+ { as: "button", type: "button" },
86
+ )
87
+
88
+ const isNativeButton = computed(() => props.as === "button")
89
+
90
+ const classes = computed(() =>
91
+ button({
92
+ variant: props.variant,
93
+ size: props.size,
94
+ block: props.block,
95
+ square: props.square,
96
+ glow: props.glow,
97
+ }),
98
+ )
99
+ </script>
100
+
101
+ <template>
102
+ <component
103
+ :is="as"
104
+ :type="isNativeButton ? type : undefined"
105
+ :disabled="isNativeButton ? disabled : undefined"
106
+ :aria-disabled="!isNativeButton && disabled ? 'true' : undefined"
107
+ :class="classes"
108
+ >
109
+ <slot />
110
+ </component>
111
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Inline month calendar (reka Calendar). The kit-facing model is an ISO date
4
+ * STRING ("YYYY-MM-DD"), not reka's DateValue — we convert at the boundary so
5
+ * callers never touch @internationalized/date. Single-date selection; min/max
6
+ * bounds and an isDisabled predicate (also taking ISO strings). Themed via
7
+ * tokens, with the brand pill marking the selected day.
8
+ */
9
+ import { type DateValue, parseDate, today, getLocalTimeZone } from "@internationalized/date"
10
+ import {
11
+ CalendarCell,
12
+ CalendarCellTrigger,
13
+ CalendarGrid,
14
+ CalendarGridBody,
15
+ CalendarGridHead,
16
+ CalendarGridRow,
17
+ CalendarHeadCell,
18
+ CalendarHeader,
19
+ CalendarHeading,
20
+ CalendarNext,
21
+ CalendarPrev,
22
+ CalendarRoot,
23
+ } from "reka-ui"
24
+ import { computed } from "vue"
25
+ import LpIcon from "./LpIcon.vue"
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /** Selected date as "YYYY-MM-DD" (v-model). */
30
+ modelValue?: string
31
+ /** Earliest selectable date, "YYYY-MM-DD". */
32
+ min?: string
33
+ /** Latest selectable date, "YYYY-MM-DD". */
34
+ max?: string
35
+ /** Predicate to disable specific days; receives the ISO string. */
36
+ isDisabled?: (iso: string) => boolean
37
+ weekdayFormat?: "narrow" | "short" | "long"
38
+ }>(),
39
+ { weekdayFormat: "short" },
40
+ )
41
+
42
+ const emit = defineEmits<{
43
+ (e: "update:modelValue", value: string | undefined): void
44
+ }>()
45
+
46
+ // ISO string ⇄ reka DateValue. parseDate throws on junk, so guard.
47
+ function toDate(iso?: string): DateValue | undefined {
48
+ if (!iso) return undefined
49
+ try {
50
+ return parseDate(iso)
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ const value = computed<DateValue | undefined>(() => toDate(props.modelValue))
57
+ const minValue = computed(() => toDate(props.min))
58
+ const maxValue = computed(() => toDate(props.max))
59
+ const placeholderValue = computed(() => value.value ?? today(getLocalTimeZone()))
60
+
61
+ function onUpdate(v: DateValue | undefined) {
62
+ emit("update:modelValue", v?.toString())
63
+ }
64
+
65
+ function disabledFor(date: DateValue): boolean {
66
+ return props.isDisabled?.(date.toString()) ?? false
67
+ }
68
+
69
+ const navBtn =
70
+ "flex size-8 items-center justify-center rounded-control text-muted outline-none transition-[color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-surface-soft hover:text-ink active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring"
71
+ </script>
72
+
73
+ <template>
74
+ <CalendarRoot
75
+ v-slot="{ weekDays, grid }"
76
+ :model-value="value"
77
+ :min-value="minValue"
78
+ :max-value="maxValue"
79
+ :placeholder="placeholderValue"
80
+ :weekday-format="weekdayFormat"
81
+ :is-date-disabled="disabledFor"
82
+ fixed-weeks
83
+ class="inline-block rounded-card border border-line bg-surface-raised p-3"
84
+ @update:model-value="onUpdate"
85
+ >
86
+ <CalendarHeader class="mb-2 flex items-center justify-between gap-2">
87
+ <CalendarPrev :class="navBtn" aria-label="Previous month">
88
+ <LpIcon name="lucide:chevron-left" :size="16" />
89
+ </CalendarPrev>
90
+ <CalendarHeading class="text-sm font-semibold text-ink" />
91
+ <CalendarNext :class="navBtn" aria-label="Next month">
92
+ <LpIcon name="lucide:chevron-right" :size="16" />
93
+ </CalendarNext>
94
+ </CalendarHeader>
95
+
96
+ <CalendarGrid
97
+ v-for="month in grid"
98
+ :key="month.value.toString()"
99
+ class="w-full border-collapse select-none"
100
+ >
101
+ <CalendarGridHead>
102
+ <CalendarGridRow class="mb-1 flex">
103
+ <CalendarHeadCell
104
+ v-for="day in weekDays"
105
+ :key="day"
106
+ class="w-9 text-center text-[11px] font-medium text-muted"
107
+ >
108
+ {{ day }}
109
+ </CalendarHeadCell>
110
+ </CalendarGridRow>
111
+ </CalendarGridHead>
112
+ <CalendarGridBody>
113
+ <CalendarGridRow
114
+ v-for="(weekDates, i) in month.rows"
115
+ :key="`w-${i}`"
116
+ class="flex"
117
+ >
118
+ <CalendarCell
119
+ v-for="weekDate in weekDates"
120
+ :key="weekDate.toString()"
121
+ :date="weekDate"
122
+ class="p-0.5"
123
+ >
124
+ <CalendarCellTrigger
125
+ :day="weekDate"
126
+ :month="month.value"
127
+ class="flex size-8 items-center justify-center rounded-control text-sm text-ink outline-none transition-[color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-surface-soft active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring data-[outside-view]:text-muted/40 data-[today]:font-semibold data-[today]:not-data-[selected]:text-brand data-[selected]:bg-brand data-[selected]:font-semibold data-[selected]:text-ink-inverse data-[selected]:hover:bg-brand-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-35 data-[unavailable]:pointer-events-none data-[unavailable]:line-through data-[unavailable]:opacity-35"
128
+ />
129
+ </CalendarCell>
130
+ </CalendarGridRow>
131
+ </CalendarGridBody>
132
+ </CalendarGrid>
133
+ </CalendarRoot>
134
+ </template>