@nqmcreative/ui 0.1.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 +507 -0
- package/cli/index.mjs +410 -0
- package/dist/actions/anchor.d.ts +29 -0
- package/dist/actions/anchor.js +122 -0
- package/dist/actions/dismissable.d.ts +44 -0
- package/dist/actions/dismissable.js +147 -0
- package/dist/components/Accordion.svelte +14 -0
- package/dist/components/Accordion.svelte.d.ts +8 -0
- package/dist/components/AccordionItem.svelte +46 -0
- package/dist/components/AccordionItem.svelte.d.ts +14 -0
- package/dist/components/Alert.svelte +88 -0
- package/dist/components/Alert.svelte.d.ts +18 -0
- package/dist/components/Avatar.svelte +58 -0
- package/dist/components/Avatar.svelte.d.ts +16 -0
- package/dist/components/AvatarGroup.svelte +17 -0
- package/dist/components/AvatarGroup.svelte.d.ts +8 -0
- package/dist/components/Badge.svelte +53 -0
- package/dist/components/Badge.svelte.d.ts +18 -0
- package/dist/components/Breadcrumb.svelte +46 -0
- package/dist/components/Breadcrumb.svelte.d.ts +14 -0
- package/dist/components/Button.svelte +94 -0
- package/dist/components/Button.svelte.d.ts +21 -0
- package/dist/components/Calendar.svelte +206 -0
- package/dist/components/Calendar.svelte.d.ts +22 -0
- package/dist/components/Card.svelte +76 -0
- package/dist/components/Card.svelte.d.ts +21 -0
- package/dist/components/Checkbox.svelte +69 -0
- package/dist/components/Checkbox.svelte.d.ts +15 -0
- package/dist/components/CheckboxGroup.svelte +109 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +30 -0
- package/dist/components/Combobox.svelte +268 -0
- package/dist/components/Combobox.svelte.d.ts +29 -0
- package/dist/components/CommandPalette.svelte +226 -0
- package/dist/components/CommandPalette.svelte.d.ts +32 -0
- package/dist/components/ConfirmDialog.svelte +97 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +22 -0
- package/dist/components/ContextMenu.svelte +90 -0
- package/dist/components/ContextMenu.svelte.d.ts +15 -0
- package/dist/components/DatePicker.svelte +186 -0
- package/dist/components/DatePicker.svelte.d.ts +23 -0
- package/dist/components/Divider.svelte +30 -0
- package/dist/components/Divider.svelte.d.ts +10 -0
- package/dist/components/Drawer.svelte +161 -0
- package/dist/components/Drawer.svelte.d.ts +20 -0
- package/dist/components/Dropdown.svelte +112 -0
- package/dist/components/Dropdown.svelte.d.ts +23 -0
- package/dist/components/Dropzone.svelte +226 -0
- package/dist/components/Dropzone.svelte.d.ts +32 -0
- package/dist/components/EmptyState.svelte +57 -0
- package/dist/components/EmptyState.svelte.d.ts +18 -0
- package/dist/components/Field.svelte +42 -0
- package/dist/components/Field.svelte.d.ts +16 -0
- package/dist/components/Footer.svelte +93 -0
- package/dist/components/Footer.svelte.d.ts +25 -0
- package/dist/components/Input.svelte +60 -0
- package/dist/components/Input.svelte.d.ts +18 -0
- package/dist/components/InputGroup.svelte +19 -0
- package/dist/components/InputGroup.svelte.d.ts +10 -0
- package/dist/components/Kbd.svelte +17 -0
- package/dist/components/Kbd.svelte.d.ts +8 -0
- package/dist/components/Label.svelte +16 -0
- package/dist/components/Label.svelte.d.ts +9 -0
- package/dist/components/Link.svelte +44 -0
- package/dist/components/Link.svelte.d.ts +16 -0
- package/dist/components/LocaleProvider.svelte +24 -0
- package/dist/components/LocaleProvider.svelte.d.ts +10 -0
- package/dist/components/MenuItem.svelte +58 -0
- package/dist/components/MenuItem.svelte.d.ts +18 -0
- package/dist/components/MenuSeparator.svelte +22 -0
- package/dist/components/MenuSeparator.svelte.d.ts +8 -0
- package/dist/components/Modal.svelte +114 -0
- package/dist/components/Modal.svelte.d.ts +21 -0
- package/dist/components/MultiSelect.svelte +292 -0
- package/dist/components/MultiSelect.svelte.d.ts +31 -0
- package/dist/components/Navbar.svelte +190 -0
- package/dist/components/Navbar.svelte.d.ts +33 -0
- package/dist/components/NumberInput.svelte +100 -0
- package/dist/components/NumberInput.svelte.d.ts +15 -0
- package/dist/components/Pagination.svelte +90 -0
- package/dist/components/Pagination.svelte.d.ts +15 -0
- package/dist/components/PasswordInput.svelte +104 -0
- package/dist/components/PasswordInput.svelte.d.ts +12 -0
- package/dist/components/Popover.svelte +114 -0
- package/dist/components/Popover.svelte.d.ts +19 -0
- package/dist/components/Progress.svelte +56 -0
- package/dist/components/Progress.svelte.d.ts +17 -0
- package/dist/components/Radio.svelte +55 -0
- package/dist/components/Radio.svelte.d.ts +15 -0
- package/dist/components/RadioGroup.svelte +93 -0
- package/dist/components/RadioGroup.svelte.d.ts +27 -0
- package/dist/components/SegmentedControl.svelte +88 -0
- package/dist/components/SegmentedControl.svelte.d.ts +25 -0
- package/dist/components/Select.svelte +61 -0
- package/dist/components/Select.svelte.d.ts +20 -0
- package/dist/components/Sidebar.svelte +206 -0
- package/dist/components/Sidebar.svelte.d.ts +36 -0
- package/dist/components/Skeleton.svelte +46 -0
- package/dist/components/Skeleton.svelte.d.ts +13 -0
- package/dist/components/Slider.svelte +79 -0
- package/dist/components/Slider.svelte.d.ts +19 -0
- package/dist/components/Spinner.svelte +34 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +49 -0
- package/dist/components/Stat.svelte.d.ts +16 -0
- package/dist/components/Steps.svelte +136 -0
- package/dist/components/Steps.svelte.d.ts +24 -0
- package/dist/components/Switch.svelte +64 -0
- package/dist/components/Switch.svelte.d.ts +14 -0
- package/dist/components/Table.svelte +236 -0
- package/dist/components/Table.svelte.d.ts +70 -0
- package/dist/components/Tabs.svelte +91 -0
- package/dist/components/Tabs.svelte.d.ts +23 -0
- package/dist/components/Textarea.svelte +54 -0
- package/dist/components/Textarea.svelte.d.ts +12 -0
- package/dist/components/ThemeToggle.svelte +133 -0
- package/dist/components/ThemeToggle.svelte.d.ts +20 -0
- package/dist/components/Toaster.svelte +110 -0
- package/dist/components/Toaster.svelte.d.ts +8 -0
- package/dist/components/Tooltip.svelte +33 -0
- package/dist/components/Tooltip.svelte.d.ts +12 -0
- package/dist/date.d.ts +35 -0
- package/dist/date.js +108 -0
- package/dist/fonts.css +193 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +77 -0
- package/dist/locale.svelte.d.ts +93 -0
- package/dist/locale.svelte.js +153 -0
- package/dist/theme.css +210 -0
- package/dist/toast.svelte.d.ts +47 -0
- package/dist/toast.svelte.js +60 -0
- package/dist/tones.d.ts +48 -0
- package/dist/tones.js +209 -0
- package/package.json +349 -0
- package/registry.json +669 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { anchored } from '../actions/anchor.js';
|
|
3
|
+
import { clickOutside, portal } from '../actions/dismissable.js';
|
|
4
|
+
import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../tones.js';
|
|
5
|
+
import { useLocale } from '../locale.svelte.js';
|
|
6
|
+
|
|
7
|
+
export interface ComboboxOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Second line under the label. */
|
|
11
|
+
description?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Options sharing a group are rendered under one heading. */
|
|
14
|
+
group?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Bindable — the selected option's `value`, or `''`. */
|
|
19
|
+
value?: string;
|
|
20
|
+
options: ComboboxOption[];
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
invalid?: boolean;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
/** Shows a clear button once something is selected. */
|
|
26
|
+
clearable?: boolean;
|
|
27
|
+
emptyText?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
onchange?: (value: string) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
value = $bindable(''),
|
|
36
|
+
options,
|
|
37
|
+
placeholder,
|
|
38
|
+
tone = 'brand',
|
|
39
|
+
invalid = false,
|
|
40
|
+
disabled = false,
|
|
41
|
+
clearable = true,
|
|
42
|
+
emptyText,
|
|
43
|
+
id,
|
|
44
|
+
name,
|
|
45
|
+
onchange,
|
|
46
|
+
class: className = ''
|
|
47
|
+
}: Props = $props();
|
|
48
|
+
|
|
49
|
+
const t = useLocale();
|
|
50
|
+
|
|
51
|
+
let open = $state(false);
|
|
52
|
+
let query = $state('');
|
|
53
|
+
let active = $state(0);
|
|
54
|
+
let wrapper: HTMLElement | null = $state(null);
|
|
55
|
+
let inputEl: HTMLInputElement | null = $state(null);
|
|
56
|
+
let listEl: HTMLElement | null = $state(null);
|
|
57
|
+
|
|
58
|
+
const selected = $derived(options.find((option) => option.value === value));
|
|
59
|
+
|
|
60
|
+
const matches = $derived.by(() => {
|
|
61
|
+
const needle = query.trim().toLowerCase();
|
|
62
|
+
if (!needle) return options;
|
|
63
|
+
return options.filter(
|
|
64
|
+
(option) =>
|
|
65
|
+
option.label.toLowerCase().includes(needle) ||
|
|
66
|
+
option.description?.toLowerCase().includes(needle)
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Options in render order, grouped when any option declares a `group`. */
|
|
71
|
+
const groups = $derived.by(() => {
|
|
72
|
+
const out: [string, ComboboxOption[]][] = [];
|
|
73
|
+
for (const option of matches) {
|
|
74
|
+
const key = option.group ?? '';
|
|
75
|
+
const bucket = out.find(([name]) => name === key);
|
|
76
|
+
if (bucket) bucket[1].push(option);
|
|
77
|
+
else out.push([key, [option]]);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const flat = $derived(groups.flatMap(([, items]) => items));
|
|
83
|
+
|
|
84
|
+
function openList() {
|
|
85
|
+
if (disabled || open) return;
|
|
86
|
+
open = true;
|
|
87
|
+
query = '';
|
|
88
|
+
active = Math.max(
|
|
89
|
+
0,
|
|
90
|
+
flat.findIndex((option) => option.value === value)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function choose(option: ComboboxOption) {
|
|
95
|
+
if (option.disabled) return;
|
|
96
|
+
value = option.value;
|
|
97
|
+
onchange?.(option.value);
|
|
98
|
+
open = false;
|
|
99
|
+
query = '';
|
|
100
|
+
inputEl?.focus();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clear(event: MouseEvent) {
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
value = '';
|
|
106
|
+
query = '';
|
|
107
|
+
onchange?.('');
|
|
108
|
+
inputEl?.focus();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function move(step: number) {
|
|
112
|
+
if (flat.length === 0) return;
|
|
113
|
+
let next = active;
|
|
114
|
+
for (let i = 0; i < flat.length; i++) {
|
|
115
|
+
next = (next + step + flat.length) % flat.length;
|
|
116
|
+
if (!flat[next].disabled) break;
|
|
117
|
+
}
|
|
118
|
+
active = next;
|
|
119
|
+
listEl?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onKeydown(event: KeyboardEvent) {
|
|
123
|
+
switch (event.key) {
|
|
124
|
+
case 'ArrowDown':
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
if (!open) openList();
|
|
127
|
+
else move(1);
|
|
128
|
+
break;
|
|
129
|
+
case 'ArrowUp':
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
if (open) move(-1);
|
|
132
|
+
break;
|
|
133
|
+
case 'Enter':
|
|
134
|
+
if (open && flat[active]) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
choose(flat[active]);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
case 'Escape':
|
|
140
|
+
if (open) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
event.stopPropagation();
|
|
143
|
+
open = false;
|
|
144
|
+
query = '';
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'Tab':
|
|
148
|
+
open = false;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<div
|
|
155
|
+
bind:this={wrapper}
|
|
156
|
+
class="relative inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-4 transition-colors duration-150 ease-brand-out
|
|
157
|
+
{invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
|
|
158
|
+
{disabled ? 'pointer-events-none opacity-50' : ''} {className}"
|
|
159
|
+
>
|
|
160
|
+
<input
|
|
161
|
+
bind:this={inputEl}
|
|
162
|
+
{id}
|
|
163
|
+
{disabled}
|
|
164
|
+
type="text"
|
|
165
|
+
role="combobox"
|
|
166
|
+
autocomplete="off"
|
|
167
|
+
aria-expanded={open}
|
|
168
|
+
aria-controls="{id ?? 'combobox'}-listbox"
|
|
169
|
+
aria-activedescendant={open && flat[active] ? `${id ?? 'combobox'}-opt-${active}` : undefined}
|
|
170
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
171
|
+
placeholder={selected ? selected.label : (placeholder ?? t.current.comboboxPlaceholder)}
|
|
172
|
+
value={open ? query : (selected?.label ?? '')}
|
|
173
|
+
oninput={(event) => {
|
|
174
|
+
query = event.currentTarget.value;
|
|
175
|
+
open = true;
|
|
176
|
+
active = 0;
|
|
177
|
+
}}
|
|
178
|
+
onfocus={openList}
|
|
179
|
+
onkeydown={onKeydown}
|
|
180
|
+
class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
{#if name}<input type="hidden" {name} {value} />{/if}
|
|
184
|
+
|
|
185
|
+
{#if clearable && value && !disabled}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onclick={clear}
|
|
189
|
+
aria-label={t.current.clear}
|
|
190
|
+
class="shrink-0 p-1.5 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
191
|
+
>
|
|
192
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
193
|
+
<path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
|
|
194
|
+
</svg>
|
|
195
|
+
</button>
|
|
196
|
+
{/if}
|
|
197
|
+
|
|
198
|
+
<svg
|
|
199
|
+
class="pointer-events-none mr-2 size-4 shrink-0 text-text-muted transition-transform duration-150 {open
|
|
200
|
+
? 'rotate-180'
|
|
201
|
+
: ''}"
|
|
202
|
+
viewBox="0 0 16 16"
|
|
203
|
+
fill="none"
|
|
204
|
+
aria-hidden="true"
|
|
205
|
+
>
|
|
206
|
+
<path d="m4 6 4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" />
|
|
207
|
+
</svg>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{#if open}
|
|
211
|
+
<div
|
|
212
|
+
bind:this={listEl}
|
|
213
|
+
use:portal
|
|
214
|
+
use:anchored={{ anchor: wrapper, placement: 'bottom-start', offset: 4, matchWidth: true }}
|
|
215
|
+
use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
|
|
216
|
+
id="{id ?? 'combobox'}-listbox"
|
|
217
|
+
role="listbox"
|
|
218
|
+
class="z-50 max-h-64 overflow-y-auto border border-hairline bg-bg py-1 font-sans"
|
|
219
|
+
>
|
|
220
|
+
{#if flat.length === 0}
|
|
221
|
+
<p class="px-3 py-6 text-center text-sm text-text-muted">
|
|
222
|
+
{emptyText ?? t.current.noMatches}
|
|
223
|
+
</p>
|
|
224
|
+
{:else}
|
|
225
|
+
{#each groups as [group, items] (group)}
|
|
226
|
+
{#if group}
|
|
227
|
+
<p class="px-3 pt-2 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase">
|
|
228
|
+
{group}
|
|
229
|
+
</p>
|
|
230
|
+
{/if}
|
|
231
|
+
{#each items as option (option.value)}
|
|
232
|
+
{@const index = flat.indexOf(option)}
|
|
233
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
234
|
+
<div
|
|
235
|
+
id="{id ?? 'combobox'}-opt-{index}"
|
|
236
|
+
data-index={index}
|
|
237
|
+
role="option"
|
|
238
|
+
tabindex="-1"
|
|
239
|
+
aria-selected={option.value === value}
|
|
240
|
+
aria-disabled={option.disabled}
|
|
241
|
+
onclick={() => choose(option)}
|
|
242
|
+
onpointermove={() => (active = index)}
|
|
243
|
+
class="flex cursor-pointer flex-col gap-0.5 px-3 py-2 text-[14px] transition-colors duration-100
|
|
244
|
+
{option.disabled ? 'pointer-events-none opacity-40' : ''}
|
|
245
|
+
{index === active ? toneSoft[tone] : 'text-text-secondary'}"
|
|
246
|
+
>
|
|
247
|
+
<span class="flex items-center gap-2">
|
|
248
|
+
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
|
249
|
+
{#if option.value === value}
|
|
250
|
+
<svg
|
|
251
|
+
class="size-3.5 shrink-0 {toneText[tone]}"
|
|
252
|
+
viewBox="0 0 14 14"
|
|
253
|
+
fill="none"
|
|
254
|
+
aria-hidden="true"
|
|
255
|
+
>
|
|
256
|
+
<path d="m2 7 3.2 3.2L12 3.6" stroke="currentColor" stroke-width="1.6" />
|
|
257
|
+
</svg>
|
|
258
|
+
{/if}
|
|
259
|
+
</span>
|
|
260
|
+
{#if option.description}
|
|
261
|
+
<span class="truncate text-xs text-text-muted">{option.description}</span>
|
|
262
|
+
{/if}
|
|
263
|
+
</div>
|
|
264
|
+
{/each}
|
|
265
|
+
{/each}
|
|
266
|
+
{/if}
|
|
267
|
+
</div>
|
|
268
|
+
{/if}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Tone } from '../tones.js';
|
|
2
|
+
export interface ComboboxOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Second line under the label. */
|
|
6
|
+
description?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
/** Options sharing a group are rendered under one heading. */
|
|
9
|
+
group?: string;
|
|
10
|
+
}
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Bindable — the selected option's `value`, or `''`. */
|
|
13
|
+
value?: string;
|
|
14
|
+
options: ComboboxOption[];
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/** Shows a clear button once something is selected. */
|
|
20
|
+
clearable?: boolean;
|
|
21
|
+
emptyText?: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
onchange?: (value: string) => void;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const Combobox: import("svelte").Component<Props, {}, "value">;
|
|
28
|
+
type Combobox = ReturnType<typeof Combobox>;
|
|
29
|
+
export default Combobox;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface CommandItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Second line under the label. */
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Items sharing a group are rendered under one heading. */
|
|
8
|
+
group?: string;
|
|
9
|
+
/** Right-aligned hint, e.g. `'⌘K'`. */
|
|
10
|
+
shortcut?: string;
|
|
11
|
+
/** Extra words to match on that aren't in the label. */
|
|
12
|
+
keywords?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
onselect?: () => void;
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { focusTrap } from '../actions/dismissable.js';
|
|
20
|
+
import { useLocale } from '../locale.svelte.js';
|
|
21
|
+
import { toneSoft, type Tone } from '../tones.js';
|
|
22
|
+
import Kbd from './Kbd.svelte';
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
open?: boolean;
|
|
26
|
+
items: CommandItem[];
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
emptyText?: string;
|
|
29
|
+
tone?: Tone;
|
|
30
|
+
/**
|
|
31
|
+
* Key that opens the palette with Cmd/Ctrl held. Default `'k'`.
|
|
32
|
+
* Pass `null` to wire your own trigger only.
|
|
33
|
+
*/
|
|
34
|
+
hotkey?: string | null;
|
|
35
|
+
onselect?: (item: CommandItem) => void;
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
open = $bindable(false),
|
|
41
|
+
items,
|
|
42
|
+
placeholder,
|
|
43
|
+
emptyText,
|
|
44
|
+
tone = 'brand',
|
|
45
|
+
hotkey = 'k',
|
|
46
|
+
onselect,
|
|
47
|
+
class: className = ''
|
|
48
|
+
}: Props = $props();
|
|
49
|
+
|
|
50
|
+
const t = useLocale();
|
|
51
|
+
|
|
52
|
+
let dialog: HTMLDialogElement | null = $state(null);
|
|
53
|
+
let inputEl: HTMLInputElement | null = $state(null);
|
|
54
|
+
let listEl: HTMLElement | null = $state(null);
|
|
55
|
+
let query = $state('');
|
|
56
|
+
let active = $state(0);
|
|
57
|
+
|
|
58
|
+
const matches = $derived.by(() => {
|
|
59
|
+
const needle = query.trim().toLowerCase();
|
|
60
|
+
if (!needle) return items;
|
|
61
|
+
return items.filter((item) =>
|
|
62
|
+
`${item.label} ${item.description ?? ''} ${item.keywords ?? ''} ${item.group ?? ''}`
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.includes(needle)
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const groups = $derived.by(() => {
|
|
69
|
+
const out: [string, CommandItem[]][] = [];
|
|
70
|
+
for (const item of matches) {
|
|
71
|
+
const key = item.group ?? '';
|
|
72
|
+
const bucket = out.find(([name]) => name === key);
|
|
73
|
+
if (bucket) bucket[1].push(item);
|
|
74
|
+
else out.push([key, [item]]);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const flat = $derived(groups.flatMap(([, group]) => group));
|
|
80
|
+
|
|
81
|
+
$effect(() => {
|
|
82
|
+
if (!dialog) return;
|
|
83
|
+
if (open && !dialog.open) {
|
|
84
|
+
dialog.showModal();
|
|
85
|
+
query = '';
|
|
86
|
+
active = 0;
|
|
87
|
+
queueMicrotask(() => inputEl?.focus());
|
|
88
|
+
} else if (!open && dialog.open) {
|
|
89
|
+
dialog.close();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
$effect(() => {
|
|
94
|
+
if (!hotkey) return;
|
|
95
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
96
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === hotkey.toLowerCase()) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
open = !open;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener('keydown', onKeydown);
|
|
102
|
+
return () => window.removeEventListener('keydown', onKeydown);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function run(item: CommandItem) {
|
|
106
|
+
if (item.disabled) return;
|
|
107
|
+
open = false;
|
|
108
|
+
item.onselect?.();
|
|
109
|
+
onselect?.(item);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function move(step: number) {
|
|
113
|
+
if (flat.length === 0) return;
|
|
114
|
+
let next = active;
|
|
115
|
+
for (let i = 0; i < flat.length; i++) {
|
|
116
|
+
next = (next + step + flat.length) % flat.length;
|
|
117
|
+
if (!flat[next].disabled) break;
|
|
118
|
+
}
|
|
119
|
+
active = next;
|
|
120
|
+
listEl?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onKeydown(event: KeyboardEvent) {
|
|
124
|
+
switch (event.key) {
|
|
125
|
+
case 'ArrowDown':
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
move(1);
|
|
128
|
+
break;
|
|
129
|
+
case 'ArrowUp':
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
move(-1);
|
|
132
|
+
break;
|
|
133
|
+
case 'Enter':
|
|
134
|
+
if (flat[active]) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
run(flat[active]);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
case 'Escape':
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
open = false;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<dialog
|
|
148
|
+
bind:this={dialog}
|
|
149
|
+
onclose={() => (open = false)}
|
|
150
|
+
onclick={(event) => {
|
|
151
|
+
if (event.target === dialog) open = false;
|
|
152
|
+
}}
|
|
153
|
+
class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none border border-hairline bg-bg p-0 text-text backdrop:bg-text/40 {className}"
|
|
154
|
+
>
|
|
155
|
+
<div use:focusTrap={{ autofocus: false }} onkeydown={onKeydown} role="none" class="flex flex-col">
|
|
156
|
+
<div class="flex items-center gap-3 border-b border-hairline px-4">
|
|
157
|
+
<svg
|
|
158
|
+
class="size-4 shrink-0 text-text-muted"
|
|
159
|
+
viewBox="0 0 16 16"
|
|
160
|
+
fill="none"
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
>
|
|
163
|
+
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.5" />
|
|
164
|
+
<path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.5" />
|
|
165
|
+
</svg>
|
|
166
|
+
<input
|
|
167
|
+
bind:this={inputEl}
|
|
168
|
+
bind:value={query}
|
|
169
|
+
oninput={() => (active = 0)}
|
|
170
|
+
type="text"
|
|
171
|
+
autocomplete="off"
|
|
172
|
+
aria-label={placeholder ?? t.current.commandPlaceholder}
|
|
173
|
+
placeholder={placeholder ?? t.current.commandPlaceholder}
|
|
174
|
+
class="h-12 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
|
|
175
|
+
/>
|
|
176
|
+
<Kbd>esc</Kbd>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div
|
|
180
|
+
bind:this={listEl}
|
|
181
|
+
role="listbox"
|
|
182
|
+
aria-label="Commands"
|
|
183
|
+
class="max-h-80 overflow-y-auto py-1"
|
|
184
|
+
>
|
|
185
|
+
{#if flat.length === 0}
|
|
186
|
+
<p class="px-4 py-10 text-center font-sans text-sm text-text-muted">
|
|
187
|
+
{emptyText ?? t.current.noResults}
|
|
188
|
+
</p>
|
|
189
|
+
{:else}
|
|
190
|
+
{#each groups as [group, groupItems] (group)}
|
|
191
|
+
{#if group}
|
|
192
|
+
<p class="px-4 pt-3 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase">
|
|
193
|
+
{group}
|
|
194
|
+
</p>
|
|
195
|
+
{/if}
|
|
196
|
+
{#each groupItems as item (item.id)}
|
|
197
|
+
{@const index = flat.indexOf(item)}
|
|
198
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
199
|
+
<div
|
|
200
|
+
data-index={index}
|
|
201
|
+
role="option"
|
|
202
|
+
tabindex="-1"
|
|
203
|
+
aria-selected={index === active}
|
|
204
|
+
aria-disabled={item.disabled}
|
|
205
|
+
onclick={() => run(item)}
|
|
206
|
+
onpointermove={() => (active = index)}
|
|
207
|
+
class="flex cursor-pointer items-center gap-3 px-4 py-2.5 font-sans text-[14px] transition-colors duration-100
|
|
208
|
+
{item.disabled ? 'pointer-events-none opacity-40' : ''}
|
|
209
|
+
{index === active ? toneSoft[tone] : 'text-text-secondary'}"
|
|
210
|
+
>
|
|
211
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
212
|
+
<span class="truncate">{item.label}</span>
|
|
213
|
+
{#if item.description}
|
|
214
|
+
<span class="truncate text-xs text-text-muted">{item.description}</span>
|
|
215
|
+
{/if}
|
|
216
|
+
</span>
|
|
217
|
+
{#if item.shortcut}
|
|
218
|
+
<span class="shrink-0 font-mono text-[11px] text-text-muted">{item.shortcut}</span>
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|
|
221
|
+
{/each}
|
|
222
|
+
{/each}
|
|
223
|
+
{/if}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</dialog>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface CommandItem {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
/** Second line under the label. */
|
|
5
|
+
description?: string;
|
|
6
|
+
/** Items sharing a group are rendered under one heading. */
|
|
7
|
+
group?: string;
|
|
8
|
+
/** Right-aligned hint, e.g. `'⌘K'`. */
|
|
9
|
+
shortcut?: string;
|
|
10
|
+
/** Extra words to match on that aren't in the label. */
|
|
11
|
+
keywords?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onselect?: () => void;
|
|
14
|
+
}
|
|
15
|
+
import { type Tone } from '../tones.js';
|
|
16
|
+
interface Props {
|
|
17
|
+
open?: boolean;
|
|
18
|
+
items: CommandItem[];
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
emptyText?: string;
|
|
21
|
+
tone?: Tone;
|
|
22
|
+
/**
|
|
23
|
+
* Key that opens the palette with Cmd/Ctrl held. Default `'k'`.
|
|
24
|
+
* Pass `null` to wire your own trigger only.
|
|
25
|
+
*/
|
|
26
|
+
hotkey?: string | null;
|
|
27
|
+
onselect?: (item: CommandItem) => void;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const CommandPalette: import("svelte").Component<Props, {}, "open">;
|
|
31
|
+
type CommandPalette = ReturnType<typeof CommandPalette>;
|
|
32
|
+
export default CommandPalette;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Button from './Button.svelte';
|
|
4
|
+
import Modal from './Modal.svelte';
|
|
5
|
+
import { useLocale } from '../locale.svelte.js';
|
|
6
|
+
import { toneSoft, type Tone } from '../tones.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
open?: boolean;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Drives the confirm button and the glyph. `danger` for destructive acts. */
|
|
13
|
+
tone?: Tone;
|
|
14
|
+
confirmLabel?: string;
|
|
15
|
+
cancelLabel?: string;
|
|
16
|
+
/** Spins the confirm button and blocks dismissal while the action runs. */
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
/** Awaited — the dialog stays open and busy until it settles. */
|
|
19
|
+
onconfirm?: () => void | Promise<void>;
|
|
20
|
+
oncancel?: () => void;
|
|
21
|
+
icon?: Snippet;
|
|
22
|
+
/** Extra content between the description and the buttons. */
|
|
23
|
+
children?: Snippet;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
open = $bindable(false),
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
tone = 'danger',
|
|
31
|
+
confirmLabel,
|
|
32
|
+
cancelLabel,
|
|
33
|
+
loading = $bindable(false),
|
|
34
|
+
onconfirm,
|
|
35
|
+
oncancel,
|
|
36
|
+
icon,
|
|
37
|
+
children
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const t = useLocale();
|
|
41
|
+
|
|
42
|
+
async function confirm() {
|
|
43
|
+
try {
|
|
44
|
+
loading = true;
|
|
45
|
+
await onconfirm?.();
|
|
46
|
+
open = false;
|
|
47
|
+
} finally {
|
|
48
|
+
loading = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cancel() {
|
|
53
|
+
if (loading) return;
|
|
54
|
+
open = false;
|
|
55
|
+
oncancel?.();
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<Modal
|
|
60
|
+
bind:open
|
|
61
|
+
size="sm"
|
|
62
|
+
dismissible={!loading}
|
|
63
|
+
showClose={false}
|
|
64
|
+
onclose={() => (loading = false)}
|
|
65
|
+
>
|
|
66
|
+
<div class="flex items-start gap-4">
|
|
67
|
+
<span class="flex size-10 shrink-0 items-center justify-center rounded-full {toneSoft[tone]}">
|
|
68
|
+
{#if icon}
|
|
69
|
+
{@render icon()}
|
|
70
|
+
{:else}
|
|
71
|
+
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
72
|
+
<path d="M10 5.5v5" stroke="currentColor" stroke-width="1.6" />
|
|
73
|
+
<circle cx="10" cy="14" r="1" fill="currentColor" />
|
|
74
|
+
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.4" />
|
|
75
|
+
</svg>
|
|
76
|
+
{/if}
|
|
77
|
+
</span>
|
|
78
|
+
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
79
|
+
<h2 class="font-heading text-lg font-medium tracking-tight text-text">{title}</h2>
|
|
80
|
+
{#if description}
|
|
81
|
+
<p class="text-sm leading-relaxed text-text-secondary">{description}</p>
|
|
82
|
+
{/if}
|
|
83
|
+
{#if children}
|
|
84
|
+
{@render children()}
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{#snippet footer()}
|
|
90
|
+
<Button variant="ghost" tone="neutral" onclick={cancel} disabled={loading}>
|
|
91
|
+
{cancelLabel ?? t.current.cancel}
|
|
92
|
+
</Button>
|
|
93
|
+
<Button {tone} onclick={confirm} {loading}>
|
|
94
|
+
{confirmLabel ?? t.current.confirm}
|
|
95
|
+
</Button>
|
|
96
|
+
{/snippet}
|
|
97
|
+
</Modal>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type Tone } from '../tones.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
open?: boolean;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Drives the confirm button and the glyph. `danger` for destructive acts. */
|
|
8
|
+
tone?: Tone;
|
|
9
|
+
confirmLabel?: string;
|
|
10
|
+
cancelLabel?: string;
|
|
11
|
+
/** Spins the confirm button and blocks dismissal while the action runs. */
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
/** Awaited — the dialog stays open and busy until it settles. */
|
|
14
|
+
onconfirm?: () => void | Promise<void>;
|
|
15
|
+
oncancel?: () => void;
|
|
16
|
+
icon?: Snippet;
|
|
17
|
+
/** Extra content between the description and the buttons. */
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
}
|
|
20
|
+
declare const ConfirmDialog: import("svelte").Component<Props, {}, "loading" | "open">;
|
|
21
|
+
type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
|
|
22
|
+
export default ConfirmDialog;
|