svelte-fluentui 1.4.0 → 1.5.0-rc01
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/README.md +12 -7
- package/dist/components/CommandPalette.svelte +597 -0
- package/dist/components/CommandPalette.svelte.d.ts +39 -0
- package/dist/components/CommandPalette.types.d.ts +41 -0
- package/dist/components/CommandPalette.types.js +1 -0
- package/dist/components/CommandPaletteTrigger.svelte +110 -0
- package/dist/components/CommandPaletteTrigger.svelte.d.ts +16 -0
- package/dist/components/Select.svelte +730 -730
- package/dist/components/layout/MultiSplitter.svelte +35 -11
- package/dist/components/layout/TopNav.svelte +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
A comprehensive Svelte wrapper library for Microsoft FluentUI web components (v2.6.x), providing a seamless way to use FluentUI components in Svelte applications.
|
|
4
4
|
|
|
5
|
-
## What's New in v1.
|
|
5
|
+
## What's New in v1.5.0-rc01
|
|
6
6
|
|
|
7
|
-
- **`
|
|
8
|
-
- **`
|
|
9
|
-
- **`Select` — encapsulated in a single render root via `display: contents`** — The component renders 2-4 sibling elements at root depending on mode (label + hidden source mirror + trigger + optional hidden form input in single mode; label + inline listbox in multi mode). That meant a consumer dropping `<Select>` into a flex or grid had to reason about the internal sibling structure to align it. Wrapped everything in `<div class="select-root">` with `display: contents` so the wrapper is invisible to flex/grid layout (children still participate as direct items of the consumer's container), but the component now reads as a single logical root from the outside. Zero behaviour change for existing layout code.
|
|
7
|
+
- **`CommandPalette` — a generic, app-ready command palette is now a published component** — The Spotlight-style palette that previously lived only inside this repo's docs is now exported from `svelte-fluentui` and reshaped for real apps. It dropped its hard dependency on SvelteKit's `goto`: navigation items render as real `<a>` elements so whatever router your app uses handles the click, which makes the component framework-agnostic. Items are either navigation (`href`) or actions (`onSelect`) and can carry an `icon`, a `meta` line, a `badge`, a `shortcut` hint, a `group`, and search-only `keywords`. You get layered fuzzy scoring (exact > prefix > substring > subsequence) with match highlighting, a grouped idle screen, `bind:open` control, and a configurable global `mod+k` shortcut out of the box — plus escape hatches (a custom `filter` prop and `item`/`empty`/`footer` snippets) when you need full control. See the new `/components/command-palette` demo.
|
|
8
|
+
- **`CommandPaletteTrigger` — a drop-in search pill to open the palette** — A small presentational trigger that looks like a search input but acts as a button: search icon, placeholder, and a platform-aware keyboard hint (⌘K on Mac, Ctrl K elsewhere). It ships `small` / `medium` / `large` sizes and collapses to icon-only on narrow screens. Wire its `onclick` to your palette's `bind:open` state and you have the familiar "click the search box or press the shortcut" pattern with no custom markup.
|
|
10
9
|
|
|
11
|
-
## What's New in v1.
|
|
10
|
+
## What's New in v1.4.1
|
|
12
11
|
|
|
13
|
-
-
|
|
12
|
+
- **`MultiSplitter` — splitters mounted inside hidden tab panels now render at proper widths on first reveal** — When a splitter mounted inside an inactive `<Tab>` (or any `display: none` parent), `clientWidth` was 0 at construction time, so the previous code assigned `flex-basis: 0px` to every pane and the `ResizeObserver` then short-circuited the first reveal — leaving panes smashed at 0 width with no path back to their declared `min` / `size`. The fix splits init into two phases: `scheduleInit` always installs the observer immediately (even when hidden), and `applyInitialSizes` defers its work until `totalAvailable() > 0`. The observer's callback runs the deferred init on the first 0 → non-zero transition via a new `!initApplied` branch. Bonus: hide-then-show cycles now preserve sizes instead of zeroing them via the destructive `clampToConstraints(0, ...)` path, and a reveal-after-resize-while-hidden falls through to the existing scale path so panes re-scale proportionally to the new container width. Demo added under "Inside tabs (deferred initial sizing)".
|
|
14
13
|
|
|
15
|
-
- **`
|
|
14
|
+
- **`TopNav` — the pinned desktop drawer no longer collapses on every link click** — `closeMobileMenu` was unconditionally writing `mobileMenuOpen = false`, so the documented pattern of wiring `onClick={closeDrawer}` on every `NavLinkItem` (needed for the mobile overlay to dismiss on tap) accidentally closed the pinned desktop rail too. The sync `$effect` only re-ran when `drawerPinned` itself changed, so once collapsed the rail stayed collapsed until the consumer toggled the prop. Added an early-return guard in `closeMobileMenu` — it's now a no-op when `drawerPinned` is true, matching the documented contract. Mobile overlay behaviour is unchanged.
|
|
15
|
+
|
|
16
|
+
- **`CommandPalette` — keyboard-shortcut chips in the footer are legible again** — The `.cp-footer` rule set `color: var(--neutral-foreground-hint)` for the "navigate" / "open" / "toggle" labels (reads fine on the panel background), but `.cp-footer kbd` carries its own opaque `--neutral-layer-2` background for the boxed-key look — so the hint color cascaded into the kbd boxes, giving #888 text on #f5f5f5 at 0.7 rem and the arrow / Ctrl / K glyphs effectively vanished. Added explicit `color: var(--neutral-foreground-rest)` on `.cp-footer kbd` so the kbd contents resolve against full foreground-on-layer-2 contrast.
|
|
17
|
+
|
|
18
|
+
- **Security — npm audit cleaned up, 8 of 9 advisories resolved** — Dev/build dependency chain refreshed: `ws`, `devalue`, `vite`, `postcss`, `qs`, `brace-expansion`, `svelte`, `@sveltejs/kit`, and the nested `cookie` chain all bumped past their respective advisories (3 high, 5 moderate, plus the cookie chain). Only `esbuild` 0.27.x's Windows dev-server file-read low remains, deferred because 0.28.x mis-emits output in this project's pipeline. All resolved packages are devDeps in `svelte-fluentui` — consumers don't get runtime changes, but anyone building from source no longer trips the audit gate.
|
|
19
|
+
|
|
20
|
+
- **Build pipeline — `vitePreprocess({ script: true })` restored in `docs/svelte.config.js`** — Newer `@sveltejs/vite-plugin-svelte` made `vitePreprocess()` skip TypeScript by default; Svelte 5's native parser handles most TS but tripped on curried arrow functions with typed optional params + return-type annotation and let raw `?.` / `??` operators through to the browser. Switching to `vitePreprocess({ script: true })` re-enables explicit script preprocessing. If you fork this repo or use the same config in your own project and see `SyntaxError: Unexpected token '?'` in the browser after a `@sveltejs/vite-plugin-svelte` bump — this is the fix.
|
|
16
21
|
|
|
17
22
|
## Features
|
|
18
23
|
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {onMount, tick} from "svelte"
|
|
3
|
+
import Icon from "./Icon.svelte"
|
|
4
|
+
import {portal} from "../actions/portal.js"
|
|
5
|
+
import type {SlotType} from "../types/index.js"
|
|
6
|
+
import type {CommandPaletteItem, CommandPaletteFilter} from "./CommandPalette.types.js"
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
/** Items to search. Each is either navigation (`href`) or an action (`onSelect`). */
|
|
10
|
+
items: CommandPaletteItem[]
|
|
11
|
+
/** Controlled open state. Bind it to drive the palette from your own trigger. */
|
|
12
|
+
open?: boolean
|
|
13
|
+
/** Input placeholder. */
|
|
14
|
+
placeholder?: string
|
|
15
|
+
/** Items shown on the idle screen (no query yet), grouped by `group`. Defaults to the first `maxResults` of `items`. */
|
|
16
|
+
homeItems?: CommandPaletteItem[]
|
|
17
|
+
/** Max results rendered for a query. */
|
|
18
|
+
maxResults?: number
|
|
19
|
+
/**
|
|
20
|
+
* Global keyboard shortcut that toggles the palette, e.g. "mod+k"
|
|
21
|
+
* (mod = ⌘ on Mac, Ctrl elsewhere). Set `enableShortcut={false}` to
|
|
22
|
+
* disable the built-in listener and drive `open` yourself.
|
|
23
|
+
*/
|
|
24
|
+
shortcut?: string
|
|
25
|
+
/** Register the global `shortcut` listener. */
|
|
26
|
+
enableShortcut?: boolean
|
|
27
|
+
/** Close the palette after an item is selected. */
|
|
28
|
+
closeOnSelect?: boolean
|
|
29
|
+
/** Override the built-in fuzzy scorer with app-specific search. */
|
|
30
|
+
filter?: CommandPaletteFilter
|
|
31
|
+
/** Called with the selected item before navigation/action runs. */
|
|
32
|
+
onselect?: (item: CommandPaletteItem) => void
|
|
33
|
+
onopen?: () => void
|
|
34
|
+
onclose?: () => void
|
|
35
|
+
/** Replace a result row's rendering. Receives the item and whether it's the active row. */
|
|
36
|
+
item?: SlotType
|
|
37
|
+
/** Replace the empty-state body. Receives the current query. */
|
|
38
|
+
empty?: SlotType
|
|
39
|
+
/** Replace the footer hints. */
|
|
40
|
+
footer?: SlotType
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
items,
|
|
45
|
+
open = $bindable(false),
|
|
46
|
+
placeholder = "Search…",
|
|
47
|
+
homeItems = undefined,
|
|
48
|
+
maxResults = 50,
|
|
49
|
+
shortcut = "mod+k",
|
|
50
|
+
enableShortcut = true,
|
|
51
|
+
closeOnSelect = true,
|
|
52
|
+
filter = undefined,
|
|
53
|
+
onselect = undefined,
|
|
54
|
+
onopen = undefined,
|
|
55
|
+
onclose = undefined,
|
|
56
|
+
item: itemSnippet = undefined,
|
|
57
|
+
empty: emptySnippet = undefined,
|
|
58
|
+
footer: footerSnippet = undefined
|
|
59
|
+
}: Props = $props()
|
|
60
|
+
|
|
61
|
+
let query = $state("")
|
|
62
|
+
let selectedIndex = $state(0)
|
|
63
|
+
let inputEl = $state<HTMLInputElement | undefined>(undefined)
|
|
64
|
+
let listEl = $state<HTMLDivElement | undefined>(undefined)
|
|
65
|
+
|
|
66
|
+
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/i.test(navigator.platform)
|
|
67
|
+
const modKey = isMac ? "⌘" : "Ctrl"
|
|
68
|
+
|
|
69
|
+
// Parse "mod+k" once into the modifier/key contract the keydown handler checks.
|
|
70
|
+
const parsedShortcut = $derived.by(() => {
|
|
71
|
+
const parts = shortcut.toLowerCase().split("+").map((p) => p.trim()).filter(Boolean)
|
|
72
|
+
return {
|
|
73
|
+
key: parts[parts.length - 1] ?? "",
|
|
74
|
+
mod: parts.includes("mod"),
|
|
75
|
+
ctrl: parts.includes("ctrl"),
|
|
76
|
+
meta: parts.includes("cmd") || parts.includes("meta"),
|
|
77
|
+
shift: parts.includes("shift"),
|
|
78
|
+
alt: parts.includes("alt")
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Score: higher = better match. 0 = no match. Layered so exact / prefix beat
|
|
83
|
+
// substring beat fuzzy-subsequence. Searches title, meta, group and keywords.
|
|
84
|
+
function score(it: CommandPaletteItem, needle: string): number {
|
|
85
|
+
const title = it.title.toLowerCase()
|
|
86
|
+
if (title === needle) return 1000
|
|
87
|
+
if (title.startsWith(needle)) return 500 - (title.length - needle.length)
|
|
88
|
+
const group = (it.group ?? "").toLowerCase()
|
|
89
|
+
if (group === needle) return 400
|
|
90
|
+
if (title.includes(needle)) return 300 - title.indexOf(needle)
|
|
91
|
+
if ((it.meta ?? "").toLowerCase().includes(needle)) return 200
|
|
92
|
+
if ((it.keywords ?? []).some((k) => k.toLowerCase().includes(needle))) return 150
|
|
93
|
+
if (group.includes(needle)) return 100
|
|
94
|
+
// Fuzzy: every character of needle must appear in title in order.
|
|
95
|
+
let i = 0
|
|
96
|
+
for (const c of title) {
|
|
97
|
+
if (c === needle[i]) i++
|
|
98
|
+
if (i === needle.length) return 20
|
|
99
|
+
}
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const trimmedQuery = $derived(query.trim())
|
|
104
|
+
|
|
105
|
+
const results = $derived.by(() => {
|
|
106
|
+
const q = trimmedQuery
|
|
107
|
+
if (!q) return []
|
|
108
|
+
if (filter) return filter(items, q).slice(0, maxResults)
|
|
109
|
+
const needle = q.toLowerCase()
|
|
110
|
+
return items
|
|
111
|
+
.map((it) => ({it, s: score(it, needle)}))
|
|
112
|
+
.filter((r) => r.s > 0)
|
|
113
|
+
.sort((a, b) => b.s - a.s)
|
|
114
|
+
.slice(0, maxResults)
|
|
115
|
+
.map((r) => r.it)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Idle screen: grouped sections preserving first-appearance order.
|
|
119
|
+
const homeSections = $derived.by(() => {
|
|
120
|
+
const source = (homeItems ?? items).slice(0, maxResults)
|
|
121
|
+
const order: string[] = []
|
|
122
|
+
const byGroup = new Map<string, CommandPaletteItem[]>()
|
|
123
|
+
for (const it of source) {
|
|
124
|
+
const g = it.group ?? ""
|
|
125
|
+
if (!byGroup.has(g)) {
|
|
126
|
+
byGroup.set(g, [])
|
|
127
|
+
order.push(g)
|
|
128
|
+
}
|
|
129
|
+
byGroup.get(g)!.push(it)
|
|
130
|
+
}
|
|
131
|
+
return order.map((g) => ({group: g, items: byGroup.get(g)!}))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Flattened render list — section headers interleaved with rows — so a single
|
|
135
|
+
// running index drives both keyboard nav and the highlight. Headers carry no index.
|
|
136
|
+
type Row =
|
|
137
|
+
| {kind: "header"; label: string}
|
|
138
|
+
| {kind: "item"; item: CommandPaletteItem; index: number}
|
|
139
|
+
|
|
140
|
+
const rows = $derived.by(() => {
|
|
141
|
+
const out: Row[] = []
|
|
142
|
+
let index = 0
|
|
143
|
+
if (trimmedQuery) {
|
|
144
|
+
for (const it of results) out.push({kind: "item", item: it, index: index++})
|
|
145
|
+
} else {
|
|
146
|
+
for (const section of homeSections) {
|
|
147
|
+
if (section.group) out.push({kind: "header", label: section.group})
|
|
148
|
+
for (const it of section.items) out.push({kind: "item", item: it, index: index++})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const itemCount = $derived(rows.filter((r) => r.kind === "item").length)
|
|
155
|
+
|
|
156
|
+
function itemKey(it: CommandPaletteItem): string {
|
|
157
|
+
return it.id ?? it.href ?? it.title
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Split a title around the first contiguous match of the query for <mark>
|
|
161
|
+
// highlighting. Subsequence (fuzzy) matches don't highlight — only the simple
|
|
162
|
+
// substring case, which is what users actually read.
|
|
163
|
+
function highlight(title: string): {text: string; mark: boolean}[] {
|
|
164
|
+
const q = trimmedQuery
|
|
165
|
+
if (!q) return [{text: title, mark: false}]
|
|
166
|
+
const i = title.toLowerCase().indexOf(q.toLowerCase())
|
|
167
|
+
if (i < 0) return [{text: title, mark: false}]
|
|
168
|
+
return [
|
|
169
|
+
{text: title.slice(0, i), mark: false},
|
|
170
|
+
{text: title.slice(i, i + q.length), mark: true},
|
|
171
|
+
{text: title.slice(i + q.length), mark: false}
|
|
172
|
+
].filter((s) => s.text.length > 0)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function openPalette() {
|
|
176
|
+
open = true
|
|
177
|
+
query = ""
|
|
178
|
+
selectedIndex = 0
|
|
179
|
+
onopen?.()
|
|
180
|
+
await tick()
|
|
181
|
+
inputEl?.focus()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function closePalette() {
|
|
185
|
+
if (!open) return
|
|
186
|
+
open = false
|
|
187
|
+
onclose?.()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function selectItem(it: CommandPaletteItem) {
|
|
191
|
+
if (it.disabled) return
|
|
192
|
+
onselect?.(it)
|
|
193
|
+
if (closeOnSelect) closePalette()
|
|
194
|
+
// Actions run inline; navigation rows are real <a> elements, so a click
|
|
195
|
+
// (mouse or the synthetic Enter click below) lets the host router handle it.
|
|
196
|
+
it.onSelect?.()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Enter activates the highlighted row by clicking its DOM node — works for both
|
|
200
|
+
// <button> actions and <a> navigation (router intercepts the anchor click).
|
|
201
|
+
function activateSelected() {
|
|
202
|
+
const el = listEl?.querySelector<HTMLElement>(`[data-idx="${selectedIndex}"]`)
|
|
203
|
+
el?.click()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function matchesShortcut(e: KeyboardEvent): boolean {
|
|
207
|
+
const s = parsedShortcut
|
|
208
|
+
if (!s.key || e.key.toLowerCase() !== s.key) return false
|
|
209
|
+
const wantCtrlOrMeta = s.mod || s.ctrl || s.meta
|
|
210
|
+
if (wantCtrlOrMeta && !(e.ctrlKey || e.metaKey)) return false
|
|
211
|
+
if (!wantCtrlOrMeta && (e.ctrlKey || e.metaKey)) return false
|
|
212
|
+
if (s.shift !== e.shiftKey) return false
|
|
213
|
+
if (s.alt !== e.altKey) return false
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Reset highlighted row whenever the query changes.
|
|
218
|
+
$effect(() => {
|
|
219
|
+
query
|
|
220
|
+
selectedIndex = 0
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Keep highlighted row in view during keyboard nav.
|
|
224
|
+
$effect(() => {
|
|
225
|
+
if (!open || !listEl) return
|
|
226
|
+
const target = listEl.querySelector<HTMLElement>(`[data-idx="${selectedIndex}"]`)
|
|
227
|
+
target?.scrollIntoView({block: "nearest"})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
function handleGlobalKey(e: KeyboardEvent) {
|
|
231
|
+
if (enableShortcut && matchesShortcut(e)) {
|
|
232
|
+
e.preventDefault()
|
|
233
|
+
if (open) closePalette()
|
|
234
|
+
else openPalette()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!open) return
|
|
239
|
+
|
|
240
|
+
if (e.key === "Escape") {
|
|
241
|
+
e.preventDefault()
|
|
242
|
+
closePalette()
|
|
243
|
+
} else if (e.key === "ArrowDown") {
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
selectedIndex = Math.min(selectedIndex + 1, Math.max(itemCount - 1, 0))
|
|
246
|
+
} else if (e.key === "ArrowUp") {
|
|
247
|
+
e.preventDefault()
|
|
248
|
+
selectedIndex = Math.max(selectedIndex - 1, 0)
|
|
249
|
+
} else if (e.key === "Enter") {
|
|
250
|
+
e.preventDefault()
|
|
251
|
+
activateSelected()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onMount(() => {
|
|
256
|
+
document.addEventListener("keydown", handleGlobalKey)
|
|
257
|
+
return () => document.removeEventListener("keydown", handleGlobalKey)
|
|
258
|
+
})
|
|
259
|
+
</script>
|
|
260
|
+
|
|
261
|
+
{#snippet rowBody(it: CommandPaletteItem)}
|
|
262
|
+
{#if it.icon}
|
|
263
|
+
<span class="cp-item-icon"><Icon name={it.icon} size={20} /></span>
|
|
264
|
+
{/if}
|
|
265
|
+
<span class="cp-item-content">
|
|
266
|
+
<span class="cp-item-title">
|
|
267
|
+
{#each highlight(it.title) as seg}{#if seg.mark}<mark>{seg.text}</mark>{:else}{seg.text}{/if}{/each}
|
|
268
|
+
</span>
|
|
269
|
+
{#if it.meta}<span class="cp-item-meta">{it.meta}</span>{/if}
|
|
270
|
+
</span>
|
|
271
|
+
{#if trimmedQuery && it.group}
|
|
272
|
+
<span class="cp-item-group">{it.group}</span>
|
|
273
|
+
{/if}
|
|
274
|
+
{#if it.badge}
|
|
275
|
+
<span class="cp-item-badge">{it.badge}</span>
|
|
276
|
+
{/if}
|
|
277
|
+
{#if it.shortcut}
|
|
278
|
+
<span class="cp-item-shortcut">
|
|
279
|
+
{#each it.shortcut.split(" ") as key}<kbd>{key}</kbd>{/each}
|
|
280
|
+
</span>
|
|
281
|
+
{/if}
|
|
282
|
+
{/snippet}
|
|
283
|
+
|
|
284
|
+
{#if open}
|
|
285
|
+
<div use:portal class="cp-root">
|
|
286
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
287
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
288
|
+
<div class="cp-backdrop" onclick={closePalette}></div>
|
|
289
|
+
<div
|
|
290
|
+
class="cp-panel"
|
|
291
|
+
role="dialog"
|
|
292
|
+
aria-modal="true"
|
|
293
|
+
aria-label="Command palette"
|
|
294
|
+
>
|
|
295
|
+
<div class="cp-search-row">
|
|
296
|
+
<svg class="cp-search-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
297
|
+
<path d="M7 1.75a5.25 5.25 0 1 0 3.215 9.4l3.067 3.068a.75.75 0 1 0 1.061-1.061l-3.066-3.066A5.25 5.25 0 0 0 7 1.75zM3.25 7a3.75 3.75 0 1 1 7.5 0 3.75 3.75 0 0 1-7.5 0z" fill="currentColor"/>
|
|
298
|
+
</svg>
|
|
299
|
+
<input
|
|
300
|
+
bind:this={inputEl}
|
|
301
|
+
bind:value={query}
|
|
302
|
+
type="text"
|
|
303
|
+
class="cp-input"
|
|
304
|
+
{placeholder}
|
|
305
|
+
autocomplete="off"
|
|
306
|
+
spellcheck="false"
|
|
307
|
+
/>
|
|
308
|
+
<span class="cp-kbd-hint">esc to close</span>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div class="cp-results" bind:this={listEl} role="listbox">
|
|
312
|
+
{#each rows as row (row.kind === "header" ? "h:" + row.label : itemKey(row.item) + ":" + row.index)}
|
|
313
|
+
{#if row.kind === "header"}
|
|
314
|
+
<div class="cp-section">{row.label}</div>
|
|
315
|
+
{:else if row.item.href && !row.item.onSelect}
|
|
316
|
+
<!-- Navigation row — real <a> so the host router handles the click. -->
|
|
317
|
+
<a
|
|
318
|
+
class="cp-item"
|
|
319
|
+
class:selected={row.index === selectedIndex}
|
|
320
|
+
class:disabled={row.item.disabled}
|
|
321
|
+
data-idx={row.index}
|
|
322
|
+
href={row.item.disabled ? undefined : row.item.href}
|
|
323
|
+
target={row.item.target ?? null}
|
|
324
|
+
rel={row.item.rel ?? null}
|
|
325
|
+
role="option"
|
|
326
|
+
aria-selected={row.index === selectedIndex}
|
|
327
|
+
onclick={() => selectItem(row.item)}
|
|
328
|
+
onmousemove={() => (selectedIndex = row.index)}
|
|
329
|
+
>
|
|
330
|
+
{#if itemSnippet}{@render itemSnippet(row.item, row.index === selectedIndex)}{:else}{@render rowBody(row.item)}{/if}
|
|
331
|
+
</a>
|
|
332
|
+
{:else}
|
|
333
|
+
<!-- Action row. -->
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
class="cp-item"
|
|
337
|
+
class:selected={row.index === selectedIndex}
|
|
338
|
+
data-idx={row.index}
|
|
339
|
+
disabled={row.item.disabled || undefined}
|
|
340
|
+
role="option"
|
|
341
|
+
aria-selected={row.index === selectedIndex}
|
|
342
|
+
onclick={() => selectItem(row.item)}
|
|
343
|
+
onmousemove={() => (selectedIndex = row.index)}
|
|
344
|
+
>
|
|
345
|
+
{#if itemSnippet}{@render itemSnippet(row.item, row.index === selectedIndex)}{:else}{@render rowBody(row.item)}{/if}
|
|
346
|
+
</button>
|
|
347
|
+
{/if}
|
|
348
|
+
{/each}
|
|
349
|
+
|
|
350
|
+
{#if itemCount === 0}
|
|
351
|
+
{#if emptySnippet}
|
|
352
|
+
{@render emptySnippet(trimmedQuery)}
|
|
353
|
+
{:else}
|
|
354
|
+
<div class="cp-empty">
|
|
355
|
+
{#if trimmedQuery}No matches for "{trimmedQuery}"{:else}No items{/if}
|
|
356
|
+
</div>
|
|
357
|
+
{/if}
|
|
358
|
+
{/if}
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
{#if footerSnippet}
|
|
362
|
+
{@render footerSnippet()}
|
|
363
|
+
{:else}
|
|
364
|
+
<div class="cp-footer">
|
|
365
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
|
|
366
|
+
<span><kbd>↵</kbd> select</span>
|
|
367
|
+
<span><kbd>{modKey}</kbd><kbd>{parsedShortcut.key.toUpperCase()}</kbd> toggle</span>
|
|
368
|
+
</div>
|
|
369
|
+
{/if}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
{/if}
|
|
373
|
+
|
|
374
|
+
<style>
|
|
375
|
+
.cp-root {
|
|
376
|
+
position: fixed;
|
|
377
|
+
inset: 0;
|
|
378
|
+
z-index: var(--fluent-z-modal, 1070);
|
|
379
|
+
display: flex;
|
|
380
|
+
justify-content: center;
|
|
381
|
+
align-items: flex-start;
|
|
382
|
+
padding-top: 12vh;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.cp-backdrop {
|
|
386
|
+
position: absolute;
|
|
387
|
+
inset: 0;
|
|
388
|
+
background: rgba(0, 0, 0, 0.4);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.cp-panel {
|
|
392
|
+
position: relative;
|
|
393
|
+
width: min(640px, calc(100vw - 2rem));
|
|
394
|
+
max-height: 70vh;
|
|
395
|
+
display: flex;
|
|
396
|
+
flex-direction: column;
|
|
397
|
+
background: var(--neutral-layer-1, #ffffff);
|
|
398
|
+
border: 1px solid var(--neutral-stroke-layer-rest, #e0e0e0);
|
|
399
|
+
border-radius: 8px;
|
|
400
|
+
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22), 0 0 2px rgba(0, 0, 0, 0.12);
|
|
401
|
+
overflow: hidden;
|
|
402
|
+
font-family: var(--body-font);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.cp-search-row {
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
gap: 0.5rem;
|
|
409
|
+
padding: 0.75rem 1rem;
|
|
410
|
+
border-bottom: 1px solid var(--neutral-stroke-layer-rest, #e0e0e0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.cp-search-icon {
|
|
414
|
+
color: var(--neutral-foreground-hint, #666);
|
|
415
|
+
flex-shrink: 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.cp-input {
|
|
419
|
+
flex: 1;
|
|
420
|
+
min-width: 0;
|
|
421
|
+
border: none;
|
|
422
|
+
outline: none;
|
|
423
|
+
background: transparent;
|
|
424
|
+
font-size: 1rem;
|
|
425
|
+
color: var(--neutral-foreground-rest, inherit);
|
|
426
|
+
font-family: inherit;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.cp-kbd-hint {
|
|
430
|
+
color: var(--neutral-foreground-hint, #888);
|
|
431
|
+
font-size: 0.75rem;
|
|
432
|
+
flex-shrink: 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.cp-results {
|
|
436
|
+
flex: 1;
|
|
437
|
+
overflow-y: auto;
|
|
438
|
+
overscroll-behavior: contain;
|
|
439
|
+
padding: 0.25rem;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.cp-section {
|
|
443
|
+
padding: 0.5rem 0.75rem 0.25rem;
|
|
444
|
+
font-size: 0.6875rem;
|
|
445
|
+
font-weight: 600;
|
|
446
|
+
letter-spacing: 0.04em;
|
|
447
|
+
text-transform: uppercase;
|
|
448
|
+
color: var(--neutral-foreground-hint, #888);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.cp-item {
|
|
452
|
+
display: flex;
|
|
453
|
+
align-items: center;
|
|
454
|
+
gap: 0.75rem;
|
|
455
|
+
width: 100%;
|
|
456
|
+
padding: 0.5rem 0.75rem;
|
|
457
|
+
border: none;
|
|
458
|
+
border-radius: 4px;
|
|
459
|
+
background: transparent;
|
|
460
|
+
cursor: pointer;
|
|
461
|
+
text-align: start;
|
|
462
|
+
text-decoration: none;
|
|
463
|
+
font: inherit;
|
|
464
|
+
color: var(--neutral-foreground-rest, inherit);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.cp-item.selected {
|
|
468
|
+
background: var(--accent-fill-rest, #0078d4);
|
|
469
|
+
color: var(--neutral-foreground-on-accent, #ffffff);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.cp-item.disabled {
|
|
473
|
+
cursor: not-allowed;
|
|
474
|
+
opacity: 0.5;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.cp-item-icon {
|
|
478
|
+
display: inline-flex;
|
|
479
|
+
align-items: center;
|
|
480
|
+
flex-shrink: 0;
|
|
481
|
+
color: var(--accent-fill-rest);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.cp-item.selected .cp-item-icon {
|
|
485
|
+
color: inherit;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.cp-item-content {
|
|
489
|
+
flex: 1;
|
|
490
|
+
min-width: 0;
|
|
491
|
+
display: flex;
|
|
492
|
+
flex-direction: column;
|
|
493
|
+
gap: 0.0625rem;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.cp-item-title {
|
|
497
|
+
font-size: 0.9375rem;
|
|
498
|
+
overflow: hidden;
|
|
499
|
+
text-overflow: ellipsis;
|
|
500
|
+
white-space: nowrap;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.cp-item-title :global(mark) {
|
|
504
|
+
background: transparent;
|
|
505
|
+
color: var(--accent-fill-rest, #0078d4);
|
|
506
|
+
font-weight: 700;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.cp-item.selected .cp-item-title :global(mark) {
|
|
510
|
+
color: inherit;
|
|
511
|
+
text-decoration: underline;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.cp-item-meta {
|
|
515
|
+
font-size: 0.75rem;
|
|
516
|
+
color: var(--neutral-foreground-hint, #888);
|
|
517
|
+
overflow: hidden;
|
|
518
|
+
text-overflow: ellipsis;
|
|
519
|
+
white-space: nowrap;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.cp-item.selected .cp-item-meta {
|
|
523
|
+
color: inherit;
|
|
524
|
+
opacity: 0.85;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.cp-item-group {
|
|
528
|
+
flex-shrink: 0;
|
|
529
|
+
font-size: 0.75rem;
|
|
530
|
+
opacity: 0.8;
|
|
531
|
+
padding: 0.125rem 0.5rem;
|
|
532
|
+
border-radius: 999px;
|
|
533
|
+
background: var(--neutral-fill-secondary-rest, rgba(0, 0, 0, 0.06));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.cp-item.selected .cp-item-group {
|
|
537
|
+
background: rgba(255, 255, 255, 0.2);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.cp-item-badge {
|
|
541
|
+
flex-shrink: 0;
|
|
542
|
+
font-size: 0.6875rem;
|
|
543
|
+
font-weight: 600;
|
|
544
|
+
padding: 0.125rem 0.5rem;
|
|
545
|
+
border-radius: 4px;
|
|
546
|
+
background: var(--neutral-fill-secondary-rest, rgba(0, 0, 0, 0.06));
|
|
547
|
+
color: var(--neutral-foreground-rest);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.cp-item.selected .cp-item-badge {
|
|
551
|
+
background: rgba(255, 255, 255, 0.2);
|
|
552
|
+
color: inherit;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.cp-item-shortcut {
|
|
556
|
+
display: inline-flex;
|
|
557
|
+
gap: 0.15rem;
|
|
558
|
+
flex-shrink: 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.cp-empty {
|
|
562
|
+
padding: 1.5rem 1rem;
|
|
563
|
+
text-align: center;
|
|
564
|
+
color: var(--neutral-foreground-hint, #888);
|
|
565
|
+
font-size: 0.875rem;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.cp-footer {
|
|
569
|
+
display: flex;
|
|
570
|
+
gap: 1rem;
|
|
571
|
+
padding: 0.5rem 1rem;
|
|
572
|
+
border-top: 1px solid var(--neutral-stroke-layer-rest, #e0e0e0);
|
|
573
|
+
font-size: 0.75rem;
|
|
574
|
+
color: var(--neutral-foreground-hint, #888);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.cp-footer kbd,
|
|
578
|
+
.cp-item-shortcut kbd {
|
|
579
|
+
display: inline-block;
|
|
580
|
+
padding: 0.05rem 0.35rem;
|
|
581
|
+
border: 1px solid var(--neutral-stroke-layer-rest, #ccc);
|
|
582
|
+
border-radius: 3px;
|
|
583
|
+
background: var(--neutral-layer-2, #f5f5f5);
|
|
584
|
+
color: var(--neutral-foreground-rest, #222);
|
|
585
|
+
font-family: inherit;
|
|
586
|
+
font-size: 0.7rem;
|
|
587
|
+
margin: 0 0.15rem 0 0;
|
|
588
|
+
min-width: 1rem;
|
|
589
|
+
text-align: center;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.cp-item.selected .cp-item-shortcut kbd {
|
|
593
|
+
background: rgba(255, 255, 255, 0.2);
|
|
594
|
+
border-color: rgba(255, 255, 255, 0.3);
|
|
595
|
+
color: inherit;
|
|
596
|
+
}
|
|
597
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SlotType } from "../types/index.js";
|
|
2
|
+
import type { CommandPaletteItem, CommandPaletteFilter } from "./CommandPalette.types.js";
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Items to search. Each is either navigation (`href`) or an action (`onSelect`). */
|
|
5
|
+
items: CommandPaletteItem[];
|
|
6
|
+
/** Controlled open state. Bind it to drive the palette from your own trigger. */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/** Input placeholder. */
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Items shown on the idle screen (no query yet), grouped by `group`. Defaults to the first `maxResults` of `items`. */
|
|
11
|
+
homeItems?: CommandPaletteItem[];
|
|
12
|
+
/** Max results rendered for a query. */
|
|
13
|
+
maxResults?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Global keyboard shortcut that toggles the palette, e.g. "mod+k"
|
|
16
|
+
* (mod = ⌘ on Mac, Ctrl elsewhere). Set `enableShortcut={false}` to
|
|
17
|
+
* disable the built-in listener and drive `open` yourself.
|
|
18
|
+
*/
|
|
19
|
+
shortcut?: string;
|
|
20
|
+
/** Register the global `shortcut` listener. */
|
|
21
|
+
enableShortcut?: boolean;
|
|
22
|
+
/** Close the palette after an item is selected. */
|
|
23
|
+
closeOnSelect?: boolean;
|
|
24
|
+
/** Override the built-in fuzzy scorer with app-specific search. */
|
|
25
|
+
filter?: CommandPaletteFilter;
|
|
26
|
+
/** Called with the selected item before navigation/action runs. */
|
|
27
|
+
onselect?: (item: CommandPaletteItem) => void;
|
|
28
|
+
onopen?: () => void;
|
|
29
|
+
onclose?: () => void;
|
|
30
|
+
/** Replace a result row's rendering. Receives the item and whether it's the active row. */
|
|
31
|
+
item?: SlotType;
|
|
32
|
+
/** Replace the empty-state body. Receives the current query. */
|
|
33
|
+
empty?: SlotType;
|
|
34
|
+
/** Replace the footer hints. */
|
|
35
|
+
footer?: SlotType;
|
|
36
|
+
};
|
|
37
|
+
declare const CommandPalette: import("svelte").Component<Props, {}, "open">;
|
|
38
|
+
type CommandPalette = ReturnType<typeof CommandPalette>;
|
|
39
|
+
export default CommandPalette;
|