@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,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>
|