@spaethtech/svelte-ui 0.14.0 → 0.15.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/.claude/skills/svelte-ui/SKILL.md +14 -11
- package/dist/components/Carousel/Carousel.svelte +147 -0
- package/dist/components/Carousel/Carousel.svelte.d.ts +38 -0
- package/dist/components/Carousel/index.d.ts +1 -0
- package/dist/components/Carousel/index.js +1 -0
- package/dist/components/Combobox/Combobox.svelte +62 -31
- package/dist/components/Combobox/Combobox.svelte.d.ts +1 -1
- package/dist/components/CommandPalette/CommandPalette.svelte +203 -0
- package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +23 -0
- package/dist/components/CommandPalette/index.d.ts +2 -0
- package/dist/components/CommandPalette/index.js +1 -0
- package/dist/components/ContextMenu/ContextMenu.svelte +58 -0
- package/dist/components/ContextMenu/ContextMenu.svelte.d.ts +16 -0
- package/dist/components/ContextMenu/index.d.ts +1 -0
- package/dist/components/ContextMenu/index.js +1 -0
- package/dist/components/EmptyState/EmptyState.svelte +68 -0
- package/dist/components/EmptyState/EmptyState.svelte.d.ts +19 -0
- package/dist/components/EmptyState/index.d.ts +1 -0
- package/dist/components/EmptyState/index.js +1 -0
- package/dist/components/Kbd/Kbd.svelte +53 -0
- package/dist/components/Kbd/Kbd.svelte.d.ts +15 -0
- package/dist/components/Kbd/index.d.ts +1 -0
- package/dist/components/Kbd/index.js +1 -0
- package/dist/components/Pagination/Pagination.svelte +58 -43
- package/dist/components/Pagination/Pagination.svelte.d.ts +7 -2
- package/dist/components/Rating.svelte +169 -30
- package/dist/components/Rating.svelte.d.ts +22 -4
- package/dist/components/ScrollArea/ScrollArea.svelte +71 -0
- package/dist/components/ScrollArea/ScrollArea.svelte.d.ts +15 -0
- package/dist/components/ScrollArea/index.d.ts +1 -0
- package/dist/components/ScrollArea/index.js +1 -0
- package/dist/components/Slider/Slider.svelte +5 -3
- package/dist/components/Splitter/Splitter.svelte +127 -0
- package/dist/components/Splitter/Splitter.svelte.d.ts +18 -0
- package/dist/components/Splitter/index.d.ts +1 -0
- package/dist/components/Splitter/index.js +1 -0
- package/dist/components/Stat/Stat.svelte +95 -0
- package/dist/components/Stat/Stat.svelte.d.ts +21 -0
- package/dist/components/Stat/index.d.ts +1 -0
- package/dist/components/Stat/index.js +1 -0
- package/dist/components/Timeline/Timeline.svelte +98 -0
- package/dist/components/Timeline/Timeline.svelte.d.ts +21 -0
- package/dist/components/Timeline/index.d.ts +2 -0
- package/dist/components/Timeline/index.js +1 -0
- package/dist/components/TokenInput/TokenInput.svelte +12 -6
- package/dist/components/Tree/Tree.svelte +178 -0
- package/dist/components/Tree/Tree.svelte.d.ts +26 -0
- package/dist/components/Tree/index.d.ts +2 -0
- package/dist/components/Tree/index.js +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +10 -1
- package/docs/components.md +125 -22
- package/docs/usage.md +180 -13
- package/package.json +1 -1
|
@@ -153,7 +153,8 @@ All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/u
|
|
|
153
153
|
examples):
|
|
154
154
|
|
|
155
155
|
- **Form:** `Button` `ButtonDropdown` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
|
|
156
|
-
`Toggle` `Radio` `Rating`
|
|
156
|
+
`Toggle` `Radio` `Rating` (display `average`/`count`, OR `interactive` + `bind:value` star picker
|
|
157
|
+
with `allowHalf`, click/hover/arrow-keys) · **`Combobox`** (free-text autocomplete — arbitrary value + suggestions,
|
|
157
158
|
client `options` or async `suggest`; vs `Select` which is a closed choice. Composes Input + Popup.) ·
|
|
158
159
|
**`TokenInput`** (multi-value entry — Enter/comma adds a removable `Chip` token, Backspace-on-empty
|
|
159
160
|
removes; `bind:values`, `max`, `allowDuplicates`; FieldChrome.) · **`FileUpload`** (drag-drop + click
|
|
@@ -170,20 +171,22 @@ examples):
|
|
|
170
171
|
- **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
|
|
171
172
|
`percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
|
|
172
173
|
`parse`/`format` for per-country)
|
|
173
|
-
- **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput
|
|
174
|
-
|
|
175
|
-
`Calendar mode="range"
|
|
174
|
+
- **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`.
|
|
175
|
+
For a start–end range use **`DatePicker range`** (`bind:value` = `{ start, end }`) — it composes
|
|
176
|
+
`Calendar mode="range"`.
|
|
176
177
|
- **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
|
|
177
178
|
(query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
|
|
178
|
-
pager
|
|
179
|
-
`
|
|
179
|
+
pager styled like the DataTable footer: tinted band, per-page `Select`, range total, and a
|
|
180
|
+
`First·Prev·[page]/N·Next·Last` stepper whose indicator is an editable `NumberInput` quick-jump;
|
|
181
|
+
`bind:page`/`bind:perPage`, `total`, `perPageOptions`, `showEdges`/`showTotal`, `borderless`;
|
|
182
|
+
`variant` tints band+hover only; built from `Button`+`Select`+`NumberInput`).
|
|
180
183
|
- **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
|
|
181
184
|
`onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
|
|
182
185
|
`ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
|
|
183
186
|
· **`Drawer`** (edge-anchored sliding panel / "sheet"; `side` left/right/top/bottom, `size` CSS
|
|
184
187
|
length, `bind:open`, `dismissible`/`backdrop`; same modal mechanics as Dialog — a pure shell, put a
|
|
185
|
-
Card inside) · `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
|
|
186
|
-
- **Navigation:** **`Breadcrumbs`** (trail of `BreadcrumbItem` {`label`,`href`,`onclick`,`icon`}; last =
|
|
188
|
+
Card inside) · `Popup` `Menu` · **`ContextMenu`** (right-click menu at the pointer; `items` MenuItem[]; on `Menu`) · **`CommandPalette`** (⌘K/Ctrl+K fuzzy command launcher; `bind:open`, `commands` Command[] {`label`,`keywords`,`group`,`icon`,`shortcut`,`disabled`,`onrun`}, global `hotkey` default `"mod+k"`; ↑/↓/Enter/Esc; on `Input`+`Kbd`) · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
|
|
189
|
+
- **Navigation:** **`Tree`** (hierarchical expandable tree — `items` of `TreeNode`; `bind:expanded`/`selected`; roving-tabindex keyboard; ARIA `tree`) · **`Breadcrumbs`** (trail of `BreadcrumbItem` {`label`,`href`,`onclick`,`icon`}; last =
|
|
187
190
|
current page; `maxItems` collapses the middle to a `⋯` Menu; `separator` slot) · `TabStrip` (tabs;
|
|
188
191
|
`size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
|
|
189
192
|
fixed-height bar with bottom-aligned tabs, `activeSurface` to flow the active tab into the content
|
|
@@ -193,14 +196,14 @@ examples):
|
|
|
193
196
|
`interactMode` `'hover'|'click'` popouts; `currentPath` active-match (per-item `activeMatch` for
|
|
194
197
|
query-keyed/exact rules); `bind:open`/`bind:api`/`bind:activeMode`; `publishWidthVar` → CSS width var;
|
|
195
198
|
popouts expose `data-sbm-popout`/`data-open`)
|
|
196
|
-
- **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast` · **`Spinner`** (indeterminate loading
|
|
199
|
+
- **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast` · **`Kbd`** (keyboard keycap; `text`/`keys` combo) · **`Spinner`** (indeterminate loading
|
|
197
200
|
ring; `variant`/`size`, omit `variant` to inherit `currentColor`. `Button` has a `loading` prop that
|
|
198
201
|
renders one in place of its icon and blocks interaction.) · **`Progress`** (horizontal bar;
|
|
199
202
|
determinate `value`/`max` or `indeterminate` sweep; `variant`/`size` = 4/8/12px; `label`/`showValue`.)
|
|
200
203
|
· **`Skeleton`** (neutral loading placeholder; `shape` text/rect/circle, `lines`, `width`/`height`,
|
|
201
204
|
`animate` shimmer. No `variant` — it's absent-content, not a themed surface. Compose to sketch a card.)
|
|
202
205
|
- **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
|
|
203
|
-
`columns` + `gap`) · **`Divider`** (thin `role="separator"` rule; `orientation` h/v, optional inline
|
|
206
|
+
`columns` + `gap`) · **`ScrollArea`** (themed thin-scrollbar container; `orientation`, `maxHeight`, `variant` thumb) · **`Splitter`** (two resizable panes + draggable divider; `bind:size` %, `orientation`, keyboard) · **`Carousel`** (sliding viewport; `items` + `slide` snippet, `bind:index`, `autoplay`, controls + dots) · **`Stat`** (metric/KPI card — `label`/`value`/`delta` up-down + `icon`; on `Card`) · **`Timeline`** (vertical event list — `items` of `TimelineItem` {`title`,`time`,`description`,`icon`,`variant`}) · **`Divider`** (thin `role="separator"` rule; `orientation` h/v, optional inline
|
|
204
207
|
`label`+`labelAlign`; `--ui-border-color`)
|
|
205
208
|
- **Flow:** **`Stepper`** (linear multi-step indicator — `steps` of `StepItem` {`label`,`description`,
|
|
206
209
|
`icon`}, `bind:current`, `orientation` h/v, `onstep` clickable; done/current/upcoming markers +
|
|
@@ -208,7 +211,7 @@ examples):
|
|
|
208
211
|
- **Disclosure:** **`Disclosure`** (one expand/collapse section; `title`/`header`, `bind:open`, panel
|
|
209
212
|
stays mounted so state is preserved) · **`Accordion`** (coordinated group — `multiple` single/multi,
|
|
210
213
|
`bind:value`; children are `<Disclosure value="…">`, no per-item `bind:open`)
|
|
211
|
-
- **Display / theme:** **`Chip`** (interactive pill — `selected` toggle fill, `removable` ×/`onremove`,
|
|
214
|
+
- **Display / theme:** **`EmptyState`** (zero-data placeholder — icon + title + description + `action`) · **`Chip`** (interactive pill — `selected` toggle fill, `removable` ×/`onremove`,
|
|
212
215
|
`onclick`/`href`, `icon`; the interactive counterpart to display-only `Badge`) · **`Avatar`** (image →
|
|
213
216
|
`name` initials → `icon` → glyph fallback; `size`, `variant` fill, `shape`, `status` dot) +
|
|
214
217
|
**`AvatarGroup`** (overlapped stack; `items`+`max` → `+N`) ·
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* Carousel — a sliding, single-item-at-a-time viewport with prev/next controls + dot indicators.
|
|
4
|
+
* Data-driven (`items` + a `slide` snippet); `bind:index`, optional `autoplay`, `loop`, keyboard.
|
|
5
|
+
* ARIA carousel roles. Composes `Button`. See Carousel.spec.md.
|
|
6
|
+
*/
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts" module>
|
|
9
|
+
import type { Snippet } from "svelte";
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts" generics="T">
|
|
13
|
+
import Button from "../Button.svelte";
|
|
14
|
+
import IconPrev from "~icons/mdi/chevron-left";
|
|
15
|
+
import IconNext from "~icons/mdi/chevron-right";
|
|
16
|
+
import type { Variant } from "../../types/variants.js";
|
|
17
|
+
import { variantToken } from "../../types/variants.js";
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
items,
|
|
21
|
+
slide,
|
|
22
|
+
index = $bindable(0),
|
|
23
|
+
loop = true,
|
|
24
|
+
autoplay,
|
|
25
|
+
controls = true,
|
|
26
|
+
indicators = true,
|
|
27
|
+
variant = "primary",
|
|
28
|
+
ariaLabel = "Carousel",
|
|
29
|
+
class: cls = "",
|
|
30
|
+
}: {
|
|
31
|
+
items: T[];
|
|
32
|
+
slide: Snippet<[T, number]>;
|
|
33
|
+
index?: number;
|
|
34
|
+
loop?: boolean;
|
|
35
|
+
/** Auto-advance interval in ms; pauses on hover/focus. Omit to disable. */
|
|
36
|
+
autoplay?: number;
|
|
37
|
+
controls?: boolean;
|
|
38
|
+
indicators?: boolean;
|
|
39
|
+
variant?: Variant;
|
|
40
|
+
ariaLabel?: string;
|
|
41
|
+
class?: string;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
const n = $derived(items.length);
|
|
45
|
+
const token = $derived(variantToken[variant]);
|
|
46
|
+
let paused = $state(false);
|
|
47
|
+
|
|
48
|
+
const go = (i: number) => {
|
|
49
|
+
index = loop ? (i + n) % n : Math.min(Math.max(i, 0), n - 1);
|
|
50
|
+
};
|
|
51
|
+
const next = () => go(index + 1);
|
|
52
|
+
const prev = () => go(index - 1);
|
|
53
|
+
|
|
54
|
+
$effect(() => {
|
|
55
|
+
index = Math.min(Math.max(index, 0), Math.max(0, n - 1));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Autoplay — advances on an interval, paused while hovered/focused.
|
|
59
|
+
$effect(() => {
|
|
60
|
+
if (!autoplay || paused || n <= 1) return;
|
|
61
|
+
const t = setInterval(() => go(index + 1), autoplay);
|
|
62
|
+
return () => clearInterval(t);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function onKeydown(e: KeyboardEvent) {
|
|
66
|
+
if (e.key === "ArrowLeft") {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
prev();
|
|
69
|
+
} else if (e.key === "ArrowRight") {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
next();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_no_noninteractive_tabindex (focusable carousel region for keyboard nav) -->
|
|
77
|
+
<div
|
|
78
|
+
role="region"
|
|
79
|
+
aria-roledescription="carousel"
|
|
80
|
+
aria-label={ariaLabel}
|
|
81
|
+
tabindex="0"
|
|
82
|
+
class="relative overflow-hidden rounded-[var(--ui-border-radius)] focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] {cls}"
|
|
83
|
+
onkeydown={onKeydown}
|
|
84
|
+
onmouseenter={() => (paused = true)}
|
|
85
|
+
onmouseleave={() => (paused = false)}
|
|
86
|
+
onfocusin={() => (paused = true)}
|
|
87
|
+
onfocusout={() => (paused = false)}
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
class="flex transition-transform duration-300 ease-out"
|
|
91
|
+
style="transform: translateX(-{index * 100}%);"
|
|
92
|
+
>
|
|
93
|
+
{#each items as item, i (i)}
|
|
94
|
+
<div
|
|
95
|
+
class="w-full shrink-0"
|
|
96
|
+
role="group"
|
|
97
|
+
aria-roledescription="slide"
|
|
98
|
+
aria-label={`${i + 1} of ${n}`}
|
|
99
|
+
aria-hidden={i !== index}
|
|
100
|
+
>
|
|
101
|
+
{@render slide(item, i)}
|
|
102
|
+
</div>
|
|
103
|
+
{/each}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{#if controls && n > 1}
|
|
107
|
+
<div class="absolute inset-y-0 left-2 flex items-center">
|
|
108
|
+
<Button
|
|
109
|
+
title="Previous"
|
|
110
|
+
variant="secondary"
|
|
111
|
+
onclick={prev}
|
|
112
|
+
disabled={!loop && index === 0}
|
|
113
|
+
class="!rounded-full shadow"
|
|
114
|
+
>
|
|
115
|
+
{#snippet icon()}<IconPrev />{/snippet}
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="absolute inset-y-0 right-2 flex items-center">
|
|
119
|
+
<Button
|
|
120
|
+
title="Next"
|
|
121
|
+
variant="secondary"
|
|
122
|
+
onclick={next}
|
|
123
|
+
disabled={!loop && index === n - 1}
|
|
124
|
+
class="!rounded-full shadow"
|
|
125
|
+
>
|
|
126
|
+
{#snippet icon()}<IconNext />{/snippet}
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
|
|
131
|
+
{#if indicators && n > 1}
|
|
132
|
+
<div class="absolute inset-x-0 bottom-2 flex items-center justify-center gap-1.5">
|
|
133
|
+
{#each items as _, i (i)}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
137
|
+
aria-current={i === index}
|
|
138
|
+
onclick={() => go(i)}
|
|
139
|
+
class="h-2 rounded-full transition-all {i === index ? 'w-5' : 'w-2'}"
|
|
140
|
+
style="background-color: {i === index
|
|
141
|
+
? `var(${token})`
|
|
142
|
+
: 'color-mix(in srgb, var(--ui-color-background) 70%, var(--ui-color-text))'};"
|
|
143
|
+
></button>
|
|
144
|
+
{/each}
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { Variant } from "../../types/variants.js";
|
|
3
|
+
declare function $$render<T>(): {
|
|
4
|
+
props: {
|
|
5
|
+
items: T[];
|
|
6
|
+
slide: Snippet<[T, number]>;
|
|
7
|
+
index?: number;
|
|
8
|
+
loop?: boolean;
|
|
9
|
+
/** Auto-advance interval in ms; pauses on hover/focus. Omit to disable. */
|
|
10
|
+
autoplay?: number;
|
|
11
|
+
controls?: boolean;
|
|
12
|
+
indicators?: boolean;
|
|
13
|
+
variant?: Variant;
|
|
14
|
+
ariaLabel?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
};
|
|
17
|
+
exports: {};
|
|
18
|
+
bindings: "index";
|
|
19
|
+
slots: {};
|
|
20
|
+
events: {};
|
|
21
|
+
};
|
|
22
|
+
declare class __sveltets_Render<T> {
|
|
23
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
24
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
25
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
26
|
+
bindings(): "index";
|
|
27
|
+
exports(): {};
|
|
28
|
+
}
|
|
29
|
+
interface $$IsomorphicComponent {
|
|
30
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
31
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
32
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
33
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
34
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
35
|
+
}
|
|
36
|
+
declare const Carousel: $$IsomorphicComponent;
|
|
37
|
+
type Carousel<T> = InstanceType<typeof Carousel<T>>;
|
|
38
|
+
export default Carousel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Carousel } from "./Carousel.svelte";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Carousel } from "./Carousel.svelte";
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import Input from "../Input.svelte";
|
|
11
11
|
import Popup from "../Popup.svelte";
|
|
12
12
|
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
13
|
+
import Spinner from "../Spinner/Spinner.svelte";
|
|
13
14
|
import type { Variant } from "../../types/variants.js";
|
|
14
15
|
import type { Size } from "../../types/sizes.js";
|
|
15
|
-
import type
|
|
16
|
+
import { responsiveClasses, type Responsive } from "../../types/responsive.js";
|
|
16
17
|
|
|
17
18
|
let {
|
|
18
19
|
value = $bindable(""),
|
|
@@ -65,6 +66,10 @@
|
|
|
65
66
|
let fieldEl = $state<HTMLDivElement>();
|
|
66
67
|
let inputEl = $state<HTMLInputElement>();
|
|
67
68
|
let asyncResults = $state<string[]>([]);
|
|
69
|
+
let loading = $state(false);
|
|
70
|
+
// Monotonic request id — a later query invalidates the results of any earlier in-flight one, so a
|
|
71
|
+
// slow response can't clobber a newer, faster one (out-of-order race).
|
|
72
|
+
let reqSeq = 0;
|
|
68
73
|
|
|
69
74
|
// Suggestions: async `suggest` (debounced) or client-side substring filter of `options`.
|
|
70
75
|
const filtered = $derived.by(() => {
|
|
@@ -78,23 +83,37 @@
|
|
|
78
83
|
return maxResults != null ? out.slice(0, maxResults) : out;
|
|
79
84
|
});
|
|
80
85
|
|
|
81
|
-
// Debounced async fetch — re-runs as `value` changes; gated by `minChars`.
|
|
86
|
+
// Debounced async fetch — re-runs as `value` changes; gated by `minChars`. Sets `loading` for the
|
|
87
|
+
// whole debounce→resolve window so the field shows feedback (otherwise the delay reads as "broken").
|
|
82
88
|
$effect(() => {
|
|
83
89
|
if (!suggest) return;
|
|
84
90
|
const q = value;
|
|
85
91
|
if (q.trim().length < minChars) {
|
|
86
92
|
asyncResults = [];
|
|
93
|
+
loading = false;
|
|
87
94
|
return;
|
|
88
95
|
}
|
|
96
|
+
const seq = ++reqSeq;
|
|
97
|
+
loading = true;
|
|
89
98
|
const t = setTimeout(() => {
|
|
90
99
|
suggest(q)
|
|
91
|
-
.then((r) =>
|
|
92
|
-
|
|
100
|
+
.then((r) => {
|
|
101
|
+
if (seq !== reqSeq) return; // a newer query superseded this one
|
|
102
|
+
asyncResults = r;
|
|
103
|
+
loading = false;
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {
|
|
106
|
+
if (seq !== reqSeq) return;
|
|
107
|
+
asyncResults = [];
|
|
108
|
+
loading = false;
|
|
109
|
+
});
|
|
93
110
|
}, debounceMs);
|
|
94
111
|
return () => clearTimeout(t);
|
|
95
112
|
});
|
|
96
113
|
|
|
97
|
-
|
|
114
|
+
// Open the popup while loading too, so the "Loading…" row can show during the async fetch.
|
|
115
|
+
const canOpen = $derived(open && !disabled && (filtered.length > 0 || loading));
|
|
116
|
+
const optionText = $derived(responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" }));
|
|
98
117
|
|
|
99
118
|
function choose(v: string) {
|
|
100
119
|
value = v;
|
|
@@ -166,14 +185,20 @@
|
|
|
166
185
|
}}
|
|
167
186
|
>
|
|
168
187
|
{#snippet actions()}
|
|
169
|
-
|
|
170
|
-
class="inline-flex items-center justify-center opacity-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
{#if loading}
|
|
189
|
+
<span class="inline-flex items-center justify-center opacity-70" aria-hidden="true">
|
|
190
|
+
<Spinner size="sm" />
|
|
191
|
+
</span>
|
|
192
|
+
{:else}
|
|
193
|
+
<span
|
|
194
|
+
class="inline-flex items-center justify-center opacity-60 transition-transform duration-150 {canOpen
|
|
195
|
+
? 'rotate-180'
|
|
196
|
+
: ''} [&_svg]:w-4 [&_svg]:h-4"
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
>
|
|
199
|
+
<IconChevronDown />
|
|
200
|
+
</span>
|
|
201
|
+
{/if}
|
|
177
202
|
{/snippet}
|
|
178
203
|
</Input>
|
|
179
204
|
|
|
@@ -181,25 +206,31 @@
|
|
|
181
206
|
<ul
|
|
182
207
|
id={listId}
|
|
183
208
|
role="listbox"
|
|
184
|
-
class="max-h-64 overflow-y-auto rounded-md border shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:var(--ui-border-color)] py-1"
|
|
209
|
+
class="max-h-64 overflow-y-auto rounded-md border shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:var(--ui-border-color)] py-1 {optionText}"
|
|
185
210
|
>
|
|
186
|
-
{#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
id={optId(i)}
|
|
190
|
-
role="option"
|
|
191
|
-
aria-selected={i === highlight}
|
|
192
|
-
class="cursor-pointer px-3 py-1.5 text-sm {i === highlight
|
|
193
|
-
? '[background-color:var(--ui-color-hover)]'
|
|
194
|
-
: ''} hover:[background-color:var(--ui-color-hover)]"
|
|
195
|
-
onpointerdown={(e) => {
|
|
196
|
-
e.preventDefault(); // keep input focused so the click lands before blur closes the list
|
|
197
|
-
choose(opt);
|
|
198
|
-
}}
|
|
199
|
-
onpointerenter={() => (highlight = i)}
|
|
200
|
-
>
|
|
201
|
-
{opt}
|
|
211
|
+
{#if filtered.length === 0 && loading}
|
|
212
|
+
<li class="flex items-center gap-2 px-3 py-1.5 [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]" aria-hidden="true">
|
|
213
|
+
<Spinner size="sm" /> Loading…
|
|
202
214
|
</li>
|
|
203
|
-
{
|
|
215
|
+
{:else}
|
|
216
|
+
{#each filtered as opt, i (opt + i)}
|
|
217
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
218
|
+
<li
|
|
219
|
+
id={optId(i)}
|
|
220
|
+
role="option"
|
|
221
|
+
aria-selected={i === highlight}
|
|
222
|
+
class="cursor-pointer px-3 py-1.5 {i === highlight
|
|
223
|
+
? '[background-color:var(--ui-color-hover)]'
|
|
224
|
+
: ''} hover:[background-color:var(--ui-color-hover)]"
|
|
225
|
+
onpointerdown={(e) => {
|
|
226
|
+
e.preventDefault(); // keep input focused so the click lands before blur closes the list
|
|
227
|
+
choose(opt);
|
|
228
|
+
}}
|
|
229
|
+
onpointerenter={() => (highlight = i)}
|
|
230
|
+
>
|
|
231
|
+
{opt}
|
|
232
|
+
</li>
|
|
233
|
+
{/each}
|
|
234
|
+
{/if}
|
|
204
235
|
</ul>
|
|
205
236
|
</Popup>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
2
|
import type { Variant } from "../../types/variants.js";
|
|
3
3
|
import type { Size } from "../../types/sizes.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type Responsive } from "../../types/responsive.js";
|
|
5
5
|
type $$ComponentProps = {
|
|
6
6
|
value?: string;
|
|
7
7
|
options?: string[];
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* CommandPalette — a ⌘K/Ctrl+K fuzzy command launcher: a top-centered overlay with a search `Input`
|
|
4
|
+
* and a filtered, grouped, keyboard-navigable command list. `bind:open`, a global `hotkey`, per-command
|
|
5
|
+
* `shortcut` (shown as `Kbd`). Portals to <body> (scroll-lock + backdrop + focus). See spec.
|
|
6
|
+
*/
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts" module>
|
|
9
|
+
import type { Snippet } from "svelte";
|
|
10
|
+
export type Command = {
|
|
11
|
+
id?: string;
|
|
12
|
+
label: string;
|
|
13
|
+
keywords?: string[];
|
|
14
|
+
group?: string;
|
|
15
|
+
icon?: Snippet;
|
|
16
|
+
shortcut?: string[];
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
onrun: () => void;
|
|
19
|
+
};
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import Input from "../Input.svelte";
|
|
24
|
+
import Kbd from "../Kbd/Kbd.svelte";
|
|
25
|
+
import IconSearch from "~icons/mdi/magnify";
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
open = $bindable(false),
|
|
29
|
+
commands,
|
|
30
|
+
hotkey = "mod+k",
|
|
31
|
+
placeholder = "Type a command or search…",
|
|
32
|
+
empty = "No matching commands",
|
|
33
|
+
onopen,
|
|
34
|
+
}: {
|
|
35
|
+
open?: boolean;
|
|
36
|
+
commands: Command[];
|
|
37
|
+
/** Global open hotkey — `mod` = ⌘ on macOS, Ctrl elsewhere. Set `""` to disable. */
|
|
38
|
+
hotkey?: string;
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
empty?: string;
|
|
41
|
+
onopen?: () => void;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
let query = $state("");
|
|
45
|
+
let highlight = $state(0);
|
|
46
|
+
let inputEl = $state<HTMLInputElement>();
|
|
47
|
+
let previouslyFocused: HTMLElement | null = null;
|
|
48
|
+
|
|
49
|
+
const isMac =
|
|
50
|
+
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent);
|
|
51
|
+
|
|
52
|
+
const filtered = $derived.by(() => {
|
|
53
|
+
const q = query.trim().toLowerCase();
|
|
54
|
+
if (!q) return commands;
|
|
55
|
+
return commands.filter(
|
|
56
|
+
(c) =>
|
|
57
|
+
c.label.toLowerCase().includes(q) ||
|
|
58
|
+
(c.keywords ?? []).some((k) => k.toLowerCase().includes(q)),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
// Group in first-seen order for rendering; `filtered` is the flat list the highlight indexes into.
|
|
62
|
+
const groups = $derived.by(() => {
|
|
63
|
+
const map = new Map<string, Command[]>();
|
|
64
|
+
for (const c of filtered) {
|
|
65
|
+
const g = c.group ?? "";
|
|
66
|
+
(map.get(g) ?? map.set(g, []).get(g)!).push(c);
|
|
67
|
+
}
|
|
68
|
+
return [...map.entries()].map(([name, items]) => ({ name, items }));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function portal(node: HTMLElement) {
|
|
72
|
+
document.body.appendChild(node);
|
|
73
|
+
return { destroy: () => node.remove() };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Global hotkey — toggles open. `mod+k` → ⌘K (mac) / Ctrl+K.
|
|
77
|
+
function onGlobalKey(e: KeyboardEvent) {
|
|
78
|
+
if (!hotkey) return;
|
|
79
|
+
const parts = hotkey.toLowerCase().split("+");
|
|
80
|
+
const key = parts[parts.length - 1];
|
|
81
|
+
const needMod = parts.includes("mod");
|
|
82
|
+
const modOk = needMod ? (isMac ? e.metaKey : e.ctrlKey) : true;
|
|
83
|
+
if (modOk && e.key.toLowerCase() === key) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
open = !open;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// On open: remember focus, reset query/highlight, focus the input. On close: restore focus.
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
93
|
+
query = "";
|
|
94
|
+
highlight = 0;
|
|
95
|
+
onopen?.();
|
|
96
|
+
const sbw = window.innerWidth - document.documentElement.clientWidth;
|
|
97
|
+
const prevOverflow = document.body.style.overflow;
|
|
98
|
+
const prevPad = document.body.style.paddingRight;
|
|
99
|
+
document.body.style.overflow = "hidden";
|
|
100
|
+
if (sbw > 0) document.body.style.paddingRight = `${sbw}px`;
|
|
101
|
+
queueMicrotask(() => inputEl?.focus());
|
|
102
|
+
return () => {
|
|
103
|
+
document.body.style.overflow = prevOverflow;
|
|
104
|
+
document.body.style.paddingRight = prevPad;
|
|
105
|
+
previouslyFocused?.focus?.();
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
// Keep the highlight in range as the filter changes.
|
|
109
|
+
$effect(() => {
|
|
110
|
+
if (highlight > filtered.length - 1) highlight = Math.max(0, filtered.length - 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function run(c: Command | undefined) {
|
|
114
|
+
if (!c || c.disabled) return;
|
|
115
|
+
open = false;
|
|
116
|
+
c.onrun();
|
|
117
|
+
}
|
|
118
|
+
function onKeydown(e: KeyboardEvent) {
|
|
119
|
+
const len = filtered.length;
|
|
120
|
+
if (e.key === "ArrowDown") {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
if (len) highlight = (highlight + 1) % len;
|
|
123
|
+
} else if (e.key === "ArrowUp") {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (len) highlight = (highlight - 1 + len) % len;
|
|
126
|
+
} else if (e.key === "Enter") {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
run(filtered[highlight]);
|
|
129
|
+
} else if (e.key === "Escape") {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
open = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<svelte:window onkeydown={onGlobalKey} />
|
|
137
|
+
|
|
138
|
+
{#if open}
|
|
139
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
140
|
+
<div use:portal class="fixed inset-0 z-[9999] flex justify-center bg-black/40 p-4 pt-[12vh]" role="presentation"
|
|
141
|
+
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}>
|
|
142
|
+
<div
|
|
143
|
+
class="h-max w-full max-w-xl overflow-hidden rounded-[var(--ui-border-radius)] border shadow-2xl [background-color:var(--ui-color-background)] [border-color:var(--ui-border-color)]"
|
|
144
|
+
role="dialog"
|
|
145
|
+
aria-modal="true"
|
|
146
|
+
aria-label="Command palette"
|
|
147
|
+
>
|
|
148
|
+
<Input
|
|
149
|
+
bind:value={query}
|
|
150
|
+
bind:element={inputEl}
|
|
151
|
+
{placeholder}
|
|
152
|
+
borderless
|
|
153
|
+
icon={searchIcon}
|
|
154
|
+
iconPosition="left"
|
|
155
|
+
role="combobox"
|
|
156
|
+
aria-expanded="true"
|
|
157
|
+
aria-controls="cmdp-list"
|
|
158
|
+
aria-activedescendant={filtered.length ? `cmdp-opt-${highlight}` : undefined}
|
|
159
|
+
onkeydown={onKeydown}
|
|
160
|
+
class="[&_input]:h-12 border-b [border-color:var(--ui-border-color)] rounded-none"
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<ul id="cmdp-list" role="listbox" class="max-h-80 overflow-y-auto p-1">
|
|
164
|
+
{#if filtered.length === 0}
|
|
165
|
+
<li class="px-3 py-8 text-center text-sm [color:color-mix(in_srgb,var(--ui-color-text)_55%,transparent)]">
|
|
166
|
+
{empty}
|
|
167
|
+
</li>
|
|
168
|
+
{:else}
|
|
169
|
+
{#each groups as g (g.name)}
|
|
170
|
+
{#if g.name}
|
|
171
|
+
<li class="px-3 pb-1 pt-3 text-xs font-medium uppercase tracking-wide [color:color-mix(in_srgb,var(--ui-color-text)_45%,transparent)]">
|
|
172
|
+
{g.name}
|
|
173
|
+
</li>
|
|
174
|
+
{/if}
|
|
175
|
+
{#each g.items as c (c.id ?? c.label)}
|
|
176
|
+
{@const idx = filtered.indexOf(c)}
|
|
177
|
+
<li>
|
|
178
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
179
|
+
<div
|
|
180
|
+
id={`cmdp-opt-${idx}`}
|
|
181
|
+
role="option"
|
|
182
|
+
aria-selected={idx === highlight}
|
|
183
|
+
aria-disabled={c.disabled || undefined}
|
|
184
|
+
onclick={() => run(c)}
|
|
185
|
+
onpointermove={() => (highlight = idx)}
|
|
186
|
+
class="flex cursor-pointer items-center gap-3 rounded-[var(--ui-border-radius)] px-3 py-2 text-sm {c.disabled
|
|
187
|
+
? 'opacity-40'
|
|
188
|
+
: ''} {idx === highlight ? '[background-color:var(--ui-color-hover)]' : ''}"
|
|
189
|
+
>
|
|
190
|
+
{#if c.icon}<span class="inline-flex shrink-0 [&_svg]:w-4 [&_svg]:h-4 [color:var(--ui-color-secondary)]">{@render c.icon()}</span>{/if}
|
|
191
|
+
<span class="min-w-0 flex-1 truncate [color:var(--ui-color-text)]">{c.label}</span>
|
|
192
|
+
{#if c.shortcut}<Kbd keys={c.shortcut} size="sm" />{/if}
|
|
193
|
+
</div>
|
|
194
|
+
</li>
|
|
195
|
+
{/each}
|
|
196
|
+
{/each}
|
|
197
|
+
{/if}
|
|
198
|
+
</ul>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{/if}
|
|
202
|
+
|
|
203
|
+
{#snippet searchIcon()}<IconSearch />{/snippet}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type Command = {
|
|
3
|
+
id?: string;
|
|
4
|
+
label: string;
|
|
5
|
+
keywords?: string[];
|
|
6
|
+
group?: string;
|
|
7
|
+
icon?: Snippet;
|
|
8
|
+
shortcut?: string[];
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onrun: () => void;
|
|
11
|
+
};
|
|
12
|
+
type $$ComponentProps = {
|
|
13
|
+
open?: boolean;
|
|
14
|
+
commands: Command[];
|
|
15
|
+
/** Global open hotkey — `mod` = ⌘ on macOS, Ctrl elsewhere. Set `""` to disable. */
|
|
16
|
+
hotkey?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
empty?: string;
|
|
19
|
+
onopen?: () => void;
|
|
20
|
+
};
|
|
21
|
+
declare const CommandPalette: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
22
|
+
type CommandPalette = ReturnType<typeof CommandPalette>;
|
|
23
|
+
export default CommandPalette;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as CommandPalette } from "./CommandPalette.svelte";
|