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