@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,323 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Command palette (⌘K). A centered dialog with a filter box and a keyboard-
|
|
4
|
+
* navigable, grouped command list. Built on reka Dialog (focus trap + overlay);
|
|
5
|
+
* the list nav (↑/↓/Enter) is owned here so the query input keeps focus while
|
|
6
|
+
* arrows move the highlight — the pattern the LeavePulse frontend palette uses.
|
|
7
|
+
*
|
|
8
|
+
* Presentation-only and data-agnostic: the consumer supplies `commands` (a
|
|
9
|
+
* composable can assemble them from routes/docs/API). Scoring ranks label >
|
|
10
|
+
* keywords > description; matches are highlighted; the active row scrolls into
|
|
11
|
+
* view. Open state is v-model:open; a global ⌘K/Ctrl-K toggle is on by default.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogDescription,
|
|
16
|
+
DialogOverlay,
|
|
17
|
+
DialogPortal,
|
|
18
|
+
DialogRoot,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from "reka-ui"
|
|
21
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
22
|
+
import LpIcon from "./LpIcon.vue"
|
|
23
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
24
|
+
|
|
25
|
+
export interface Command {
|
|
26
|
+
id: string
|
|
27
|
+
label: string
|
|
28
|
+
/** Second line under the label. */
|
|
29
|
+
description?: string
|
|
30
|
+
icon?: string
|
|
31
|
+
/** Right-aligned hint, e.g. "⌘P". */
|
|
32
|
+
shortcut?: string
|
|
33
|
+
/** Extra search terms not shown but matched against the query. */
|
|
34
|
+
keywords?: string[]
|
|
35
|
+
/** Section heading this command sits under. */
|
|
36
|
+
group?: string
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Hidden from the default (empty-query) list — only surfaces once the user
|
|
40
|
+
* types something it matches. For rarely-used or destructive actions (prune,
|
|
41
|
+
* decommission, …) that shouldn't clutter the palette but stay reachable.
|
|
42
|
+
*/
|
|
43
|
+
hidden?: boolean
|
|
44
|
+
onSelect?: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(
|
|
48
|
+
defineProps<{
|
|
49
|
+
open?: boolean
|
|
50
|
+
commands: Command[]
|
|
51
|
+
placeholder?: string
|
|
52
|
+
emptyText?: string
|
|
53
|
+
/** Bind a global ⌘K / Ctrl-K toggle. */
|
|
54
|
+
hotkey?: boolean
|
|
55
|
+
}>(),
|
|
56
|
+
{ placeholder: "Type a command or search…", emptyText: "No results", hotkey: true },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const emit = defineEmits<{
|
|
60
|
+
(e: "update:open", value: boolean): void
|
|
61
|
+
(e: "select", command: Command): void
|
|
62
|
+
}>()
|
|
63
|
+
|
|
64
|
+
const query = ref("")
|
|
65
|
+
const selectedIndex = ref(0)
|
|
66
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
67
|
+
const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
|
|
68
|
+
|
|
69
|
+
// ── filtering + scoring ──────────────────────────────────────
|
|
70
|
+
// No query → everything (grouped). With a query, score by where it hits:
|
|
71
|
+
// label > keywords > description, then alphabetical. Mirrors the app palette.
|
|
72
|
+
const ranked = computed<Command[]>(() => {
|
|
73
|
+
const q = query.value.trim().toLowerCase()
|
|
74
|
+
// Empty query: show everything EXCEPT hidden commands (they only surface once
|
|
75
|
+
// typed). With a query, hidden commands rank like any other.
|
|
76
|
+
if (!q) return props.commands.filter((c) => !c.hidden)
|
|
77
|
+
return props.commands
|
|
78
|
+
.map((c) => {
|
|
79
|
+
const label = c.label.toLowerCase().includes(q)
|
|
80
|
+
const kw = (c.keywords ?? []).some((k) => k.toLowerCase().includes(q))
|
|
81
|
+
const desc = c.description?.toLowerCase().includes(q) ?? false
|
|
82
|
+
const score = (label ? 4 : 0) + (kw ? 3 : 0) + (desc ? 2 : 0)
|
|
83
|
+
return { c, score }
|
|
84
|
+
})
|
|
85
|
+
.filter((e) => e.score > 0)
|
|
86
|
+
.sort((a, b) => b.score - a.score || a.c.label.localeCompare(b.c.label))
|
|
87
|
+
.map((e) => e.c)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Group preserving first-seen order; ungrouped commands sit under "".
|
|
91
|
+
const grouped = computed<{ name: string; items: Command[] }[]>(() => {
|
|
92
|
+
const order: string[] = []
|
|
93
|
+
const map = new Map<string, Command[]>()
|
|
94
|
+
for (const c of ranked.value) {
|
|
95
|
+
const g = c.group ?? ""
|
|
96
|
+
if (!map.has(g)) {
|
|
97
|
+
map.set(g, [])
|
|
98
|
+
order.push(g)
|
|
99
|
+
}
|
|
100
|
+
map.get(g)!.push(c)
|
|
101
|
+
}
|
|
102
|
+
return order.map((name) => ({ name, items: map.get(name)! }))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Flat list in render order — what arrow keys walk through.
|
|
106
|
+
const flat = computed(() => grouped.value.flatMap((g) => g.items))
|
|
107
|
+
function flatIndexOf(c: Command): number {
|
|
108
|
+
return flat.value.indexOf(c)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── highlight ────────────────────────────────────────────────
|
|
112
|
+
// Split text around query terms (≥2 chars, longest-first) for <mark> wrapping,
|
|
113
|
+
// without v-html. Multi-word queries highlight each term.
|
|
114
|
+
interface Part {
|
|
115
|
+
text: string
|
|
116
|
+
hit: boolean
|
|
117
|
+
}
|
|
118
|
+
function highlight(value: string): Part[] {
|
|
119
|
+
const terms = [...new Set(query.value.toLowerCase().trim().split(/\s+/).filter((t) => t.length >= 2))].sort(
|
|
120
|
+
(a, b) => b.length - a.length,
|
|
121
|
+
)
|
|
122
|
+
if (!value || terms.length === 0) return value ? [{ text: value, hit: false }] : []
|
|
123
|
+
const lower = value.toLowerCase()
|
|
124
|
+
const parts: Part[] = []
|
|
125
|
+
let i = 0
|
|
126
|
+
while (i < value.length) {
|
|
127
|
+
let at = -1
|
|
128
|
+
let term = ""
|
|
129
|
+
for (const t of terms) {
|
|
130
|
+
const found = lower.indexOf(t, i)
|
|
131
|
+
if (found !== -1 && (at === -1 || found < at || (found === at && t.length > term.length))) {
|
|
132
|
+
at = found
|
|
133
|
+
term = t
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (at === -1) {
|
|
137
|
+
parts.push({ text: value.slice(i), hit: false })
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
if (at > i) parts.push({ text: value.slice(i, at), hit: false })
|
|
141
|
+
parts.push({ text: value.slice(at, at + term.length), hit: true })
|
|
142
|
+
i = at + term.length
|
|
143
|
+
}
|
|
144
|
+
return parts
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── selection + keyboard ─────────────────────────────────────
|
|
148
|
+
watch(query, () => (selectedIndex.value = 0))
|
|
149
|
+
watch(
|
|
150
|
+
() => flat.value.length,
|
|
151
|
+
(len) => {
|
|
152
|
+
if (selectedIndex.value >= len) selectedIndex.value = Math.max(0, len - 1)
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
watch([selectedIndex, () => flat.value.map((c) => c.id).join("|")], async () => {
|
|
156
|
+
await nextTick()
|
|
157
|
+
scrollSelectedIntoView()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function scrollSelectedIntoView() {
|
|
161
|
+
const el = scrollRef.value?.viewportEl?.querySelector<HTMLElement>(
|
|
162
|
+
`[data-cmd-index="${selectedIndex.value}"]`,
|
|
163
|
+
)
|
|
164
|
+
el?.scrollIntoView({ block: "nearest" })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function moveDown() {
|
|
168
|
+
const len = flat.value.length
|
|
169
|
+
if (len) selectedIndex.value = (selectedIndex.value + 1) % len
|
|
170
|
+
}
|
|
171
|
+
function moveUp() {
|
|
172
|
+
const len = flat.value.length
|
|
173
|
+
if (len) selectedIndex.value = (selectedIndex.value - 1 + len) % len
|
|
174
|
+
}
|
|
175
|
+
function runSelected() {
|
|
176
|
+
run(flat.value[selectedIndex.value])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function run(c: Command | undefined) {
|
|
180
|
+
if (!c || c.disabled) return
|
|
181
|
+
emit("select", c)
|
|
182
|
+
c.onSelect?.()
|
|
183
|
+
emit("update:open", false)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Reset + focus when opening; clear on close.
|
|
187
|
+
watch(
|
|
188
|
+
() => props.open,
|
|
189
|
+
async (o) => {
|
|
190
|
+
if (o) {
|
|
191
|
+
query.value = ""
|
|
192
|
+
selectedIndex.value = 0
|
|
193
|
+
await nextTick()
|
|
194
|
+
inputRef.value?.focus()
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
function onHotkey(e: KeyboardEvent) {
|
|
200
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
emit("update:open", !props.open)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
onMounted(() => {
|
|
206
|
+
if (props.hotkey) window.addEventListener("keydown", onHotkey)
|
|
207
|
+
})
|
|
208
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onHotkey))
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<template>
|
|
212
|
+
<DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
|
|
213
|
+
<DialogPortal>
|
|
214
|
+
<DialogOverlay
|
|
215
|
+
class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
216
|
+
/>
|
|
217
|
+
<DialogContent
|
|
218
|
+
class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
|
|
219
|
+
@open-auto-focus.prevent
|
|
220
|
+
>
|
|
221
|
+
<DialogTitle class="sr-only">Command palette</DialogTitle>
|
|
222
|
+
<DialogDescription class="sr-only">Search and run a command.</DialogDescription>
|
|
223
|
+
|
|
224
|
+
<!-- Filter -->
|
|
225
|
+
<div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
226
|
+
<LpIcon name="lucide:search" :size="18" class="shrink-0 text-muted" />
|
|
227
|
+
<input
|
|
228
|
+
ref="inputRef"
|
|
229
|
+
v-model="query"
|
|
230
|
+
type="text"
|
|
231
|
+
:placeholder="placeholder"
|
|
232
|
+
class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
|
|
233
|
+
@keydown.down.prevent="moveDown"
|
|
234
|
+
@keydown.up.prevent="moveUp"
|
|
235
|
+
@keydown.enter.prevent="runSelected"
|
|
236
|
+
/>
|
|
237
|
+
<kbd class="shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted">esc</kbd>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- Results -->
|
|
241
|
+
<LpScrollArea ref="scrollRef" class="min-h-0 flex-1" content-class="p-2">
|
|
242
|
+
<p v-if="flat.length === 0" class="px-2 py-10 text-center text-sm text-muted">
|
|
243
|
+
{{ emptyText }}
|
|
244
|
+
</p>
|
|
245
|
+
|
|
246
|
+
<template v-for="group in grouped" :key="group.name">
|
|
247
|
+
<div
|
|
248
|
+
v-if="group.name"
|
|
249
|
+
class="px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
|
|
250
|
+
>
|
|
251
|
+
{{ group.name }}
|
|
252
|
+
</div>
|
|
253
|
+
<button
|
|
254
|
+
v-for="c in group.items"
|
|
255
|
+
:key="c.id"
|
|
256
|
+
type="button"
|
|
257
|
+
:data-cmd-index="flatIndexOf(c)"
|
|
258
|
+
:disabled="c.disabled"
|
|
259
|
+
class="lp-cmd-item flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors duration-[var(--duration-fast)] disabled:pointer-events-none disabled:opacity-45"
|
|
260
|
+
:class="flatIndexOf(c) === selectedIndex ? 'bg-brand-soft text-brand' : 'text-ink hover:bg-surface-soft/60'"
|
|
261
|
+
@click="run(c)"
|
|
262
|
+
@pointerenter="selectedIndex = flatIndexOf(c)"
|
|
263
|
+
>
|
|
264
|
+
<LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" />
|
|
265
|
+
<span class="min-w-0 flex-1">
|
|
266
|
+
<span class="block truncate font-medium">
|
|
267
|
+
<template v-for="(p, pi) in highlight(c.label)" :key="`l${pi}`">
|
|
268
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
|
|
269
|
+
<template v-else>{{ p.text }}</template>
|
|
270
|
+
</template>
|
|
271
|
+
</span>
|
|
272
|
+
<span v-if="c.description" class="mt-0.5 block truncate text-xs text-muted">
|
|
273
|
+
<template v-for="(p, pi) in highlight(c.description)" :key="`d${pi}`">
|
|
274
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/20 text-ink">{{ p.text }}</mark>
|
|
275
|
+
<template v-else>{{ p.text }}</template>
|
|
276
|
+
</template>
|
|
277
|
+
</span>
|
|
278
|
+
</span>
|
|
279
|
+
<kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
|
|
280
|
+
</button>
|
|
281
|
+
</template>
|
|
282
|
+
</LpScrollArea>
|
|
283
|
+
|
|
284
|
+
<!-- Footer hints -->
|
|
285
|
+
<div
|
|
286
|
+
class="flex shrink-0 items-center gap-4 border-t border-line px-4 py-2.5 text-[11px] text-muted"
|
|
287
|
+
>
|
|
288
|
+
<span class="flex items-center gap-1">
|
|
289
|
+
<kbd class="rounded border border-line px-1 text-[10px]">↑</kbd>
|
|
290
|
+
<kbd class="rounded border border-line px-1 text-[10px]">↓</kbd>
|
|
291
|
+
navigate
|
|
292
|
+
</span>
|
|
293
|
+
<span class="flex items-center gap-1">
|
|
294
|
+
<kbd class="rounded border border-line px-1 text-[10px]">↵</kbd>
|
|
295
|
+
select
|
|
296
|
+
</span>
|
|
297
|
+
<span class="ml-auto flex items-center gap-1">
|
|
298
|
+
<kbd class="rounded border border-line px-1 text-[10px]">esc</kbd>
|
|
299
|
+
close
|
|
300
|
+
</span>
|
|
301
|
+
</div>
|
|
302
|
+
</DialogContent>
|
|
303
|
+
</DialogPortal>
|
|
304
|
+
</DialogRoot>
|
|
305
|
+
</template>
|
|
306
|
+
|
|
307
|
+
<style scoped>
|
|
308
|
+
/* Each result eases in; honours reduced motion. */
|
|
309
|
+
.lp-cmd-item {
|
|
310
|
+
animation: lp-cmd-in 140ms ease-out both;
|
|
311
|
+
}
|
|
312
|
+
@keyframes lp-cmd-in {
|
|
313
|
+
from {
|
|
314
|
+
opacity: 0;
|
|
315
|
+
transform: translateY(3px);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
@media (prefers-reduced-motion: reduce) {
|
|
319
|
+
.lp-cmd-item {
|
|
320
|
+
animation: none;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Confirmation dialog built on LpModal. Resolves an action via confirm/cancel.
|
|
3
|
+
import LpButton from "./LpButton.vue"
|
|
4
|
+
import LpModal from "./LpModal.vue"
|
|
5
|
+
|
|
6
|
+
withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
open?: boolean
|
|
9
|
+
title?: string
|
|
10
|
+
description?: string
|
|
11
|
+
confirmLabel?: string
|
|
12
|
+
cancelLabel?: string
|
|
13
|
+
danger?: boolean
|
|
14
|
+
}>(),
|
|
15
|
+
{ confirmLabel: "Confirm", cancelLabel: "Cancel" },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
(e: "update:open", value: boolean): void
|
|
20
|
+
(e: "confirm"): void
|
|
21
|
+
(e: "cancel"): void
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
function confirm() {
|
|
25
|
+
emit("confirm")
|
|
26
|
+
emit("update:open", false)
|
|
27
|
+
}
|
|
28
|
+
function cancel() {
|
|
29
|
+
emit("cancel")
|
|
30
|
+
emit("update:open", false)
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<LpModal
|
|
36
|
+
:open="open"
|
|
37
|
+
:title="title"
|
|
38
|
+
:description="description"
|
|
39
|
+
@update:open="(v) => $emit('update:open', v)"
|
|
40
|
+
>
|
|
41
|
+
<slot />
|
|
42
|
+
<template #footer>
|
|
43
|
+
<LpButton variant="ghost" @click="cancel">{{ cancelLabel }}</LpButton>
|
|
44
|
+
<LpButton :variant="danger ? 'danger' : 'solid'" @click="confirm">
|
|
45
|
+
{{ confirmLabel }}
|
|
46
|
+
</LpButton>
|
|
47
|
+
</template>
|
|
48
|
+
</LpModal>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Right-click context menu (reka ContextMenu). Same item shape as
|
|
4
|
+
* LpDropdownMenu, plus nested submenus (`children`) and a shortcut hint. The
|
|
5
|
+
* trigger is whatever you put in the default slot — right-clicking it (or
|
|
6
|
+
* long-press on touch) opens the menu at the pointer. Shares the floating-panel
|
|
7
|
+
* surface + option-row look with the rest of the kit via dropdown.ts.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ContextMenuContent,
|
|
11
|
+
ContextMenuItem,
|
|
12
|
+
ContextMenuPortal,
|
|
13
|
+
ContextMenuRoot,
|
|
14
|
+
ContextMenuSeparator,
|
|
15
|
+
ContextMenuSub,
|
|
16
|
+
ContextMenuSubContent,
|
|
17
|
+
ContextMenuSubTrigger,
|
|
18
|
+
ContextMenuTrigger,
|
|
19
|
+
} from "reka-ui"
|
|
20
|
+
import { computed } from "vue"
|
|
21
|
+
import { POPOVER_PANEL } from "./dropdown"
|
|
22
|
+
import LpIcon from "./LpIcon.vue"
|
|
23
|
+
|
|
24
|
+
export interface ContextMenuItemDef {
|
|
25
|
+
label: string
|
|
26
|
+
icon?: string
|
|
27
|
+
/** Right-aligned hint, e.g. "⌘C". */
|
|
28
|
+
shortcut?: string
|
|
29
|
+
danger?: boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
separatorBefore?: boolean
|
|
32
|
+
onSelect?: () => void
|
|
33
|
+
/** Nested submenu. When present, onSelect is ignored. */
|
|
34
|
+
children?: ContextMenuItemDef[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const props = defineProps<{ items: ContextMenuItemDef[] }>()
|
|
38
|
+
|
|
39
|
+
// With no items, render just the trigger slot — no reka machinery — so the
|
|
40
|
+
// element keeps the browser's native context menu. Lets callers wrap
|
|
41
|
+
// unconditionally and pass [] to opt out per element.
|
|
42
|
+
const enabled = computed(() => props.items.length > 0)
|
|
43
|
+
|
|
44
|
+
const PANEL = `${POPOVER_PANEL} z-(--z-popover) min-w-48 p-1 outline-none`
|
|
45
|
+
const ITEM =
|
|
46
|
+
"group/item flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-brand-soft"
|
|
47
|
+
const ICON =
|
|
48
|
+
"transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[highlighted]/item:translate-x-0.5"
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<slot v-if="!enabled" />
|
|
53
|
+
|
|
54
|
+
<ContextMenuRoot v-else>
|
|
55
|
+
<ContextMenuTrigger as-child>
|
|
56
|
+
<slot />
|
|
57
|
+
</ContextMenuTrigger>
|
|
58
|
+
<ContextMenuPortal>
|
|
59
|
+
<ContextMenuContent :class="PANEL">
|
|
60
|
+
<template v-for="(item, i) in items" :key="i">
|
|
61
|
+
<ContextMenuSeparator v-if="item.separatorBefore" class="my-1 h-px bg-line" />
|
|
62
|
+
|
|
63
|
+
<!-- Submenu branch -->
|
|
64
|
+
<ContextMenuSub v-if="item.children?.length">
|
|
65
|
+
<ContextMenuSubTrigger
|
|
66
|
+
:class="[ITEM, 'justify-between text-ink data-[highlighted]:text-brand data-[state=open]:bg-brand-soft data-[state=open]:text-brand']"
|
|
67
|
+
>
|
|
68
|
+
<span class="flex items-center gap-2">
|
|
69
|
+
<LpIcon v-if="item.icon" :name="item.icon" :size="15" :class="ICON" />
|
|
70
|
+
{{ item.label }}
|
|
71
|
+
</span>
|
|
72
|
+
<LpIcon name="lucide:chevron-right" :size="14" class="text-muted" />
|
|
73
|
+
</ContextMenuSubTrigger>
|
|
74
|
+
<ContextMenuPortal>
|
|
75
|
+
<ContextMenuSubContent :class="PANEL" :side-offset="2" :align-offset="-4">
|
|
76
|
+
<template v-for="(sub, j) in item.children" :key="j">
|
|
77
|
+
<ContextMenuSeparator v-if="sub.separatorBefore" class="my-1 h-px bg-line" />
|
|
78
|
+
<ContextMenuItem
|
|
79
|
+
:class="[ITEM, 'justify-between', sub.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand']"
|
|
80
|
+
:disabled="sub.disabled"
|
|
81
|
+
@select="sub.onSelect?.()"
|
|
82
|
+
>
|
|
83
|
+
<span class="flex items-center gap-2">
|
|
84
|
+
<LpIcon v-if="sub.icon" :name="sub.icon" :size="15" :class="ICON" />
|
|
85
|
+
{{ sub.label }}
|
|
86
|
+
</span>
|
|
87
|
+
<kbd v-if="sub.shortcut" class="text-xs text-muted">{{ sub.shortcut }}</kbd>
|
|
88
|
+
</ContextMenuItem>
|
|
89
|
+
</template>
|
|
90
|
+
</ContextMenuSubContent>
|
|
91
|
+
</ContextMenuPortal>
|
|
92
|
+
</ContextMenuSub>
|
|
93
|
+
|
|
94
|
+
<!-- Leaf item -->
|
|
95
|
+
<ContextMenuItem
|
|
96
|
+
v-else
|
|
97
|
+
:class="[ITEM, 'justify-between', item.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand']"
|
|
98
|
+
:disabled="item.disabled"
|
|
99
|
+
@select="item.onSelect?.()"
|
|
100
|
+
>
|
|
101
|
+
<span class="flex items-center gap-2">
|
|
102
|
+
<LpIcon v-if="item.icon" :name="item.icon" :size="15" :class="ICON" />
|
|
103
|
+
{{ item.label }}
|
|
104
|
+
</span>
|
|
105
|
+
<kbd v-if="item.shortcut" class="text-xs text-muted">{{ item.shortcut }}</kbd>
|
|
106
|
+
</ContextMenuItem>
|
|
107
|
+
</template>
|
|
108
|
+
</ContextMenuContent>
|
|
109
|
+
</ContextMenuPortal>
|
|
110
|
+
</ContextMenuRoot>
|
|
111
|
+
</template>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Date field + calendar popover. The model is an ISO date string ("YYYY-MM-DD",
|
|
4
|
+
* v-model), same as LpCalendar (which it embeds). The trigger shows a formatted
|
|
5
|
+
* date; picking a day fills the field and closes the popover. Clearable, with
|
|
6
|
+
* min/max + isDisabled forwarded to the calendar. Themed like the other inputs.
|
|
7
|
+
*/
|
|
8
|
+
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui"
|
|
9
|
+
import { computed, ref } from "vue"
|
|
10
|
+
import { POPOVER_PANEL } from "./dropdown"
|
|
11
|
+
import LpCalendar from "./LpCalendar.vue"
|
|
12
|
+
import LpIcon from "./LpIcon.vue"
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
/** Selected date "YYYY-MM-DD" (v-model). */
|
|
17
|
+
modelValue?: string
|
|
18
|
+
placeholder?: string
|
|
19
|
+
min?: string
|
|
20
|
+
max?: string
|
|
21
|
+
isDisabled?: (iso: string) => boolean
|
|
22
|
+
clearable?: boolean
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
invalid?: boolean
|
|
25
|
+
size?: "sm" | "md" | "lg"
|
|
26
|
+
/** Intl format for the displayed date. */
|
|
27
|
+
format?: Intl.DateTimeFormatOptions
|
|
28
|
+
}>(),
|
|
29
|
+
{
|
|
30
|
+
placeholder: "Pick a date",
|
|
31
|
+
size: "md",
|
|
32
|
+
format: () => ({ year: "numeric", month: "short", day: "numeric" }),
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const emit = defineEmits<{
|
|
37
|
+
(e: "update:modelValue", value: string | undefined): void
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const open = ref(false)
|
|
41
|
+
|
|
42
|
+
const display = computed(() => {
|
|
43
|
+
if (!props.modelValue) return ""
|
|
44
|
+
// Parse as local midnight so the formatted day can't drift across a timezone.
|
|
45
|
+
const [y, m, d] = props.modelValue.split("-").map(Number)
|
|
46
|
+
if (!y || !m || !d) return props.modelValue
|
|
47
|
+
return new Intl.DateTimeFormat(undefined, props.format).format(new Date(y, m - 1, d))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const shellSize = {
|
|
51
|
+
sm: "h-(--size-control-sm) text-xs",
|
|
52
|
+
md: "h-(--size-control-md) text-sm",
|
|
53
|
+
lg: "h-(--size-control-lg) text-sm",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onPick(v: string | undefined) {
|
|
57
|
+
emit("update:modelValue", v)
|
|
58
|
+
open.value = false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clear() {
|
|
62
|
+
emit("update:modelValue", undefined)
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<PopoverRoot v-model:open="open">
|
|
68
|
+
<PopoverTrigger as-child>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
:disabled="disabled"
|
|
72
|
+
class="group flex w-full items-center gap-2 rounded-control border bg-surface-soft px-3 text-left text-ink outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:border-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
73
|
+
:class="[
|
|
74
|
+
shellSize[size],
|
|
75
|
+
invalid ? 'border-danger focus-visible:ring-danger-soft' : 'border-line focus-visible:border-brand',
|
|
76
|
+
]"
|
|
77
|
+
>
|
|
78
|
+
<LpIcon name="lucide:calendar" :size="16" class="shrink-0 text-muted" />
|
|
79
|
+
<span class="min-w-0 flex-1 truncate" :class="display ? '' : 'text-muted'">
|
|
80
|
+
{{ display || placeholder }}
|
|
81
|
+
</span>
|
|
82
|
+
<span
|
|
83
|
+
v-if="clearable && modelValue && !disabled"
|
|
84
|
+
role="button"
|
|
85
|
+
tabindex="-1"
|
|
86
|
+
aria-label="Clear"
|
|
87
|
+
class="shrink-0 text-muted transition-colors hover:text-ink"
|
|
88
|
+
@click.stop="clear"
|
|
89
|
+
@keydown.enter.stop="clear"
|
|
90
|
+
>
|
|
91
|
+
<LpIcon name="lucide:x" :size="15" />
|
|
92
|
+
</span>
|
|
93
|
+
<LpIcon
|
|
94
|
+
name="lucide:chevron-down"
|
|
95
|
+
:size="15"
|
|
96
|
+
class="shrink-0 text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[state=open]:rotate-180"
|
|
97
|
+
/>
|
|
98
|
+
</button>
|
|
99
|
+
</PopoverTrigger>
|
|
100
|
+
<PopoverPortal>
|
|
101
|
+
<PopoverContent
|
|
102
|
+
:side-offset="6"
|
|
103
|
+
align="start"
|
|
104
|
+
:class="[POPOVER_PANEL, 'z-(--z-popover) rounded-card p-0 outline-none']"
|
|
105
|
+
>
|
|
106
|
+
<LpCalendar
|
|
107
|
+
:model-value="modelValue"
|
|
108
|
+
:min="min"
|
|
109
|
+
:max="max"
|
|
110
|
+
:is-disabled="isDisabled"
|
|
111
|
+
class="border-0 bg-transparent"
|
|
112
|
+
@update:model-value="onPick"
|
|
113
|
+
/>
|
|
114
|
+
</PopoverContent>
|
|
115
|
+
</PopoverPortal>
|
|
116
|
+
</PopoverRoot>
|
|
117
|
+
</template>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Collapsible "show more" section built on reka Collapsible. The trigger is a
|
|
4
|
+
* full-width row with a chevron that rotates open; the body animates its height
|
|
5
|
+
* via the collapsible-down/up keyframes (driven by reka's content-height var).
|
|
6
|
+
*
|
|
7
|
+
* `scrollIntoView` opts the block into auto-revealing itself: when it opens, the
|
|
8
|
+
* root is smoothly scrolled into the nearest scroll container so the freshly
|
|
9
|
+
* shown content isn't left below the fold.
|
|
10
|
+
*/
|
|
11
|
+
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
|
|
12
|
+
import { type ComponentPublicInstance, nextTick, ref } from "vue"
|
|
13
|
+
import LpIcon from "./LpIcon.vue"
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(
|
|
16
|
+
defineProps<{
|
|
17
|
+
/** Controlled open state. Omit to let the component own it (with `defaultOpen`). */
|
|
18
|
+
open?: boolean
|
|
19
|
+
defaultOpen?: boolean
|
|
20
|
+
/** Trigger label (or use the #trigger slot for richer content). */
|
|
21
|
+
label?: string
|
|
22
|
+
/** Optional iconify name shown left of the label. */
|
|
23
|
+
icon?: string
|
|
24
|
+
/**
|
|
25
|
+
* On open, smooth-scroll the block to the top of the nearest scroll
|
|
26
|
+
* container so its content isn't left below the fold. Fires immediately,
|
|
27
|
+
* in parallel with the expand.
|
|
28
|
+
*/
|
|
29
|
+
scrollIntoView?: boolean
|
|
30
|
+
}>(),
|
|
31
|
+
{ defaultOpen: false, scrollIntoView: false },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
|
|
35
|
+
|
|
36
|
+
// reka's CollapsibleRoot renders a real element but exposes it as a component
|
|
37
|
+
// instance, so reach through $el for the DOM node we scroll into view.
|
|
38
|
+
const root = ref<ComponentPublicInstance | null>(null)
|
|
39
|
+
|
|
40
|
+
function onToggle(value: boolean) {
|
|
41
|
+
emit("update:open", value)
|
|
42
|
+
if (value && props.scrollIntoView) {
|
|
43
|
+
// Scroll immediately, in parallel with the expand. nextTick only waits for
|
|
44
|
+
// reka to flip data-state; the header's position is stable while the body
|
|
45
|
+
// grows, so pinning the block's top to the viewport top with a smooth scroll
|
|
46
|
+
// feels instant and reveals the content as it animates open.
|
|
47
|
+
nextTick(() => {
|
|
48
|
+
const el = root.value?.$el as HTMLElement | undefined
|
|
49
|
+
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<CollapsibleRoot
|
|
57
|
+
ref="root"
|
|
58
|
+
:open="open"
|
|
59
|
+
:default-open="defaultOpen"
|
|
60
|
+
class="overflow-hidden rounded-control border border-line bg-surface-soft"
|
|
61
|
+
@update:open="onToggle"
|
|
62
|
+
>
|
|
63
|
+
<CollapsibleTrigger
|
|
64
|
+
class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
65
|
+
>
|
|
66
|
+
<LpIcon v-if="icon" :name="icon" :size="14" class="text-muted" />
|
|
67
|
+
<span class="min-w-0 flex-1 text-left">
|
|
68
|
+
<slot name="trigger">{{ label }}</slot>
|
|
69
|
+
</span>
|
|
70
|
+
<LpIcon
|
|
71
|
+
name="lucide:chevron-down"
|
|
72
|
+
:size="16"
|
|
73
|
+
class="text-muted transition-transform duration-[var(--duration-fast)] group-data-[state=open]:rotate-180"
|
|
74
|
+
/>
|
|
75
|
+
</CollapsibleTrigger>
|
|
76
|
+
|
|
77
|
+
<CollapsibleContent
|
|
78
|
+
class="group/content overflow-hidden data-[state=open]:animate-[collapsible-down_260ms_var(--ease-emphasized)] data-[state=closed]:animate-[collapsible-up_200ms_var(--ease-emphasized)]"
|
|
79
|
+
>
|
|
80
|
+
<!--
|
|
81
|
+
The height animation lives on the wrapper; the inner panel fades + drifts
|
|
82
|
+
up so the content eases in rather than snapping at full opacity. The slide
|
|
83
|
+
is short (4px) and runs slightly behind the expand for a settled feel.
|
|
84
|
+
-->
|
|
85
|
+
<div
|
|
86
|
+
class="grid gap-2.5 px-3 pt-1 pb-3.5 group-data-[state=open]/content:animate-[disclosure-body-in_300ms_var(--ease-emphasized)] motion-reduce:animate-none"
|
|
87
|
+
>
|
|
88
|
+
<slot />
|
|
89
|
+
</div>
|
|
90
|
+
</CollapsibleContent>
|
|
91
|
+
</CollapsibleRoot>
|
|
92
|
+
</template>
|