@ternkit/tern 1.3.0 → 1.4.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/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 +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import MenuList from './MenuList.svelte';
|
|
3
|
+
import { float, type Align, type Side } from './floating.js';
|
|
4
|
+
import type { MenuClose, MenuItem } from './menu.js';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
items: MenuItem[];
|
|
8
|
+
id: string;
|
|
9
|
+
labelledby?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
/** What it opens next to: an element, or a point for context menus. */
|
|
12
|
+
anchor: Element | { x: number; y: number };
|
|
13
|
+
side?: Side;
|
|
14
|
+
align?: Align;
|
|
15
|
+
/** Which item gets focus when it opens. */
|
|
16
|
+
focus?: 'first' | 'last' | 'none';
|
|
17
|
+
/** Whether this is a submenu, which ArrowLeft closes. */
|
|
18
|
+
sub?: boolean;
|
|
19
|
+
onclose: (how: MenuClose) => void;
|
|
20
|
+
/** Keys the list doesn't use, such as ArrowLeft and ArrowRight in a menubar. */
|
|
21
|
+
onkey?: (e: KeyboardEvent) => void;
|
|
22
|
+
/** Elements whose clicks don't close the menu, such as its button. */
|
|
23
|
+
inside?: () => (Element | null | undefined)[];
|
|
24
|
+
class?: string;
|
|
25
|
+
};
|
|
26
|
+
let { items, id, labelledby, label, anchor, side = 'bottom', align = 'start', focus = 'first', sub = false, onclose, onkey, inside, class: klass = '' }: Props = $props();
|
|
27
|
+
|
|
28
|
+
let menu = $state<HTMLDivElement>();
|
|
29
|
+
let openSub = $state<number | null>(null);
|
|
30
|
+
let subFocus = $state<'first' | 'none'>('first');
|
|
31
|
+
const itemEls: HTMLElement[] = [];
|
|
32
|
+
let typed = '';
|
|
33
|
+
let typedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
|
|
36
|
+
// Items split into groups at each heading, so screen readers announce them.
|
|
37
|
+
const groups = $derived.by(() => {
|
|
38
|
+
const out: { heading?: string; entries: { item: MenuItem; index: number }[] }[] = [{ entries: [] }];
|
|
39
|
+
items.forEach((item, index) => {
|
|
40
|
+
if (item.heading !== undefined) out.push({ heading: item.heading, entries: [] });
|
|
41
|
+
else out[out.length - 1]!.entries.push({ item, index });
|
|
42
|
+
});
|
|
43
|
+
return out.filter((g) => g.heading !== undefined || g.entries.length > 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const entries = () =>
|
|
47
|
+
Array.from(menu?.querySelectorAll<HTMLElement>('[data-tern-menuitem]:not([aria-disabled="true"])') ?? []).filter((el) => el.closest('[role="menu"]') === menu);
|
|
48
|
+
|
|
49
|
+
$effect(() => {
|
|
50
|
+
if (!menu) return;
|
|
51
|
+
menu.showPopover?.();
|
|
52
|
+
const stop = float(menu, anchor, { side, align, offset: sub ? 2 : 6 });
|
|
53
|
+
const list = entries();
|
|
54
|
+
if (focus === 'first') list[0]?.focus();
|
|
55
|
+
else if (focus === 'last') list[list.length - 1]?.focus();
|
|
56
|
+
return stop;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The root list closes on clicks outside it and everything it opened.
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (sub) return;
|
|
62
|
+
const away = (e: PointerEvent) => {
|
|
63
|
+
const t = e.target as Node;
|
|
64
|
+
if (menu?.contains(t) || inside?.().some((el) => el?.contains(t))) return;
|
|
65
|
+
onclose({ all: true, returnFocus: false });
|
|
66
|
+
};
|
|
67
|
+
document.addEventListener('pointerdown', away, true);
|
|
68
|
+
return () => document.removeEventListener('pointerdown', away, true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function activate(item: MenuItem, index: number, how: 'key' | 'pointer') {
|
|
72
|
+
if (item.disabled) return;
|
|
73
|
+
if (item.items) {
|
|
74
|
+
subFocus = how === 'key' ? 'first' : 'none';
|
|
75
|
+
openSub = index;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
onclose({ all: true, returnFocus: !item.href });
|
|
79
|
+
item.onselect?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onkeydown(e: KeyboardEvent) {
|
|
83
|
+
if (e.target instanceof Node && (e.target as HTMLElement).closest?.('[role="menu"]') !== menu) return; // a submenu's key
|
|
84
|
+
const list = entries();
|
|
85
|
+
const current = list.indexOf(document.activeElement as HTMLElement);
|
|
86
|
+
const move = (j: number) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
list[(j + list.length) % list.length]?.focus();
|
|
89
|
+
};
|
|
90
|
+
const index = Number((document.activeElement as HTMLElement | null)?.dataset.ternMenuitem);
|
|
91
|
+
const item = Number.isNaN(index) ? undefined : items[index];
|
|
92
|
+
switch (e.key) {
|
|
93
|
+
case 'ArrowDown':
|
|
94
|
+
return move(current + 1);
|
|
95
|
+
case 'ArrowUp':
|
|
96
|
+
return move(current < 0 ? list.length - 1 : current - 1);
|
|
97
|
+
case 'Home':
|
|
98
|
+
case 'PageUp':
|
|
99
|
+
return move(0);
|
|
100
|
+
case 'End':
|
|
101
|
+
case 'PageDown':
|
|
102
|
+
return move(list.length - 1);
|
|
103
|
+
case 'Enter':
|
|
104
|
+
case ' ':
|
|
105
|
+
if (item) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (item.href && e.key === 'Enter' && !item.items) return onclose({ all: true, returnFocus: false }); // the link follows itself
|
|
108
|
+
activate(item, index, 'key');
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
case 'ArrowRight':
|
|
112
|
+
if (item?.items && !item.disabled) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
return activate(item, index, 'key');
|
|
115
|
+
}
|
|
116
|
+
return onkey?.(e);
|
|
117
|
+
case 'ArrowLeft':
|
|
118
|
+
if (sub) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
return onclose({ all: false, returnFocus: true });
|
|
121
|
+
}
|
|
122
|
+
return onkey?.(e);
|
|
123
|
+
case 'Escape':
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
return onclose({ all: !sub, returnFocus: true });
|
|
127
|
+
case 'Tab':
|
|
128
|
+
return onclose({ all: true, returnFocus: false });
|
|
129
|
+
}
|
|
130
|
+
if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
131
|
+
// Type to jump to an item.
|
|
132
|
+
typed += e.key.toLowerCase();
|
|
133
|
+
clearTimeout(typedTimer);
|
|
134
|
+
typedTimer = setTimeout(() => (typed = ''), 500);
|
|
135
|
+
const start = typed.length === 1 ? current + 1 : Math.max(current, 0);
|
|
136
|
+
const order = [...list.slice(start), ...list.slice(0, start)];
|
|
137
|
+
order.find((el) => el.dataset.label?.toLowerCase().startsWith(typed))?.focus();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hover(item: MenuItem, index: number, el: HTMLElement) {
|
|
142
|
+
if (item.disabled) return;
|
|
143
|
+
el.focus({ preventScroll: true });
|
|
144
|
+
clearTimeout(hoverTimer);
|
|
145
|
+
if (item.items) hoverTimer = setTimeout(() => activate(item, index, 'pointer'), 120);
|
|
146
|
+
else if (openSub !== null) hoverTimer = setTimeout(() => (openSub = null), 120);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const role = (item: MenuItem) => (item.type === 'checkbox' ? 'menuitemcheckbox' : item.type === 'radio' ? 'menuitemradio' : 'menuitem');
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
bind:this={menu}
|
|
154
|
+
class="tern-menu-list {klass}"
|
|
155
|
+
role="menu"
|
|
156
|
+
{id}
|
|
157
|
+
aria-labelledby={labelledby}
|
|
158
|
+
aria-label={labelledby ? undefined : label}
|
|
159
|
+
tabindex="-1"
|
|
160
|
+
popover="manual"
|
|
161
|
+
{onkeydown}
|
|
162
|
+
>
|
|
163
|
+
{#each groups as group, g (g)}
|
|
164
|
+
{#snippet entriesOf()}
|
|
165
|
+
{#each group.entries as { item, index } (index)}
|
|
166
|
+
{#if item.separator}
|
|
167
|
+
<div class="separator" role="separator"></div>
|
|
168
|
+
{:else}
|
|
169
|
+
<svelte:element
|
|
170
|
+
this={item.href && !item.disabled && !item.items ? 'a' : 'div'}
|
|
171
|
+
bind:this={itemEls[index]}
|
|
172
|
+
href={item.href && !item.disabled && !item.items ? item.href : undefined}
|
|
173
|
+
role={role(item)}
|
|
174
|
+
tabindex="-1"
|
|
175
|
+
class="item"
|
|
176
|
+
class:danger={item.danger}
|
|
177
|
+
data-tern-menuitem={index}
|
|
178
|
+
data-label={item.label}
|
|
179
|
+
aria-disabled={item.disabled || undefined}
|
|
180
|
+
aria-checked={item.type ? !!item.checked : undefined}
|
|
181
|
+
aria-haspopup={item.items ? 'menu' : undefined}
|
|
182
|
+
aria-expanded={item.items ? openSub === index : undefined}
|
|
183
|
+
aria-controls={item.items && openSub === index ? `${id}-sub-${index}` : undefined}
|
|
184
|
+
onclick={(e: MouseEvent) => {
|
|
185
|
+
if (item.href && !item.items && !item.disabled) return onclose({ all: true, returnFocus: false });
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
activate(item, index, 'pointer');
|
|
188
|
+
}}
|
|
189
|
+
onpointerenter={(e: PointerEvent) => hover(item, index, e.currentTarget as HTMLElement)}
|
|
190
|
+
onpointerleave={() => clearTimeout(hoverTimer)}
|
|
191
|
+
>
|
|
192
|
+
<span class="check" aria-hidden="true">
|
|
193
|
+
{#if item.type === 'checkbox' && item.checked}
|
|
194
|
+
<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>
|
|
195
|
+
{:else if item.type === 'radio' && item.checked}
|
|
196
|
+
<svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="3" fill="currentColor" /></svg>
|
|
197
|
+
{/if}
|
|
198
|
+
</span>
|
|
199
|
+
<span class="label">{item.label}</span>
|
|
200
|
+
{#if item.shortcut}<kbd class="shortcut">{item.shortcut}</kbd>{/if}
|
|
201
|
+
{#if item.items}
|
|
202
|
+
<svg class="chevron" aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
203
|
+
{/if}
|
|
204
|
+
</svelte:element>
|
|
205
|
+
{#if item.items && openSub === index && itemEls[index]}
|
|
206
|
+
<MenuList
|
|
207
|
+
items={item.items}
|
|
208
|
+
id="{id}-sub-{index}"
|
|
209
|
+
label={item.label}
|
|
210
|
+
anchor={itemEls[index]}
|
|
211
|
+
side="right"
|
|
212
|
+
focus={subFocus}
|
|
213
|
+
sub
|
|
214
|
+
onclose={(how) => {
|
|
215
|
+
openSub = null;
|
|
216
|
+
if (how.all) onclose(how);
|
|
217
|
+
else if (how.returnFocus) itemEls[index]?.focus();
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
{/if}
|
|
221
|
+
{/if}
|
|
222
|
+
{/each}
|
|
223
|
+
{/snippet}
|
|
224
|
+
{#if group.heading !== undefined}
|
|
225
|
+
<div role="group" aria-labelledby="{id}-group-{g}">
|
|
226
|
+
<div class="heading" id="{id}-group-{g}">{group.heading}</div>
|
|
227
|
+
{@render entriesOf()}
|
|
228
|
+
</div>
|
|
229
|
+
{:else}
|
|
230
|
+
{@render entriesOf()}
|
|
231
|
+
{/if}
|
|
232
|
+
{/each}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<style>
|
|
236
|
+
.tern-menu-list {
|
|
237
|
+
min-width: 12rem;
|
|
238
|
+
max-height: calc(100vh - 1rem);
|
|
239
|
+
overflow: auto;
|
|
240
|
+
padding: 0.3rem;
|
|
241
|
+
color: var(--tern-fg);
|
|
242
|
+
background: var(--tern-surface);
|
|
243
|
+
border: 1px solid var(--tern-border);
|
|
244
|
+
border-radius: var(--tern-radius);
|
|
245
|
+
box-shadow: var(--tern-shadow-lg);
|
|
246
|
+
font-size: 0.875rem;
|
|
247
|
+
outline: none;
|
|
248
|
+
animation: tern-menu-in var(--tern-duration) ease-out;
|
|
249
|
+
}
|
|
250
|
+
.item {
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
gap: 0.5rem;
|
|
254
|
+
padding: 0.4rem 0.6rem 0.4rem 0.35rem;
|
|
255
|
+
border-radius: calc(var(--tern-radius-sm) - 2px);
|
|
256
|
+
color: var(--tern-fg);
|
|
257
|
+
text-decoration: none;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
outline: none;
|
|
260
|
+
user-select: none;
|
|
261
|
+
}
|
|
262
|
+
.item:focus,
|
|
263
|
+
.item[aria-expanded='true'] {
|
|
264
|
+
background: var(--tern-accent-soft);
|
|
265
|
+
}
|
|
266
|
+
.item.danger {
|
|
267
|
+
color: var(--tern-danger);
|
|
268
|
+
}
|
|
269
|
+
.item.danger:focus {
|
|
270
|
+
background: var(--tern-danger-soft);
|
|
271
|
+
}
|
|
272
|
+
.item[aria-disabled='true'] {
|
|
273
|
+
opacity: 0.45;
|
|
274
|
+
cursor: not-allowed;
|
|
275
|
+
background: none;
|
|
276
|
+
}
|
|
277
|
+
.check {
|
|
278
|
+
display: inline-flex;
|
|
279
|
+
width: 1rem;
|
|
280
|
+
justify-content: center;
|
|
281
|
+
color: var(--tern-accent);
|
|
282
|
+
}
|
|
283
|
+
.label {
|
|
284
|
+
flex: 1;
|
|
285
|
+
}
|
|
286
|
+
.shortcut {
|
|
287
|
+
font-family: var(--tern-font-mono);
|
|
288
|
+
font-size: 0.75rem;
|
|
289
|
+
color: var(--tern-muted);
|
|
290
|
+
letter-spacing: 0.04em;
|
|
291
|
+
}
|
|
292
|
+
.chevron {
|
|
293
|
+
color: var(--tern-muted);
|
|
294
|
+
}
|
|
295
|
+
.heading {
|
|
296
|
+
padding: 0.45rem 0.6rem 0.25rem 1.85rem;
|
|
297
|
+
font-size: 0.75rem;
|
|
298
|
+
font-weight: 600;
|
|
299
|
+
color: var(--tern-muted);
|
|
300
|
+
}
|
|
301
|
+
.separator {
|
|
302
|
+
height: 1px;
|
|
303
|
+
margin: 0.3rem 0.2rem;
|
|
304
|
+
background: var(--tern-border);
|
|
305
|
+
}
|
|
306
|
+
@keyframes tern-menu-in {
|
|
307
|
+
from {
|
|
308
|
+
opacity: 0;
|
|
309
|
+
transform: scale(0.97);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
@media (prefers-reduced-motion: reduce) {
|
|
313
|
+
.tern-menu-list {
|
|
314
|
+
animation: none;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import MenuList from './MenuList.svelte';
|
|
2
|
+
import { type Align, type Side } from './floating.js';
|
|
3
|
+
import type { MenuClose, MenuItem } from './menu.js';
|
|
4
|
+
type Props = {
|
|
5
|
+
items: MenuItem[];
|
|
6
|
+
id: string;
|
|
7
|
+
labelledby?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
/** What it opens next to: an element, or a point for context menus. */
|
|
10
|
+
anchor: Element | {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
};
|
|
14
|
+
side?: Side;
|
|
15
|
+
align?: Align;
|
|
16
|
+
/** Which item gets focus when it opens. */
|
|
17
|
+
focus?: 'first' | 'last' | 'none';
|
|
18
|
+
/** Whether this is a submenu, which ArrowLeft closes. */
|
|
19
|
+
sub?: boolean;
|
|
20
|
+
onclose: (how: MenuClose) => void;
|
|
21
|
+
/** Keys the list doesn't use, such as ArrowLeft and ArrowRight in a menubar. */
|
|
22
|
+
onkey?: (e: KeyboardEvent) => void;
|
|
23
|
+
/** Elements whose clicks don't close the menu, such as its button. */
|
|
24
|
+
inside?: () => (Element | null | undefined)[];
|
|
25
|
+
class?: string;
|
|
26
|
+
};
|
|
27
|
+
declare const MenuList: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type MenuList = ReturnType<typeof MenuList>;
|
|
29
|
+
export default MenuList;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import MenuList from './MenuList.svelte';
|
|
3
|
+
import type { MenuItem } from './menu.js';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** The menus, such as File, Edit and View. */
|
|
7
|
+
menus: { label: string; items: MenuItem[] }[];
|
|
8
|
+
/** Names the bar, such as "Editor". */
|
|
9
|
+
label: string;
|
|
10
|
+
class?: string;
|
|
11
|
+
};
|
|
12
|
+
let { menus, label, class: klass = '' }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const uid = $props.id();
|
|
15
|
+
let active = $state(0); // the top item in the tab order
|
|
16
|
+
let open = $state<number | null>(null);
|
|
17
|
+
let focus = $state<'first' | 'last'>('first');
|
|
18
|
+
const buttons: HTMLButtonElement[] = [];
|
|
19
|
+
|
|
20
|
+
function show(i: number, where: 'first' | 'last' = 'first') {
|
|
21
|
+
active = i;
|
|
22
|
+
focus = where;
|
|
23
|
+
open = i;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function step(by: number, keepOpen: boolean) {
|
|
27
|
+
const next = (active + by + menus.length) % menus.length;
|
|
28
|
+
if (keepOpen) show(next);
|
|
29
|
+
else {
|
|
30
|
+
active = next;
|
|
31
|
+
buttons[next]?.focus();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function onbarkey(e: KeyboardEvent, i: number) {
|
|
36
|
+
switch (e.key) {
|
|
37
|
+
case 'ArrowRight':
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
return step(1, false);
|
|
40
|
+
case 'ArrowLeft':
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
return step(-1, false);
|
|
43
|
+
case 'Home':
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
active = 0;
|
|
46
|
+
return buttons[0]?.focus();
|
|
47
|
+
case 'End':
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
active = menus.length - 1;
|
|
50
|
+
return buttons[active]?.focus();
|
|
51
|
+
case 'ArrowDown':
|
|
52
|
+
case 'Enter':
|
|
53
|
+
case ' ':
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
return show(i);
|
|
56
|
+
case 'ArrowUp':
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
return show(i, 'last');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div class="tern-menubar {klass}" role="menubar" aria-label={label}>
|
|
64
|
+
{#each menus as menu, i (i)}
|
|
65
|
+
<button
|
|
66
|
+
bind:this={buttons[i]}
|
|
67
|
+
type="button"
|
|
68
|
+
role="menuitem"
|
|
69
|
+
id="tern-menubar-{uid}-{i}"
|
|
70
|
+
tabindex={active === i ? 0 : -1}
|
|
71
|
+
aria-haspopup="menu"
|
|
72
|
+
aria-expanded={open === i}
|
|
73
|
+
aria-controls={open === i ? `tern-menubar-${uid}-${i}-menu` : undefined}
|
|
74
|
+
onclick={() => (open === i ? (open = null) : show(i))}
|
|
75
|
+
onpointerenter={() => open !== null && open !== i && show(i)}
|
|
76
|
+
onkeydown={(e) => onbarkey(e, i)}
|
|
77
|
+
>
|
|
78
|
+
{menu.label}
|
|
79
|
+
</button>
|
|
80
|
+
{#if open === i && buttons[i]}
|
|
81
|
+
<MenuList
|
|
82
|
+
items={menu.items}
|
|
83
|
+
id="tern-menubar-{uid}-{i}-menu"
|
|
84
|
+
labelledby="tern-menubar-{uid}-{i}"
|
|
85
|
+
anchor={buttons[i]}
|
|
86
|
+
{focus}
|
|
87
|
+
inside={() => buttons}
|
|
88
|
+
onkey={(e) => {
|
|
89
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
step(e.key === 'ArrowRight' ? 1 : -1, true);
|
|
92
|
+
}
|
|
93
|
+
}}
|
|
94
|
+
onclose={(how) => {
|
|
95
|
+
open = null;
|
|
96
|
+
if (how.returnFocus) buttons[i]?.focus();
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
{/if}
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.tern-menubar {
|
|
105
|
+
display: inline-flex;
|
|
106
|
+
gap: 0.125rem;
|
|
107
|
+
padding: 0.2rem;
|
|
108
|
+
background: var(--tern-surface);
|
|
109
|
+
border: 1px solid var(--tern-border);
|
|
110
|
+
border-radius: var(--tern-radius);
|
|
111
|
+
box-shadow: var(--tern-shadow);
|
|
112
|
+
}
|
|
113
|
+
button {
|
|
114
|
+
height: 1.875rem;
|
|
115
|
+
padding: 0 0.7rem;
|
|
116
|
+
font: inherit;
|
|
117
|
+
font-size: 0.875rem;
|
|
118
|
+
font-weight: 550;
|
|
119
|
+
color: var(--tern-fg);
|
|
120
|
+
background: transparent;
|
|
121
|
+
border: 0;
|
|
122
|
+
border-radius: var(--tern-radius-sm);
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
}
|
|
125
|
+
button:hover,
|
|
126
|
+
button[aria-expanded='true'] {
|
|
127
|
+
background: var(--tern-surface-2);
|
|
128
|
+
}
|
|
129
|
+
button:focus-visible {
|
|
130
|
+
outline: 2px solid var(--tern-ring);
|
|
131
|
+
outline-offset: 1px;
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MenuItem } from './menu.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
/** The menus, such as File, Edit and View. */
|
|
4
|
+
menus: {
|
|
5
|
+
label: string;
|
|
6
|
+
items: MenuItem[];
|
|
7
|
+
}[];
|
|
8
|
+
/** Names the bar, such as "Editor". */
|
|
9
|
+
label: string;
|
|
10
|
+
class?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const Menubar: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type Menubar = ReturnType<typeof Menubar>;
|
|
14
|
+
export default Menubar;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '../runtime/state.svelte.js';
|
|
3
|
+
import { float } from './floating.js';
|
|
4
|
+
|
|
5
|
+
type Link = { label: string; href: string; description?: string };
|
|
6
|
+
type Entry = { label: string; href?: string; items?: Link[] };
|
|
7
|
+
type Props = {
|
|
8
|
+
items: Entry[];
|
|
9
|
+
label?: string;
|
|
10
|
+
class?: string;
|
|
11
|
+
};
|
|
12
|
+
let { items, label = 'Main', class: klass = '' }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const uid = $props.id();
|
|
15
|
+
let open = $state<number | null>(null);
|
|
16
|
+
const buttons: HTMLButtonElement[] = [];
|
|
17
|
+
let panel = $state<HTMLDivElement>();
|
|
18
|
+
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
|
|
20
|
+
const current = (href: string) => page.url?.pathname === href;
|
|
21
|
+
|
|
22
|
+
$effect(() => {
|
|
23
|
+
if (open === null || !panel) return;
|
|
24
|
+
panel.showPopover?.();
|
|
25
|
+
const stop = float(panel, buttons[open]!, { side: 'bottom', align: 'start', offset: 8 });
|
|
26
|
+
const away = (e: PointerEvent) => {
|
|
27
|
+
const t = e.target as Node;
|
|
28
|
+
if (!panel?.contains(t) && !buttons.some((b) => b?.contains(t))) open = null;
|
|
29
|
+
};
|
|
30
|
+
document.addEventListener('pointerdown', away, true);
|
|
31
|
+
return () => {
|
|
32
|
+
stop();
|
|
33
|
+
document.removeEventListener('pointerdown', away, true);
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function hover(i: number | null) {
|
|
38
|
+
clearTimeout(hoverTimer);
|
|
39
|
+
if (matchMedia('(hover: none)').matches) return;
|
|
40
|
+
hoverTimer = setTimeout(() => (open = i), i === null ? 250 : 120);
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<!-- Site navigation with dropdown panels: the disclosure pattern, since links don't belong in an ARIA menu. -->
|
|
45
|
+
<nav class="tern-navmenu {klass}" aria-label={label}>
|
|
46
|
+
<ul>
|
|
47
|
+
{#each items as entry, i (i)}
|
|
48
|
+
<li>
|
|
49
|
+
{#if entry.items}
|
|
50
|
+
<button
|
|
51
|
+
bind:this={buttons[i]}
|
|
52
|
+
type="button"
|
|
53
|
+
aria-expanded={open === i}
|
|
54
|
+
aria-controls="tern-navmenu-{uid}-{i}"
|
|
55
|
+
onclick={() => (open = open === i ? null : i)}
|
|
56
|
+
onpointerenter={() => hover(i)}
|
|
57
|
+
onpointerleave={() => hover(null)}
|
|
58
|
+
onkeydown={(e) => {
|
|
59
|
+
if (e.key === 'Escape' && open === i) {
|
|
60
|
+
open = null;
|
|
61
|
+
} else if (e.key === 'ArrowDown' && open === i) {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
panel?.querySelector('a')?.focus();
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{entry.label}
|
|
68
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
69
|
+
</button>
|
|
70
|
+
{#if open === i}
|
|
71
|
+
<div
|
|
72
|
+
bind:this={panel}
|
|
73
|
+
class="panel"
|
|
74
|
+
id="tern-navmenu-{uid}-{i}"
|
|
75
|
+
popover="manual"
|
|
76
|
+
role="none"
|
|
77
|
+
onpointerenter={() => clearTimeout(hoverTimer)}
|
|
78
|
+
onpointerleave={() => hover(null)}
|
|
79
|
+
onkeydown={(e) => {
|
|
80
|
+
if (e.key === 'Escape') {
|
|
81
|
+
open = null;
|
|
82
|
+
buttons[i]?.focus();
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
onfocusout={(e) => {
|
|
86
|
+
const to = e.relatedTarget as Node | null;
|
|
87
|
+
if (to && !panel?.contains(to) && !buttons[i]?.contains(to)) open = null;
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<ul class="links">
|
|
91
|
+
{#each entry.items as link (link.href)}
|
|
92
|
+
<li>
|
|
93
|
+
<a href={link.href} aria-current={current(link.href) ? 'page' : undefined} onclick={() => (open = null)}>
|
|
94
|
+
<span class="title">{link.label}</span>
|
|
95
|
+
{#if link.description}<span class="description">{link.description}</span>{/if}
|
|
96
|
+
</a>
|
|
97
|
+
</li>
|
|
98
|
+
{/each}
|
|
99
|
+
</ul>
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
{:else if entry.href}
|
|
103
|
+
<a class="top" href={entry.href} aria-current={current(entry.href) ? 'page' : undefined}>{entry.label}</a>
|
|
104
|
+
{/if}
|
|
105
|
+
</li>
|
|
106
|
+
{/each}
|
|
107
|
+
</ul>
|
|
108
|
+
</nav>
|
|
109
|
+
|
|
110
|
+
<style>
|
|
111
|
+
ul {
|
|
112
|
+
display: flex;
|
|
113
|
+
gap: 0.125rem;
|
|
114
|
+
margin: 0;
|
|
115
|
+
padding: 0;
|
|
116
|
+
list-style: none;
|
|
117
|
+
}
|
|
118
|
+
button,
|
|
119
|
+
.top {
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: 0.3rem;
|
|
123
|
+
height: 2.125rem;
|
|
124
|
+
padding: 0 0.75rem;
|
|
125
|
+
font: inherit;
|
|
126
|
+
font-size: 0.875rem;
|
|
127
|
+
font-weight: 550;
|
|
128
|
+
color: var(--tern-fg);
|
|
129
|
+
text-decoration: none;
|
|
130
|
+
background: transparent;
|
|
131
|
+
border: 0;
|
|
132
|
+
border-radius: var(--tern-radius-sm);
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
}
|
|
135
|
+
button:hover,
|
|
136
|
+
.top:hover,
|
|
137
|
+
button[aria-expanded='true'] {
|
|
138
|
+
background: var(--tern-surface-2);
|
|
139
|
+
}
|
|
140
|
+
button svg {
|
|
141
|
+
color: var(--tern-muted);
|
|
142
|
+
transition: rotate var(--tern-duration);
|
|
143
|
+
}
|
|
144
|
+
button[aria-expanded='true'] svg {
|
|
145
|
+
rotate: 180deg;
|
|
146
|
+
}
|
|
147
|
+
.top[aria-current='page'] {
|
|
148
|
+
color: var(--tern-accent);
|
|
149
|
+
}
|
|
150
|
+
button:focus-visible,
|
|
151
|
+
a:focus-visible {
|
|
152
|
+
outline: 2px solid var(--tern-ring);
|
|
153
|
+
outline-offset: 1px;
|
|
154
|
+
}
|
|
155
|
+
.panel {
|
|
156
|
+
padding: 0.5rem;
|
|
157
|
+
color: var(--tern-fg);
|
|
158
|
+
background: var(--tern-surface);
|
|
159
|
+
border: 1px solid var(--tern-border);
|
|
160
|
+
border-radius: var(--tern-radius);
|
|
161
|
+
box-shadow: var(--tern-shadow-lg);
|
|
162
|
+
animation: tern-nav-in var(--tern-duration) ease-out;
|
|
163
|
+
}
|
|
164
|
+
.links {
|
|
165
|
+
display: grid;
|
|
166
|
+
grid-template-columns: repeat(2, minmax(12rem, 16rem));
|
|
167
|
+
gap: 0.25rem;
|
|
168
|
+
}
|
|
169
|
+
@media (max-width: 36rem) {
|
|
170
|
+
.links {
|
|
171
|
+
grid-template-columns: minmax(12rem, 1fr);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
.links a {
|
|
175
|
+
display: grid;
|
|
176
|
+
gap: 0.15rem;
|
|
177
|
+
padding: 0.6rem 0.75rem;
|
|
178
|
+
color: var(--tern-fg);
|
|
179
|
+
text-decoration: none;
|
|
180
|
+
border-radius: var(--tern-radius-sm);
|
|
181
|
+
}
|
|
182
|
+
.links a:hover {
|
|
183
|
+
background: var(--tern-surface-2);
|
|
184
|
+
}
|
|
185
|
+
.links a[aria-current='page'] .title {
|
|
186
|
+
color: var(--tern-accent);
|
|
187
|
+
}
|
|
188
|
+
.title {
|
|
189
|
+
font-size: 0.875rem;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
}
|
|
192
|
+
.description {
|
|
193
|
+
font-size: 0.8125rem;
|
|
194
|
+
color: var(--tern-muted);
|
|
195
|
+
line-height: 1.4;
|
|
196
|
+
}
|
|
197
|
+
@keyframes tern-nav-in {
|
|
198
|
+
from {
|
|
199
|
+
opacity: 0;
|
|
200
|
+
transform: translateY(-4px);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
@media (prefers-reduced-motion: reduce) {
|
|
204
|
+
.panel {
|
|
205
|
+
animation: none;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
</style>
|