@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,241 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Phone number field — a country picker (flag + dial code) fused with a free-text
|
|
4
|
+
* number input. Picking a country sets the "+code" prefix; you can also just type
|
|
5
|
+
* the whole "+…" yourself and the flag follows what you typed (longest dial-code
|
|
6
|
+
* match wins). The model is the full string ("+380501234567"); a `change` event
|
|
7
|
+
* also carries the parsed { country, dialCode, number } for forms that want it.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT a full phone validator (no libphonenumber) — it stays free
|
|
10
|
+
* text so any number is allowed; `restrict` only blocks obviously non-phone keys.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
ComboboxAnchor,
|
|
14
|
+
ComboboxContent,
|
|
15
|
+
ComboboxEmpty,
|
|
16
|
+
ComboboxInput,
|
|
17
|
+
ComboboxItem,
|
|
18
|
+
ComboboxPortal,
|
|
19
|
+
ComboboxRoot,
|
|
20
|
+
ComboboxTrigger,
|
|
21
|
+
ComboboxViewport,
|
|
22
|
+
} from "reka-ui"
|
|
23
|
+
import { computed, onMounted, ref } from "vue"
|
|
24
|
+
import { useInputFilter } from "../composables/useInputFilter"
|
|
25
|
+
import { type Country, flagEmoji, loadCountries, matchCountryByValue } from "./countries"
|
|
26
|
+
import { OPTION_ITEM, POPOVER_PANEL } from "./dropdown"
|
|
27
|
+
import LpIcon from "./LpIcon.vue"
|
|
28
|
+
|
|
29
|
+
export interface PhoneDetail {
|
|
30
|
+
/** Matched country, or undefined when the prefix matches none. */
|
|
31
|
+
country?: Country
|
|
32
|
+
/** Dial code without "+", e.g. "380". Empty if unknown. */
|
|
33
|
+
dialCode: string
|
|
34
|
+
/** National part (digits after the dial code). */
|
|
35
|
+
number: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
/** Full phone string, e.g. "+380501234567". Bind with v-model. */
|
|
41
|
+
modelValue?: string
|
|
42
|
+
/** Restrict the country list (ISO2 codes). Omit for all countries. */
|
|
43
|
+
only?: string[]
|
|
44
|
+
/** Default country (ISO2) used when the field is empty. */
|
|
45
|
+
defaultCountry?: string
|
|
46
|
+
placeholder?: string
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
invalid?: boolean
|
|
49
|
+
size?: "sm" | "md" | "lg"
|
|
50
|
+
emptyText?: string
|
|
51
|
+
searchPlaceholder?: string
|
|
52
|
+
}>(),
|
|
53
|
+
{
|
|
54
|
+
placeholder: "Phone number",
|
|
55
|
+
size: "md",
|
|
56
|
+
emptyText: "No country",
|
|
57
|
+
searchPlaceholder: "Search country…",
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const emit = defineEmits<{
|
|
62
|
+
(e: "update:modelValue", value: string): void
|
|
63
|
+
(e: "change", detail: PhoneDetail): void
|
|
64
|
+
}>()
|
|
65
|
+
|
|
66
|
+
// Phone keys only: digits, "+", spaces and the usual separators. Free typing of
|
|
67
|
+
// "+code" stays possible; letters and junk are blocked at the source.
|
|
68
|
+
const PHONE_CHARS = /[\d+()\-\s]/
|
|
69
|
+
const { onBeforeInput, onPaste } = useInputFilter({ restrict: () => PHONE_CHARS })
|
|
70
|
+
|
|
71
|
+
// The dial-code table is code-split; it's empty until the lazy chunk resolves.
|
|
72
|
+
// Everything degrades gracefully meanwhile: digits still parse, the flag shows a
|
|
73
|
+
// neutral globe, and the list opens once data arrives.
|
|
74
|
+
const allCountries = ref<Country[]>([])
|
|
75
|
+
|
|
76
|
+
const countries = computed<Country[]>(() => {
|
|
77
|
+
if (!props.only?.length) return allCountries.value
|
|
78
|
+
const set = new Set(props.only.map((c) => c.toUpperCase()))
|
|
79
|
+
return allCountries.value.filter((c) => set.has(c.iso2))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Country derived from the current value (flag follows typing). Falls back to
|
|
83
|
+
// the default country while the field is empty.
|
|
84
|
+
const fallback = computed(() => {
|
|
85
|
+
const iso = props.defaultCountry?.toUpperCase()
|
|
86
|
+
return iso ? countries.value.find((c) => c.iso2 === iso) : undefined
|
|
87
|
+
})
|
|
88
|
+
const activeCountry = computed<Country | undefined>(
|
|
89
|
+
() => matchCountryByValue(props.modelValue ?? "", countries.value) ?? fallback.value,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const open = ref(false)
|
|
93
|
+
const query = ref("")
|
|
94
|
+
|
|
95
|
+
const filtered = computed(() => {
|
|
96
|
+
const q = query.value.trim().toLowerCase()
|
|
97
|
+
if (!q) return countries.value
|
|
98
|
+
return countries.value.filter(
|
|
99
|
+
(c) => c.name.toLowerCase().includes(q) || c.dial.includes(q.replace(/\D/g, "")),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
function parse(value: string): PhoneDetail {
|
|
104
|
+
const country = matchCountryByValue(value, countries.value)
|
|
105
|
+
const digits = value.replace(/[^\d]/g, "")
|
|
106
|
+
if (country) {
|
|
107
|
+
return { country, dialCode: country.dial, number: digits.slice(country.dial.length) }
|
|
108
|
+
}
|
|
109
|
+
return { country: undefined, dialCode: "", number: digits }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setValue(value: string) {
|
|
113
|
+
emit("update:modelValue", value)
|
|
114
|
+
emit("change", parse(value))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onInput(e: Event) {
|
|
118
|
+
setValue((e.target as HTMLInputElement).value)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pick a country from the list: swap the dial prefix, keep the national number.
|
|
123
|
+
* If the field had a recognised country, replace just its code; otherwise prefix
|
|
124
|
+
* the new code onto whatever digits are present.
|
|
125
|
+
*/
|
|
126
|
+
function chooseCountry(c: Country) {
|
|
127
|
+
const detail = parse(props.modelValue ?? "")
|
|
128
|
+
const national = detail.number
|
|
129
|
+
setValue(`+${c.dial}${national ? " " + national : ""}`)
|
|
130
|
+
open.value = false
|
|
131
|
+
query.value = ""
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Load the dial-code table on mount (lazy chunk), then seed the default
|
|
135
|
+
// country's "+code" into an empty field so the user starts with the prefix in
|
|
136
|
+
// place — but never clobber a value the consumer already provided.
|
|
137
|
+
onMounted(async () => {
|
|
138
|
+
allCountries.value = await loadCountries()
|
|
139
|
+
if (!props.modelValue && fallback.value) {
|
|
140
|
+
emit("update:modelValue", `+${fallback.value.dial} `)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const sizeClass = {
|
|
145
|
+
sm: "h-(--size-control-sm) text-xs",
|
|
146
|
+
md: "h-(--size-control-md) text-sm",
|
|
147
|
+
lg: "h-(--size-control-lg) text-sm",
|
|
148
|
+
}
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<div
|
|
153
|
+
class="flex w-full items-stretch rounded-control border bg-surface-soft text-ink transition-colors duration-[var(--duration-fast)] focus-within:ring-2 focus-within:ring-ring"
|
|
154
|
+
:class="[
|
|
155
|
+
sizeClass[size],
|
|
156
|
+
invalid
|
|
157
|
+
? 'border-danger focus-within:border-danger focus-within:ring-danger-soft'
|
|
158
|
+
: 'border-line focus-within:border-brand',
|
|
159
|
+
disabled ? 'cursor-not-allowed opacity-55' : '',
|
|
160
|
+
]"
|
|
161
|
+
>
|
|
162
|
+
<ComboboxRoot
|
|
163
|
+
v-model:open="open"
|
|
164
|
+
:ignore-filter="true"
|
|
165
|
+
:disabled="disabled"
|
|
166
|
+
class="flex"
|
|
167
|
+
>
|
|
168
|
+
<ComboboxAnchor as-child>
|
|
169
|
+
<ComboboxTrigger
|
|
170
|
+
type="button"
|
|
171
|
+
:disabled="disabled"
|
|
172
|
+
class="flex shrink-0 items-center gap-1 rounded-l-control border-r border-line px-2.5 outline-none hover:bg-white/[0.04] data-[state=open]:bg-white/[0.04]"
|
|
173
|
+
aria-label="Select country"
|
|
174
|
+
>
|
|
175
|
+
<span class="text-base leading-none">{{ activeCountry ? flagEmoji(activeCountry.iso2) : "🌐" }}</span>
|
|
176
|
+
<LpIcon
|
|
177
|
+
name="lucide:chevron-down"
|
|
178
|
+
:size="14"
|
|
179
|
+
class="text-muted transition-transform data-[state=open]:rotate-180"
|
|
180
|
+
/>
|
|
181
|
+
</ComboboxTrigger>
|
|
182
|
+
</ComboboxAnchor>
|
|
183
|
+
|
|
184
|
+
<ComboboxPortal>
|
|
185
|
+
<ComboboxContent
|
|
186
|
+
position="popper"
|
|
187
|
+
align="start"
|
|
188
|
+
:side-offset="6"
|
|
189
|
+
:class="[POPOVER_PANEL, 'z-(--z-popover) max-h-72 w-72 overflow-hidden']"
|
|
190
|
+
>
|
|
191
|
+
<div class="border-b border-line p-2">
|
|
192
|
+
<!--
|
|
193
|
+
Update `query` from the raw DOM event, not reka's ComboboxInput
|
|
194
|
+
v-model: reka defers the value to nextTick while the list is
|
|
195
|
+
closed, lagging the first keystroke by one char. (See LpAutocomplete
|
|
196
|
+
for the same fix.)
|
|
197
|
+
-->
|
|
198
|
+
<ComboboxInput
|
|
199
|
+
:model-value="query"
|
|
200
|
+
:placeholder="searchPlaceholder"
|
|
201
|
+
auto-focus
|
|
202
|
+
class="h-(--size-control-sm) w-full rounded-md border border-line bg-surface-soft px-2.5 text-sm text-ink outline-none placeholder:text-muted focus:border-brand"
|
|
203
|
+
@input="query = ($event.target as HTMLInputElement).value"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
<ComboboxViewport class="max-h-56 overflow-y-auto p-1">
|
|
207
|
+
<ComboboxEmpty class="px-3 py-4 text-center text-sm italic text-muted">
|
|
208
|
+
{{ emptyText }}
|
|
209
|
+
</ComboboxEmpty>
|
|
210
|
+
<ComboboxItem
|
|
211
|
+
v-for="(c, i) in filtered"
|
|
212
|
+
:key="c.iso2"
|
|
213
|
+
:value="c.iso2"
|
|
214
|
+
:style="{ animationDelay: `${Math.min(i, 12) * 22}ms` }"
|
|
215
|
+
:class="[OPTION_ITEM, 'gap-2.5']"
|
|
216
|
+
@select.prevent="chooseCountry(c)"
|
|
217
|
+
>
|
|
218
|
+
<span class="text-base leading-none">{{ flagEmoji(c.iso2) }}</span>
|
|
219
|
+
<span class="min-w-0 flex-1 truncate">{{ c.name }}</span>
|
|
220
|
+
<span class="shrink-0 text-xs text-muted">+{{ c.dial }}</span>
|
|
221
|
+
</ComboboxItem>
|
|
222
|
+
</ComboboxViewport>
|
|
223
|
+
</ComboboxContent>
|
|
224
|
+
</ComboboxPortal>
|
|
225
|
+
</ComboboxRoot>
|
|
226
|
+
|
|
227
|
+
<input
|
|
228
|
+
:value="modelValue"
|
|
229
|
+
:placeholder="placeholder"
|
|
230
|
+
:disabled="disabled"
|
|
231
|
+
:aria-invalid="invalid || undefined"
|
|
232
|
+
type="tel"
|
|
233
|
+
inputmode="tel"
|
|
234
|
+
autocomplete="tel"
|
|
235
|
+
class="min-w-0 flex-1 bg-transparent px-3 outline-none placeholder:text-muted"
|
|
236
|
+
@beforeinput="onBeforeInput"
|
|
237
|
+
@paste="onPaste"
|
|
238
|
+
@input="onInput"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
PopoverContent,
|
|
4
|
+
PopoverPortal,
|
|
5
|
+
PopoverRoot,
|
|
6
|
+
PopoverTrigger,
|
|
7
|
+
} from "reka-ui"
|
|
8
|
+
import { POPOVER_PANEL } from "./dropdown"
|
|
9
|
+
|
|
10
|
+
withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
side?: "top" | "right" | "bottom" | "left"
|
|
13
|
+
align?: "start" | "center" | "end"
|
|
14
|
+
sideOffset?: number
|
|
15
|
+
/** Extra classes for the floating panel (width, padding, overflow). */
|
|
16
|
+
panelClass?: string
|
|
17
|
+
}>(),
|
|
18
|
+
{ side: "bottom", align: "center", sideOffset: 6 },
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Controllable open state (v-model:open). Left unbound the popover stays
|
|
22
|
+
// uncontrolled and reka manages open/close on trigger interaction.
|
|
23
|
+
const open = defineModel<boolean>("open")
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<PopoverRoot v-model:open="open">
|
|
28
|
+
<PopoverTrigger as-child>
|
|
29
|
+
<slot name="trigger" />
|
|
30
|
+
</PopoverTrigger>
|
|
31
|
+
<PopoverPortal>
|
|
32
|
+
<PopoverContent
|
|
33
|
+
:side="side"
|
|
34
|
+
:align="align"
|
|
35
|
+
:side-offset="sideOffset"
|
|
36
|
+
:class="[POPOVER_PANEL, 'z-(--z-popover) w-64 rounded-card p-3 text-sm text-ink outline-none', panelClass]"
|
|
37
|
+
>
|
|
38
|
+
<slot />
|
|
39
|
+
</PopoverContent>
|
|
40
|
+
</PopoverPortal>
|
|
41
|
+
</PopoverRoot>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ProgressIndicator, ProgressRoot } from "reka-ui"
|
|
3
|
+
import { computed } from "vue"
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{ value?: number; max?: number; tone?: "brand" | "action" | "danger" }>(),
|
|
7
|
+
{ value: 0, max: 100, tone: "brand" },
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const pct = computed(() => Math.max(0, Math.min(100, (props.value / props.max) * 100)))
|
|
11
|
+
const bar: Record<string, string> = {
|
|
12
|
+
brand: "bg-brand",
|
|
13
|
+
action: "bg-action",
|
|
14
|
+
danger: "bg-danger",
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<ProgressRoot
|
|
20
|
+
:model-value="value"
|
|
21
|
+
:max="max"
|
|
22
|
+
class="h-1.5 w-full overflow-hidden rounded-pill bg-surface-soft"
|
|
23
|
+
>
|
|
24
|
+
<ProgressIndicator
|
|
25
|
+
class="h-full rounded-pill transition-[width] duration-slow ease-[var(--ease-emphasized)]"
|
|
26
|
+
:class="bar[tone]"
|
|
27
|
+
:style="{ width: `${pct}%` }"
|
|
28
|
+
/>
|
|
29
|
+
</ProgressRoot>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RadioGroupIndicator, RadioGroupItem } from "reka-ui"
|
|
3
|
+
|
|
4
|
+
// A single radio item with a slot for rich content (icon, badges, multi-line).
|
|
5
|
+
// Must be placed inside an <LpRadioGroup> (or a reka-ui RadioGroupRoot), which
|
|
6
|
+
// owns the selected value. The default slot replaces the plain label.
|
|
7
|
+
defineProps<{ value: string; label?: string; disabled?: boolean }>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<label
|
|
12
|
+
class="group inline-flex cursor-pointer items-center gap-2 text-sm text-ink"
|
|
13
|
+
:class="disabled && 'cursor-not-allowed opacity-55'"
|
|
14
|
+
>
|
|
15
|
+
<RadioGroupItem
|
|
16
|
+
:value="value"
|
|
17
|
+
:disabled="disabled"
|
|
18
|
+
class="flex size-[18px] shrink-0 items-center justify-center rounded-full border border-line-strong bg-surface-soft outline-none transition-[color,border-color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] not-data-[state=checked]:group-hover:border-brand active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
19
|
+
>
|
|
20
|
+
<RadioGroupIndicator
|
|
21
|
+
class="flex data-[state=checked]:animate-[indicator-in_140ms_var(--ease-emphasized)]"
|
|
22
|
+
>
|
|
23
|
+
<span class="size-2 rounded-full bg-brand" />
|
|
24
|
+
</RadioGroupIndicator>
|
|
25
|
+
</RadioGroupItem>
|
|
26
|
+
<slot>{{ label }}</slot>
|
|
27
|
+
</label>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "reka-ui"
|
|
3
|
+
|
|
4
|
+
export interface RadioOption {
|
|
5
|
+
value: string
|
|
6
|
+
label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// `options` renders simple {value,label} rows. For rich items (icons, badges,
|
|
10
|
+
// multi-line) pass <LpRadio> children in the default slot instead — they share
|
|
11
|
+
// the same RadioGroupRoot, so selection still flows through v-model.
|
|
12
|
+
defineProps<{ modelValue?: string; options?: RadioOption[]; disabled?: boolean }>()
|
|
13
|
+
defineEmits<{ (e: "update:modelValue", value: string): void }>()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<RadioGroupRoot
|
|
18
|
+
:model-value="modelValue"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
class="flex flex-col gap-2"
|
|
21
|
+
@update:model-value="(v) => $emit('update:modelValue', v as string)"
|
|
22
|
+
>
|
|
23
|
+
<slot>
|
|
24
|
+
<label
|
|
25
|
+
v-for="opt in options"
|
|
26
|
+
:key="opt.value"
|
|
27
|
+
class="group inline-flex cursor-pointer items-center gap-2 text-sm text-ink"
|
|
28
|
+
>
|
|
29
|
+
<RadioGroupItem
|
|
30
|
+
:value="opt.value"
|
|
31
|
+
class="flex size-[18px] items-center justify-center rounded-full border border-line-strong bg-surface-soft outline-none transition-[color,border-color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] not-data-[state=checked]:group-hover:border-brand active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
32
|
+
>
|
|
33
|
+
<RadioGroupIndicator
|
|
34
|
+
class="flex data-[state=checked]:animate-[indicator-in_140ms_var(--ease-emphasized)]"
|
|
35
|
+
>
|
|
36
|
+
<span class="size-2 rounded-full bg-brand" />
|
|
37
|
+
</RadioGroupIndicator>
|
|
38
|
+
</RadioGroupItem>
|
|
39
|
+
{{ opt.label }}
|
|
40
|
+
</label>
|
|
41
|
+
</slot>
|
|
42
|
+
</RadioGroupRoot>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Drawn overlay scrollbar (reka ScrollArea). Floats over content — never
|
|
4
|
+
* reserves layout width, so the viewport doesn't jump. Thin at rest, a touch
|
|
5
|
+
* thicker on hover; fades in/out (reka Presence keeps it mounted through the
|
|
6
|
+
* exit animation). Rounded only on the OUTER edge — the side flush to the wall
|
|
7
|
+
* stays square. Themed via tokens; native scroll/keyboard/touch preserved.
|
|
8
|
+
*/
|
|
9
|
+
import { computed, ref } from "vue"
|
|
10
|
+
import {
|
|
11
|
+
ScrollAreaRoot,
|
|
12
|
+
ScrollAreaScrollbar,
|
|
13
|
+
ScrollAreaThumb,
|
|
14
|
+
ScrollAreaViewport,
|
|
15
|
+
} from "reka-ui"
|
|
16
|
+
|
|
17
|
+
// `fade` softens the top/bottom edges to hint more content.
|
|
18
|
+
// `contentClass` styles the scrollable CONTENT container (the viewport's inner
|
|
19
|
+
// wrapper) — put list layout here (display:grid / gap / padding), not on the
|
|
20
|
+
// component root, since items live inside reka's viewport.
|
|
21
|
+
// `barInsetTop` (any CSS length) pulls the vertical scrollbar down by that much,
|
|
22
|
+
// so it doesn't run under a sticky header pinned at the top of the viewport.
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
fade?: boolean
|
|
25
|
+
contentClass?: string
|
|
26
|
+
barInsetTop?: string
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
// Re-emit the native scroll event of the underlying viewport so consumers that
|
|
30
|
+
// drive scroll-following (e.g. log tails) can track position. The listener is
|
|
31
|
+
// bound on ScrollAreaViewport, whose `$attrs` reka forwards onto the scrollable
|
|
32
|
+
// element itself.
|
|
33
|
+
defineEmits<{ scroll: [event: Event] }>()
|
|
34
|
+
|
|
35
|
+
// Expose the scrollable viewport element so callers can read/write scrollTop
|
|
36
|
+
// (auto-scroll to bottom, "is at bottom" checks). reka's ScrollAreaViewport
|
|
37
|
+
// exposes it as `viewportElement`.
|
|
38
|
+
const viewportRef = ref<{ viewportElement?: HTMLElement } | null>(null)
|
|
39
|
+
const viewportEl = computed<HTMLElement | null>(
|
|
40
|
+
() => viewportRef.value?.viewportElement ?? null,
|
|
41
|
+
)
|
|
42
|
+
defineExpose({ viewportEl })
|
|
43
|
+
|
|
44
|
+
const barFade =
|
|
45
|
+
"data-[state=visible]:animate-[fade-in_180ms_ease] data-[state=hidden]:animate-[fade-out_240ms_ease]"
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<ScrollAreaRoot class="relative overflow-hidden" type="hover" :scroll-hide-delay="500">
|
|
50
|
+
<ScrollAreaViewport
|
|
51
|
+
ref="viewportRef"
|
|
52
|
+
class="size-full min-w-0 [&>div]:!block [&>div]:!min-w-0"
|
|
53
|
+
:class="fade ? '[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]' : ''"
|
|
54
|
+
@scroll.passive="$emit('scroll', $event)"
|
|
55
|
+
>
|
|
56
|
+
<div :class="contentClass">
|
|
57
|
+
<slot />
|
|
58
|
+
</div>
|
|
59
|
+
</ScrollAreaViewport>
|
|
60
|
+
|
|
61
|
+
<ScrollAreaScrollbar
|
|
62
|
+
orientation="vertical"
|
|
63
|
+
class="group flex w-1 touch-none select-none p-px transition-[width] duration-[var(--duration-fast)] hover:w-1.5"
|
|
64
|
+
:class="barFade"
|
|
65
|
+
:style="barInsetTop ? { marginTop: barInsetTop } : undefined"
|
|
66
|
+
>
|
|
67
|
+
<ScrollAreaThumb
|
|
68
|
+
class="flex-1 rounded-l-pill bg-brand/40 transition-colors group-hover:bg-brand"
|
|
69
|
+
/>
|
|
70
|
+
</ScrollAreaScrollbar>
|
|
71
|
+
|
|
72
|
+
<ScrollAreaScrollbar
|
|
73
|
+
orientation="horizontal"
|
|
74
|
+
class="group flex h-1 flex-col touch-none select-none p-px transition-[height] duration-[var(--duration-fast)] hover:h-1.5"
|
|
75
|
+
:class="barFade"
|
|
76
|
+
>
|
|
77
|
+
<ScrollAreaThumb
|
|
78
|
+
class="flex-1 rounded-t-pill bg-brand/40 transition-colors group-hover:bg-brand"
|
|
79
|
+
/>
|
|
80
|
+
</ScrollAreaScrollbar>
|
|
81
|
+
</ScrollAreaRoot>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Segmented control — a compact bar of mutually-exclusive options with a sliding
|
|
4
|
+
* pill marking the selection (iOS-style). Built on reka ToggleGroup for the
|
|
5
|
+
* roving-focus/keyboard wiring; the pill rides motion-v's shared layoutId, the
|
|
6
|
+
* same trick as LpTabs/LpPagination. Use it for short option sets (2–5) where a
|
|
7
|
+
* full Tabs bar is too heavy — view switches, density toggles, on/off-ish picks.
|
|
8
|
+
*/
|
|
9
|
+
import { Motion } from "motion-v"
|
|
10
|
+
import { ToggleGroupItem, ToggleGroupRoot } from "reka-ui"
|
|
11
|
+
import { useId } from "vue"
|
|
12
|
+
import { usePillTransition } from "../composables/usePillTransition"
|
|
13
|
+
import LpIcon from "./LpIcon.vue"
|
|
14
|
+
|
|
15
|
+
export interface SegmentedOption {
|
|
16
|
+
value: string
|
|
17
|
+
label?: string
|
|
18
|
+
/** Optional iconify name shown before the label (or alone, icon-only). */
|
|
19
|
+
icon?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
modelValue?: string
|
|
25
|
+
options: SegmentedOption[]
|
|
26
|
+
size?: "sm" | "md"
|
|
27
|
+
/** Stretch to fill the container with equal-width segments. */
|
|
28
|
+
block?: boolean
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
}>(),
|
|
31
|
+
{ size: "md", block: false },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: "update:modelValue", value: string): void
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
// reka types the payload as AcceptableValue|AcceptableValue[]; in single mode it's
|
|
39
|
+
// the chosen string (or "" on deselect). We keep a value selected — clicking the
|
|
40
|
+
// active segment again shouldn't clear it — so ignore the deselect-to-empty.
|
|
41
|
+
function onChange(v: unknown) {
|
|
42
|
+
if (typeof v === "string" && v) emit("update:modelValue", v)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sizeCls = {
|
|
46
|
+
sm: "h-(--size-control-sm) text-xs",
|
|
47
|
+
md: "h-(--size-control-md) text-sm",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pillId = `lp-segmented-${useId()}`
|
|
51
|
+
const pillTransition = usePillTransition()
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<ToggleGroupRoot
|
|
56
|
+
:model-value="modelValue"
|
|
57
|
+
type="single"
|
|
58
|
+
:disabled="disabled"
|
|
59
|
+
:rovingFocus="true"
|
|
60
|
+
class="inline-flex items-center gap-1 rounded-control border border-line bg-surface-soft p-1"
|
|
61
|
+
:class="[sizeCls[size], block ? 'flex w-full' : '']"
|
|
62
|
+
@update:model-value="onChange"
|
|
63
|
+
>
|
|
64
|
+
<ToggleGroupItem
|
|
65
|
+
v-for="opt in options"
|
|
66
|
+
:key="opt.value"
|
|
67
|
+
:value="opt.value"
|
|
68
|
+
:aria-label="opt.label ? undefined : opt.value"
|
|
69
|
+
class="relative inline-flex h-full items-center justify-center gap-1.5 rounded-md px-3 font-medium outline-none transition-colors duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] focus-visible:ring-2 focus-visible:ring-ring data-[state=on]:text-ink data-[state=off]:text-muted data-[state=off]:hover:text-ink disabled:cursor-not-allowed disabled:opacity-55"
|
|
70
|
+
:class="block ? 'flex-1' : ''"
|
|
71
|
+
>
|
|
72
|
+
<!-- Sliding pill behind the active segment. -->
|
|
73
|
+
<Motion
|
|
74
|
+
v-if="opt.value === modelValue"
|
|
75
|
+
:layout-id="pillId"
|
|
76
|
+
:transition="pillTransition"
|
|
77
|
+
class="absolute inset-0 z-0 rounded-md border border-line bg-surface-raised shadow-sm"
|
|
78
|
+
/>
|
|
79
|
+
<LpIcon v-if="opt.icon" :name="opt.icon" :size="14" class="relative z-10 shrink-0" />
|
|
80
|
+
<span v-if="opt.label" class="relative z-10">{{ opt.label }}</span>
|
|
81
|
+
</ToggleGroupItem>
|
|
82
|
+
</ToggleGroupRoot>
|
|
83
|
+
</template>
|
|
Binary file
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Custom Vue Flow node for a SERVICE inside a host frame on LpTopologyCanvas.
|
|
4
|
+
* Smaller than LpInfraNode (a host). Coloured by kind (service/database/infra).
|
|
5
|
+
* depends_on edges connect these across host frames. Tailwind utilities only
|
|
6
|
+
* (no scoped CSS) so styles reach consumer apps via @source.
|
|
7
|
+
*/
|
|
8
|
+
import { Handle, Position } from "@vue-flow/core"
|
|
9
|
+
import { computed } from "vue"
|
|
10
|
+
|
|
11
|
+
export interface ServiceNodeData {
|
|
12
|
+
name: string
|
|
13
|
+
kind?: string // service | database | infra
|
|
14
|
+
stack?: string
|
|
15
|
+
/** Fade the node back (search miss / not part of the focused node's graph). */
|
|
16
|
+
dimmed?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{ data: ServiceNodeData; selected?: boolean }>()
|
|
20
|
+
|
|
21
|
+
const kindVar: Record<string, string> = {
|
|
22
|
+
service: "var(--color-brand)",
|
|
23
|
+
database: "var(--color-accent)",
|
|
24
|
+
infra: "var(--color-muted-strong)",
|
|
25
|
+
}
|
|
26
|
+
const accent = computed(() => kindVar[props.data.kind ?? "service"] ?? "var(--color-brand)")
|
|
27
|
+
const kindIcon: Record<string, string> = {
|
|
28
|
+
service: "◇",
|
|
29
|
+
database: "⛁",
|
|
30
|
+
infra: "⬡",
|
|
31
|
+
}
|
|
32
|
+
const icon = computed(() => kindIcon[props.data.kind ?? "service"] ?? "◇")
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div
|
|
37
|
+
class="flex w-[132px] items-center gap-1.5 rounded-control border bg-surface-soft px-2 py-1.5 transition-[border-color,opacity]"
|
|
38
|
+
:class="[
|
|
39
|
+
selected ? 'border-[var(--accent)]' : 'border-line',
|
|
40
|
+
data.dimmed ? 'opacity-30' : 'opacity-100',
|
|
41
|
+
]"
|
|
42
|
+
:style="{ '--accent': accent }"
|
|
43
|
+
>
|
|
44
|
+
<Handle
|
|
45
|
+
type="target"
|
|
46
|
+
:position="Position.Left"
|
|
47
|
+
class="!size-1.5 !border !border-[var(--accent)] !bg-surface"
|
|
48
|
+
/>
|
|
49
|
+
<Handle
|
|
50
|
+
type="source"
|
|
51
|
+
:position="Position.Right"
|
|
52
|
+
class="!size-1.5 !border !border-[var(--accent)] !bg-surface"
|
|
53
|
+
/>
|
|
54
|
+
<span class="text-[11px] text-[var(--accent)]">{{ icon }}</span>
|
|
55
|
+
<span class="flex-1 truncate text-[11px] font-medium text-ink">{{ data.name }}</span>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|