@ternkit/tern 1.3.0 → 1.5.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/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/deferred.d.ts +5 -1
- package/dist/runtime/deferred.js +27 -11
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.js +168 -0
- package/dist/server/intl.js +55 -12
- package/dist/ui/AlertDialog.svelte +79 -0
- package/dist/ui/AlertDialog.svelte.d.ts +24 -0
- package/dist/ui/AreaChart.svelte +9 -0
- package/dist/ui/AreaChart.svelte.d.ts +26 -0
- package/dist/ui/AspectRatio.svelte +33 -0
- package/dist/ui/AspectRatio.svelte.d.ts +10 -0
- package/dist/ui/BarChart.svelte +170 -0
- package/dist/ui/BarChart.svelte.d.ts +40 -0
- package/dist/ui/Breadcrumb.svelte +92 -0
- package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
- package/dist/ui/ButtonGroup.svelte +61 -0
- package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
- package/dist/ui/Calendar.svelte +265 -0
- package/dist/ui/Calendar.svelte.d.ts +28 -0
- package/dist/ui/Carousel.svelte +169 -0
- package/dist/ui/Carousel.svelte.d.ts +36 -0
- package/dist/ui/ChartFrame.svelte +174 -0
- package/dist/ui/ChartFrame.svelte.d.ts +24 -0
- package/dist/ui/Checkbox.svelte +13 -1
- package/dist/ui/Collapsible.svelte +59 -0
- package/dist/ui/Collapsible.svelte.d.ts +12 -0
- package/dist/ui/Combobox.svelte +350 -0
- package/dist/ui/Combobox.svelte.d.ts +24 -0
- package/dist/ui/Command.svelte +268 -0
- package/dist/ui/Command.svelte.d.ts +28 -0
- package/dist/ui/ConsentScreen.svelte +125 -0
- package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
- package/dist/ui/ContextMenu.svelte +78 -0
- package/dist/ui/ContextMenu.svelte.d.ts +13 -0
- package/dist/ui/DataTable.svelte +277 -0
- package/dist/ui/DataTable.svelte.d.ts +53 -0
- package/dist/ui/DatePicker.svelte +138 -0
- package/dist/ui/DatePicker.svelte.d.ts +25 -0
- package/dist/ui/Dialog.svelte +168 -27
- package/dist/ui/Dialog.svelte.d.ts +10 -0
- package/dist/ui/DonutChart.svelte +115 -0
- package/dist/ui/DonutChart.svelte.d.ts +40 -0
- package/dist/ui/Drawer.svelte +19 -0
- package/dist/ui/Drawer.svelte.d.ts +14 -0
- package/dist/ui/Empty.svelte +62 -0
- package/dist/ui/Empty.svelte.d.ts +15 -0
- package/dist/ui/Field.svelte +22 -2
- package/dist/ui/Field.svelte.d.ts +2 -0
- package/dist/ui/Form.svelte +159 -0
- package/dist/ui/Form.svelte.d.ts +21 -0
- package/dist/ui/HoverCard.svelte +106 -0
- package/dist/ui/HoverCard.svelte.d.ts +26 -0
- package/dist/ui/Input.svelte +2 -1
- package/dist/ui/InputGroup.svelte +80 -0
- package/dist/ui/InputGroup.svelte.d.ts +13 -0
- package/dist/ui/InputOTP.svelte +140 -0
- package/dist/ui/InputOTP.svelte.d.ts +18 -0
- package/dist/ui/Item.svelte +86 -0
- package/dist/ui/Item.svelte.d.ts +17 -0
- package/dist/ui/Label.svelte +29 -0
- package/dist/ui/Label.svelte.d.ts +10 -0
- package/dist/ui/LineChart.svelte +171 -0
- package/dist/ui/LineChart.svelte.d.ts +42 -0
- package/dist/ui/Menu.svelte +22 -151
- package/dist/ui/Menu.svelte.d.ts +3 -10
- package/dist/ui/MenuList.svelte +317 -0
- package/dist/ui/MenuList.svelte.d.ts +29 -0
- package/dist/ui/Menubar.svelte +133 -0
- package/dist/ui/Menubar.svelte.d.ts +14 -0
- package/dist/ui/NavigationMenu.svelte +208 -0
- package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
- package/dist/ui/Pagination.svelte +114 -0
- package/dist/ui/Pagination.svelte.d.ts +16 -0
- package/dist/ui/PasswordInput.svelte +134 -0
- package/dist/ui/PasswordInput.svelte.d.ts +11 -0
- package/dist/ui/Popover.svelte +127 -0
- package/dist/ui/Popover.svelte.d.ts +29 -0
- package/dist/ui/Resizable.svelte +122 -0
- package/dist/ui/Resizable.svelte.d.ts +16 -0
- package/dist/ui/ScrollArea.svelte +43 -0
- package/dist/ui/ScrollArea.svelte.d.ts +13 -0
- package/dist/ui/Select.svelte +3 -2
- package/dist/ui/SelectMenu.svelte +301 -0
- package/dist/ui/SelectMenu.svelte.d.ts +18 -0
- package/dist/ui/Sheet.svelte +21 -0
- package/dist/ui/Sheet.svelte.d.ts +16 -0
- package/dist/ui/Sidebar.svelte +263 -0
- package/dist/ui/Sidebar.svelte.d.ts +35 -0
- package/dist/ui/SignIn.svelte +182 -0
- package/dist/ui/SignIn.svelte.d.ts +38 -0
- package/dist/ui/SignUp.svelte +106 -0
- package/dist/ui/SignUp.svelte.d.ts +23 -0
- package/dist/ui/Slider.svelte +196 -0
- package/dist/ui/Slider.svelte.d.ts +19 -0
- package/dist/ui/Sparkline.svelte +23 -0
- package/dist/ui/Sparkline.svelte.d.ts +11 -0
- package/dist/ui/Stat.svelte +91 -0
- package/dist/ui/Stat.svelte.d.ts +18 -0
- package/dist/ui/Table.svelte +70 -0
- package/dist/ui/Table.svelte.d.ts +36 -0
- package/dist/ui/Textarea.svelte +2 -1
- package/dist/ui/Toggle.svelte +82 -0
- package/dist/ui/Toggle.svelte.d.ts +17 -0
- package/dist/ui/ToggleGroup.svelte +143 -0
- package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
- package/dist/ui/Tooltip.svelte +27 -19
- package/dist/ui/Tooltip.svelte.d.ts +2 -1
- package/dist/ui/chart.d.ts +26 -0
- package/dist/ui/chart.js +89 -0
- package/dist/ui/dates.d.ts +13 -0
- package/dist/ui/dates.js +64 -0
- package/dist/ui/field.d.ts +8 -0
- package/dist/ui/floating.d.ts +44 -0
- package/dist/ui/floating.js +97 -0
- package/dist/ui/form.d.ts +12 -0
- package/dist/ui/form.js +8 -0
- package/dist/ui/index.d.ts +54 -0
- package/dist/ui/index.js +43 -0
- package/dist/ui/menu.d.ts +25 -0
- package/dist/ui/menu.js +1 -0
- package/dist/ui/options.d.ts +17 -0
- package/dist/ui/options.js +22 -0
- package/dist/ui/table.d.ts +26 -0
- package/dist/ui/table.js +1 -0
- package/dist/ui/theme.css +25 -0
- package/dist/vite/index.js +12 -3
- package/dist/vite/native.d.ts +3 -0
- package/dist/vite/native.js +122 -0
- package/package.json +3 -2
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getField } from './field.js';
|
|
3
|
+
import { float } from './floating.js';
|
|
4
|
+
import { grouped, matches, type Option } from './options.js';
|
|
5
|
+
import Spinner from './Spinner.svelte';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
/** The options. With onsearch, the options for the current query. */
|
|
9
|
+
options: Option[];
|
|
10
|
+
/** The chosen option's value; bind it. */
|
|
11
|
+
value?: string | null;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Labels it when it isn't in a Field. */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Posts the chosen value under this name. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** Shown when nothing matches. */
|
|
18
|
+
empty?: string;
|
|
19
|
+
/** Searches elsewhere, such as an endpoint, instead of filtering options. Set options from it. */
|
|
20
|
+
onsearch?: (query: string) => void | Promise<unknown>;
|
|
21
|
+
/** Milliseconds to wait after typing before onsearch. */
|
|
22
|
+
debounce?: number;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
onchange?: (value: string | null, option: Option | null) => void;
|
|
26
|
+
};
|
|
27
|
+
const field = getField();
|
|
28
|
+
let {
|
|
29
|
+
options,
|
|
30
|
+
value = $bindable(field?.value ?? null),
|
|
31
|
+
placeholder,
|
|
32
|
+
label,
|
|
33
|
+
name: nameProp,
|
|
34
|
+
empty = 'No results',
|
|
35
|
+
onsearch,
|
|
36
|
+
debounce = 200,
|
|
37
|
+
disabled = false,
|
|
38
|
+
class: klass = '',
|
|
39
|
+
onchange
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
const name = $derived(nameProp ?? field?.name);
|
|
42
|
+
|
|
43
|
+
const uid = $props.id();
|
|
44
|
+
const id = `tern-combobox-${uid}`;
|
|
45
|
+
let input = $state<HTMLInputElement>();
|
|
46
|
+
let list = $state<HTMLDivElement>();
|
|
47
|
+
let open = $state(false);
|
|
48
|
+
let query = $state('');
|
|
49
|
+
let typing = $state(false);
|
|
50
|
+
let active = $state<string | null>(null);
|
|
51
|
+
let searching = $state(false);
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
+
// Options the user has seen, so the chosen one's label survives a new search.
|
|
54
|
+
const known = new Map<string, Option>();
|
|
55
|
+
|
|
56
|
+
$effect(() => {
|
|
57
|
+
for (const o of options) known.set(o.value, o);
|
|
58
|
+
});
|
|
59
|
+
const selected = $derived(value === null ? null : (options.find((o) => o.value === value) ?? known.get(value) ?? null));
|
|
60
|
+
const shown = $derived(onsearch || !typing || !query ? options : options.filter((o) => matches(`${o.label} ${o.description ?? ''}`, query)));
|
|
61
|
+
const enabled = $derived(shown.filter((o) => !o.disabled));
|
|
62
|
+
const text = $derived(typing ? query : (selected?.label ?? ''));
|
|
63
|
+
|
|
64
|
+
function show() {
|
|
65
|
+
if (disabled || field?.disabled) return;
|
|
66
|
+
open = true;
|
|
67
|
+
// Open on the chosen option, else the first.
|
|
68
|
+
active = selected && !selected.disabled && enabled.includes(selected) ? selected.value : (enabled[0]?.value ?? null);
|
|
69
|
+
}
|
|
70
|
+
function hide() {
|
|
71
|
+
open = false;
|
|
72
|
+
typing = false;
|
|
73
|
+
}
|
|
74
|
+
function choose(o: Option) {
|
|
75
|
+
if (o.disabled) return;
|
|
76
|
+
value = o.value;
|
|
77
|
+
known.set(o.value, o);
|
|
78
|
+
hide();
|
|
79
|
+
onchange?.(o.value, o);
|
|
80
|
+
input?.focus();
|
|
81
|
+
}
|
|
82
|
+
function move(by: number | 'first' | 'last') {
|
|
83
|
+
if (!open) return show();
|
|
84
|
+
const list = enabled;
|
|
85
|
+
if (list.length === 0) return;
|
|
86
|
+
const i = list.findIndex((o) => o.value === active);
|
|
87
|
+
const next = by === 'first' ? 0 : by === 'last' ? list.length - 1 : i < 0 ? (by > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, i + by));
|
|
88
|
+
active = list[next]!.value;
|
|
89
|
+
document.getElementById(`${id}-opt-${next}`)?.scrollIntoView?.({ block: 'nearest' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function oninput() {
|
|
93
|
+
query = input!.value;
|
|
94
|
+
typing = true;
|
|
95
|
+
open = true;
|
|
96
|
+
if (query === '' && value !== null) {
|
|
97
|
+
value = null;
|
|
98
|
+
onchange?.(null, null);
|
|
99
|
+
}
|
|
100
|
+
if (onsearch) {
|
|
101
|
+
searching = true; // until the results arrive, don't claim there are none
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
timer = setTimeout(async () => {
|
|
104
|
+
searching = true;
|
|
105
|
+
try {
|
|
106
|
+
await onsearch(query);
|
|
107
|
+
} finally {
|
|
108
|
+
searching = false;
|
|
109
|
+
}
|
|
110
|
+
}, debounce);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
$effect(() => {
|
|
114
|
+
// Keep the active option valid as the list changes.
|
|
115
|
+
if (open && !enabled.some((o) => o.value === active)) active = enabled[0]?.value ?? null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function onkeydown(e: KeyboardEvent) {
|
|
119
|
+
switch (e.key) {
|
|
120
|
+
case 'ArrowDown':
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
return e.altKey ? show() : move(1);
|
|
123
|
+
case 'ArrowUp':
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
return move(-1);
|
|
126
|
+
case 'PageDown':
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
return move(10);
|
|
129
|
+
case 'PageUp':
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
return move(-10);
|
|
132
|
+
case 'Enter': {
|
|
133
|
+
if (!open) return;
|
|
134
|
+
const o = enabled.find((x) => x.value === active);
|
|
135
|
+
if (o) {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
choose(o);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
case 'Escape':
|
|
142
|
+
if (open) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
hide();
|
|
146
|
+
} else if (value !== null) {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
value = null;
|
|
149
|
+
onchange?.(null, null);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
case 'Tab':
|
|
153
|
+
return hide();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
$effect(() => {
|
|
158
|
+
if (!open || !list || !input) return;
|
|
159
|
+
list.showPopover?.();
|
|
160
|
+
const stop = float(list, input.parentElement ?? input, { side: 'bottom', align: 'start', offset: 4, matchWidth: true });
|
|
161
|
+
const away = (e: PointerEvent) => {
|
|
162
|
+
const t = e.target as Node;
|
|
163
|
+
if (!list?.contains(t) && !input?.parentElement?.contains(t)) hide();
|
|
164
|
+
};
|
|
165
|
+
document.addEventListener('pointerdown', away, true);
|
|
166
|
+
return () => {
|
|
167
|
+
stop();
|
|
168
|
+
document.removeEventListener('pointerdown', away, true);
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const activeIndex = $derived(shown.findIndex((o) => o.value === active));
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<div class="tern-combobox {klass}">
|
|
176
|
+
<input
|
|
177
|
+
bind:this={input}
|
|
178
|
+
class="input"
|
|
179
|
+
type="text"
|
|
180
|
+
role="combobox"
|
|
181
|
+
id={field?.id ?? id}
|
|
182
|
+
value={text}
|
|
183
|
+
{placeholder}
|
|
184
|
+
aria-label={field ? undefined : label}
|
|
185
|
+
aria-describedby={field?.describedBy}
|
|
186
|
+
aria-invalid={field?.invalid || undefined}
|
|
187
|
+
aria-required={field?.required || undefined}
|
|
188
|
+
aria-expanded={open}
|
|
189
|
+
aria-controls="{id}-list"
|
|
190
|
+
aria-autocomplete="list"
|
|
191
|
+
aria-activedescendant={open && activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
|
|
192
|
+
autocomplete="off"
|
|
193
|
+
spellcheck="false"
|
|
194
|
+
disabled={disabled || field?.disabled}
|
|
195
|
+
{oninput}
|
|
196
|
+
{onkeydown}
|
|
197
|
+
onclick={() => (open ? hide() : show())}
|
|
198
|
+
onblur={(e) => {
|
|
199
|
+
if (!list?.contains(e.relatedTarget as Node)) typing = false;
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
<span class="icon" aria-hidden="true">
|
|
203
|
+
{#if searching}<Spinner size="0.875rem" label="Searching" />{:else}<svg viewBox="0 0 16 16" width="14" height="14"><path d="M5 6.5l3-3 3 3M5 9.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>{/if}
|
|
204
|
+
</span>
|
|
205
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
206
|
+
<div bind:this={list} id="{id}-list" class="list" role="listbox" aria-label={label} aria-labelledby={field?.labelId} popover="manual" class:open>
|
|
207
|
+
{#if open}
|
|
208
|
+
{#each grouped(shown) as g, gi (gi)}
|
|
209
|
+
{#snippet items()}
|
|
210
|
+
{#each g.options as o (o.value)}
|
|
211
|
+
{@const i = shown.indexOf(o)}
|
|
212
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
213
|
+
<div
|
|
214
|
+
id="{id}-opt-{i}"
|
|
215
|
+
class="option"
|
|
216
|
+
class:active={o.value === active}
|
|
217
|
+
role="option"
|
|
218
|
+
tabindex="-1"
|
|
219
|
+
aria-selected={o.value === value}
|
|
220
|
+
aria-disabled={o.disabled || undefined}
|
|
221
|
+
onpointerdown={(e) => e.preventDefault()}
|
|
222
|
+
onpointermove={() => !o.disabled && (active = o.value)}
|
|
223
|
+
onclick={() => choose(o)}
|
|
224
|
+
>
|
|
225
|
+
<span class="check" aria-hidden="true">
|
|
226
|
+
{#if o.value === value}<svg viewBox="0 0 16 16" width="14" height="14"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>{/if}
|
|
227
|
+
</span>
|
|
228
|
+
<span class="text">
|
|
229
|
+
<span>{o.label}</span>
|
|
230
|
+
{#if o.description}<span class="description">{o.description}</span>{/if}
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
{/each}
|
|
234
|
+
{/snippet}
|
|
235
|
+
{#if g.group}
|
|
236
|
+
<div role="group" aria-labelledby="{id}-group-{gi}">
|
|
237
|
+
<div class="heading" id="{id}-group-{gi}" role="presentation">{g.group}</div>
|
|
238
|
+
{@render items()}
|
|
239
|
+
</div>
|
|
240
|
+
{:else}
|
|
241
|
+
{@render items()}
|
|
242
|
+
{/if}
|
|
243
|
+
{:else}
|
|
244
|
+
<div class="empty" role="status">{searching ? 'Searching…' : empty}</div>
|
|
245
|
+
{/each}
|
|
246
|
+
{/if}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<style>
|
|
251
|
+
.tern-combobox {
|
|
252
|
+
position: relative;
|
|
253
|
+
display: flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
}
|
|
256
|
+
.input {
|
|
257
|
+
width: 100%;
|
|
258
|
+
box-sizing: border-box;
|
|
259
|
+
height: var(--tern-control-height);
|
|
260
|
+
padding: 0 2rem 0 0.75rem;
|
|
261
|
+
font: inherit;
|
|
262
|
+
font-size: 0.875rem;
|
|
263
|
+
color: var(--tern-fg);
|
|
264
|
+
background: var(--tern-surface);
|
|
265
|
+
border: 1px solid var(--tern-border-strong);
|
|
266
|
+
border-radius: var(--tern-radius-sm);
|
|
267
|
+
transition:
|
|
268
|
+
border-color var(--tern-duration),
|
|
269
|
+
box-shadow var(--tern-duration);
|
|
270
|
+
}
|
|
271
|
+
.input::placeholder {
|
|
272
|
+
color: var(--tern-muted);
|
|
273
|
+
}
|
|
274
|
+
.input:hover:not(:disabled) {
|
|
275
|
+
border-color: var(--tern-muted);
|
|
276
|
+
}
|
|
277
|
+
.input:focus-visible {
|
|
278
|
+
outline: none;
|
|
279
|
+
border-color: var(--tern-accent);
|
|
280
|
+
box-shadow: 0 0 0 3px color-mix(in oklch, var(--tern-ring) 30%, transparent);
|
|
281
|
+
}
|
|
282
|
+
.input[aria-invalid='true'] {
|
|
283
|
+
border-color: var(--tern-danger);
|
|
284
|
+
}
|
|
285
|
+
.input:disabled {
|
|
286
|
+
opacity: 0.6;
|
|
287
|
+
cursor: not-allowed;
|
|
288
|
+
}
|
|
289
|
+
.icon {
|
|
290
|
+
position: absolute;
|
|
291
|
+
right: 0.6rem;
|
|
292
|
+
display: inline-flex;
|
|
293
|
+
color: var(--tern-muted);
|
|
294
|
+
pointer-events: none;
|
|
295
|
+
}
|
|
296
|
+
.list {
|
|
297
|
+
max-height: 18rem;
|
|
298
|
+
overflow: auto;
|
|
299
|
+
padding: 0.3rem;
|
|
300
|
+
color: var(--tern-fg);
|
|
301
|
+
background: var(--tern-surface);
|
|
302
|
+
border: 1px solid var(--tern-border);
|
|
303
|
+
border-radius: var(--tern-radius);
|
|
304
|
+
box-shadow: var(--tern-shadow-lg);
|
|
305
|
+
font-size: 0.875rem;
|
|
306
|
+
}
|
|
307
|
+
.list:not(.open) {
|
|
308
|
+
display: none;
|
|
309
|
+
}
|
|
310
|
+
.option {
|
|
311
|
+
display: flex;
|
|
312
|
+
align-items: flex-start;
|
|
313
|
+
gap: 0.4rem;
|
|
314
|
+
padding: 0.4rem 0.6rem 0.4rem 0.35rem;
|
|
315
|
+
border-radius: calc(var(--tern-radius-sm) - 2px);
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
.option.active {
|
|
319
|
+
background: var(--tern-accent-soft);
|
|
320
|
+
}
|
|
321
|
+
.option[aria-disabled='true'] {
|
|
322
|
+
opacity: 0.45;
|
|
323
|
+
cursor: not-allowed;
|
|
324
|
+
}
|
|
325
|
+
.check {
|
|
326
|
+
display: inline-flex;
|
|
327
|
+
width: 1rem;
|
|
328
|
+
padding-top: 0.15rem;
|
|
329
|
+
justify-content: center;
|
|
330
|
+
color: var(--tern-accent);
|
|
331
|
+
}
|
|
332
|
+
.text {
|
|
333
|
+
display: grid;
|
|
334
|
+
}
|
|
335
|
+
.description {
|
|
336
|
+
font-size: 0.8125rem;
|
|
337
|
+
color: var(--tern-muted);
|
|
338
|
+
}
|
|
339
|
+
.heading {
|
|
340
|
+
padding: 0.45rem 0.6rem 0.25rem 1.75rem;
|
|
341
|
+
font-size: 0.75rem;
|
|
342
|
+
font-weight: 600;
|
|
343
|
+
color: var(--tern-muted);
|
|
344
|
+
}
|
|
345
|
+
.empty {
|
|
346
|
+
padding: 0.75rem;
|
|
347
|
+
text-align: center;
|
|
348
|
+
color: var(--tern-muted);
|
|
349
|
+
}
|
|
350
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Option } from './options.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
/** The options. With onsearch, the options for the current query. */
|
|
4
|
+
options: Option[];
|
|
5
|
+
/** The chosen option's value; bind it. */
|
|
6
|
+
value?: string | null;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
/** Labels it when it isn't in a Field. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Posts the chosen value under this name. */
|
|
11
|
+
name?: string;
|
|
12
|
+
/** Shown when nothing matches. */
|
|
13
|
+
empty?: string;
|
|
14
|
+
/** Searches elsewhere, such as an endpoint, instead of filtering options. Set options from it. */
|
|
15
|
+
onsearch?: (query: string) => void | Promise<unknown>;
|
|
16
|
+
/** Milliseconds to wait after typing before onsearch. */
|
|
17
|
+
debounce?: number;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
onchange?: (value: string | null, option: Option | null) => void;
|
|
21
|
+
};
|
|
22
|
+
declare const Combobox: import("svelte").Component<Props, {}, "value">;
|
|
23
|
+
type Combobox = ReturnType<typeof Combobox>;
|
|
24
|
+
export default Combobox;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { matches } from './options.js';
|
|
3
|
+
import { trapTab } from './floating.js';
|
|
4
|
+
|
|
5
|
+
export type CommandItem = {
|
|
6
|
+
label: string;
|
|
7
|
+
onselect: () => void;
|
|
8
|
+
/** Groups commands under this heading. */
|
|
9
|
+
group?: string;
|
|
10
|
+
/** Other words that find it, such as ["preferences"] for Settings. */
|
|
11
|
+
keywords?: string[];
|
|
12
|
+
/** A shortcut to show, such as "⌘S". */
|
|
13
|
+
shortcut?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
items: CommandItem[];
|
|
19
|
+
/** Names the palette for screen readers, such as "Commands". */
|
|
20
|
+
label?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
empty?: string;
|
|
23
|
+
/** Show it as a modal palette, opened with open or the hotkey. */
|
|
24
|
+
dialog?: boolean;
|
|
25
|
+
/** Whether the palette is open, with dialog; bind it. */
|
|
26
|
+
open?: boolean;
|
|
27
|
+
/** With dialog: the key that toggles it with ⌘ or Ctrl, such as "k". */
|
|
28
|
+
hotkey?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
};
|
|
31
|
+
let { items, label = 'Commands', placeholder = 'Type a command or search…', empty = 'No results', dialog = false, open = $bindable(false), hotkey, class: klass = '' }: Props = $props();
|
|
32
|
+
|
|
33
|
+
const uid = $props.id();
|
|
34
|
+
const id = `tern-command-${uid}`;
|
|
35
|
+
let query = $state('');
|
|
36
|
+
let active = $state(0);
|
|
37
|
+
let input = $state<HTMLInputElement>();
|
|
38
|
+
let modal = $state<HTMLDialogElement>();
|
|
39
|
+
|
|
40
|
+
const shown = $derived(query ? items.filter((it) => matches(`${it.label} ${(it.keywords ?? []).join(' ')} ${it.group ?? ''}`, query)) : items);
|
|
41
|
+
const groups = $derived.by(() => {
|
|
42
|
+
const out: { group?: string; entries: { it: CommandItem; i: number }[] }[] = [];
|
|
43
|
+
shown.forEach((it, i) => {
|
|
44
|
+
const g = out.find((x) => x.group === it.group);
|
|
45
|
+
if (g) g.entries.push({ it, i });
|
|
46
|
+
else out.push({ group: it.group, entries: [{ it, i }] });
|
|
47
|
+
});
|
|
48
|
+
return out;
|
|
49
|
+
});
|
|
50
|
+
// The order on screen, which grouping may change.
|
|
51
|
+
const order = $derived(groups.flatMap((g) => g.entries.map((e) => e.i)).filter((i) => !shown[i]!.disabled));
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
void query;
|
|
55
|
+
active = order[0] ?? -1;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function run(i: number) {
|
|
59
|
+
const it = shown[i];
|
|
60
|
+
if (!it || it.disabled) return;
|
|
61
|
+
if (dialog) open = false;
|
|
62
|
+
query = '';
|
|
63
|
+
it.onselect();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onkeydown(e: KeyboardEvent) {
|
|
67
|
+
const pos = order.indexOf(active);
|
|
68
|
+
const go = (p: number) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
if (!order.length) return;
|
|
71
|
+
active = order[(p + order.length) % order.length]!;
|
|
72
|
+
document.getElementById(`${id}-item-${active}`)?.scrollIntoView?.({ block: 'nearest' });
|
|
73
|
+
};
|
|
74
|
+
if (e.key === 'ArrowDown') go(pos + 1);
|
|
75
|
+
else if (e.key === 'ArrowUp') go(pos - 1);
|
|
76
|
+
else if (e.key === 'Home' && e.ctrlKey) go(0);
|
|
77
|
+
else if (e.key === 'End' && e.ctrlKey) go(order.length - 1);
|
|
78
|
+
else if (e.key === 'Enter') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
run(active);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The hotkey toggles the palette from anywhere on the page.
|
|
85
|
+
$effect(() => {
|
|
86
|
+
if (!dialog || !hotkey) return;
|
|
87
|
+
const key = (e: KeyboardEvent) => {
|
|
88
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
open = !open;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
window.addEventListener('keydown', key);
|
|
94
|
+
return () => window.removeEventListener('keydown', key);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
$effect(() => {
|
|
98
|
+
if (!dialog || !modal) return;
|
|
99
|
+
if (open && !modal.open) {
|
|
100
|
+
modal.showModal();
|
|
101
|
+
input?.focus();
|
|
102
|
+
} else if (!open && modal.open) modal.close();
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
{#snippet palette()}
|
|
107
|
+
<div class="tern-command {klass}" class:in-dialog={dialog}>
|
|
108
|
+
<div class="search">
|
|
109
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.5" /><path d="M10.5 10.5L14 14" /></svg>
|
|
110
|
+
<input
|
|
111
|
+
bind:this={input}
|
|
112
|
+
bind:value={query}
|
|
113
|
+
type="text"
|
|
114
|
+
role="combobox"
|
|
115
|
+
aria-label={label}
|
|
116
|
+
aria-expanded="true"
|
|
117
|
+
aria-controls="{id}-list"
|
|
118
|
+
aria-autocomplete="list"
|
|
119
|
+
aria-activedescendant={active >= 0 ? `${id}-item-${active}` : undefined}
|
|
120
|
+
autocomplete="off"
|
|
121
|
+
spellcheck="false"
|
|
122
|
+
{placeholder}
|
|
123
|
+
{onkeydown}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="list" id="{id}-list" role="listbox" aria-label={label}>
|
|
127
|
+
{#each groups as g, gi (gi)}
|
|
128
|
+
{#snippet entries()}
|
|
129
|
+
{#each g.entries as { it, i } (i)}
|
|
130
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
131
|
+
<div
|
|
132
|
+
id="{id}-item-{i}"
|
|
133
|
+
class="item"
|
|
134
|
+
class:active={i === active}
|
|
135
|
+
role="option"
|
|
136
|
+
tabindex="-1"
|
|
137
|
+
aria-selected={i === active}
|
|
138
|
+
aria-disabled={it.disabled || undefined}
|
|
139
|
+
onpointerdown={(e) => e.preventDefault()}
|
|
140
|
+
onpointermove={() => !it.disabled && (active = i)}
|
|
141
|
+
onclick={() => run(i)}
|
|
142
|
+
>
|
|
143
|
+
<span>{it.label}</span>
|
|
144
|
+
{#if it.shortcut}<kbd>{it.shortcut}</kbd>{/if}
|
|
145
|
+
</div>
|
|
146
|
+
{/each}
|
|
147
|
+
{/snippet}
|
|
148
|
+
{#if g.group}
|
|
149
|
+
<div role="group" aria-labelledby="{id}-group-{gi}">
|
|
150
|
+
<div class="heading" id="{id}-group-{gi}" role="presentation">{g.group}</div>
|
|
151
|
+
{@render entries()}
|
|
152
|
+
</div>
|
|
153
|
+
{:else}
|
|
154
|
+
{@render entries()}
|
|
155
|
+
{/if}
|
|
156
|
+
{/each}
|
|
157
|
+
{#if shown.length === 0}<div class="empty" role="status">{empty}</div>{/if}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
{/snippet}
|
|
161
|
+
|
|
162
|
+
{#if dialog}
|
|
163
|
+
<dialog
|
|
164
|
+
bind:this={modal}
|
|
165
|
+
class="tern-command-dialog"
|
|
166
|
+
aria-label={label}
|
|
167
|
+
onclose={() => {
|
|
168
|
+
open = false;
|
|
169
|
+
query = '';
|
|
170
|
+
}}
|
|
171
|
+
onclick={(e) => e.target === modal && (open = false)}
|
|
172
|
+
onkeydown={(e) => modal && trapTab(modal, e)}
|
|
173
|
+
>
|
|
174
|
+
{#if open}{@render palette()}{/if}
|
|
175
|
+
</dialog>
|
|
176
|
+
{:else}
|
|
177
|
+
{@render palette()}
|
|
178
|
+
{/if}
|
|
179
|
+
|
|
180
|
+
<style>
|
|
181
|
+
.tern-command {
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-direction: column;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
color: var(--tern-fg);
|
|
186
|
+
background: var(--tern-surface);
|
|
187
|
+
border: 1px solid var(--tern-border);
|
|
188
|
+
border-radius: var(--tern-radius);
|
|
189
|
+
box-shadow: var(--tern-shadow);
|
|
190
|
+
font-size: 0.875rem;
|
|
191
|
+
}
|
|
192
|
+
.in-dialog {
|
|
193
|
+
box-shadow: var(--tern-shadow-lg);
|
|
194
|
+
border-radius: calc(var(--tern-radius) + 4px);
|
|
195
|
+
}
|
|
196
|
+
.search {
|
|
197
|
+
display: flex;
|
|
198
|
+
align-items: center;
|
|
199
|
+
gap: 0.5rem;
|
|
200
|
+
padding: 0 0.85rem;
|
|
201
|
+
border-bottom: 1px solid var(--tern-border);
|
|
202
|
+
color: var(--tern-muted);
|
|
203
|
+
}
|
|
204
|
+
input {
|
|
205
|
+
flex: 1;
|
|
206
|
+
height: 2.75rem;
|
|
207
|
+
font: inherit;
|
|
208
|
+
color: var(--tern-fg);
|
|
209
|
+
background: transparent;
|
|
210
|
+
border: 0;
|
|
211
|
+
outline: none;
|
|
212
|
+
}
|
|
213
|
+
input::placeholder {
|
|
214
|
+
color: var(--tern-muted);
|
|
215
|
+
}
|
|
216
|
+
.search:has(input:focus-visible) {
|
|
217
|
+
box-shadow: inset 0 -2px 0 var(--tern-accent);
|
|
218
|
+
}
|
|
219
|
+
.list {
|
|
220
|
+
max-height: 20rem;
|
|
221
|
+
overflow: auto;
|
|
222
|
+
padding: 0.3rem;
|
|
223
|
+
}
|
|
224
|
+
.item {
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: space-between;
|
|
228
|
+
gap: 1rem;
|
|
229
|
+
padding: 0.5rem 0.65rem;
|
|
230
|
+
border-radius: calc(var(--tern-radius-sm) - 2px);
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
}
|
|
233
|
+
.item.active {
|
|
234
|
+
background: var(--tern-accent-soft);
|
|
235
|
+
}
|
|
236
|
+
.item[aria-disabled='true'] {
|
|
237
|
+
opacity: 0.45;
|
|
238
|
+
cursor: not-allowed;
|
|
239
|
+
}
|
|
240
|
+
kbd {
|
|
241
|
+
font-family: var(--tern-font-mono);
|
|
242
|
+
font-size: 0.75rem;
|
|
243
|
+
color: var(--tern-muted);
|
|
244
|
+
}
|
|
245
|
+
.heading {
|
|
246
|
+
padding: 0.5rem 0.65rem 0.25rem;
|
|
247
|
+
font-size: 0.75rem;
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
color: var(--tern-muted);
|
|
250
|
+
}
|
|
251
|
+
.empty {
|
|
252
|
+
padding: 1.5rem;
|
|
253
|
+
text-align: center;
|
|
254
|
+
color: var(--tern-muted);
|
|
255
|
+
}
|
|
256
|
+
.tern-command-dialog {
|
|
257
|
+
width: min(36rem, calc(100vw - 2rem));
|
|
258
|
+
margin: 12vh auto auto;
|
|
259
|
+
padding: 0;
|
|
260
|
+
border: 0;
|
|
261
|
+
background: transparent;
|
|
262
|
+
overflow: visible;
|
|
263
|
+
}
|
|
264
|
+
.tern-command-dialog::backdrop {
|
|
265
|
+
background: oklch(0.2 0.02 240 / 0.45);
|
|
266
|
+
backdrop-filter: blur(2px);
|
|
267
|
+
}
|
|
268
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type CommandItem = {
|
|
2
|
+
label: string;
|
|
3
|
+
onselect: () => void;
|
|
4
|
+
/** Groups commands under this heading. */
|
|
5
|
+
group?: string;
|
|
6
|
+
/** Other words that find it, such as ["preferences"] for Settings. */
|
|
7
|
+
keywords?: string[];
|
|
8
|
+
/** A shortcut to show, such as "⌘S". */
|
|
9
|
+
shortcut?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type Props = {
|
|
13
|
+
items: CommandItem[];
|
|
14
|
+
/** Names the palette for screen readers, such as "Commands". */
|
|
15
|
+
label?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
empty?: string;
|
|
18
|
+
/** Show it as a modal palette, opened with open or the hotkey. */
|
|
19
|
+
dialog?: boolean;
|
|
20
|
+
/** Whether the palette is open, with dialog; bind it. */
|
|
21
|
+
open?: boolean;
|
|
22
|
+
/** With dialog: the key that toggles it with ⌘ or Ctrl, such as "k". */
|
|
23
|
+
hotkey?: string;
|
|
24
|
+
class?: string;
|
|
25
|
+
};
|
|
26
|
+
declare const Command: import("svelte").Component<Props, {}, "open">;
|
|
27
|
+
type Command = ReturnType<typeof Command>;
|
|
28
|
+
export default Command;
|