@ternkit/tern 1.2.1 → 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.
Files changed (170) hide show
  1. package/dist/goscript/index.svelte.d.ts +32 -2
  2. package/dist/goscript/index.svelte.js +257 -31
  3. package/dist/goscript/patch.d.ts +6 -0
  4. package/dist/goscript/patch.js +25 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +2 -1
  7. package/dist/runtime/validate.d.ts +31 -0
  8. package/dist/runtime/validate.js +168 -0
  9. package/dist/server/intl.js +55 -12
  10. package/dist/ui/Accordion.svelte +84 -0
  11. package/dist/ui/Accordion.svelte.d.ts +20 -0
  12. package/dist/ui/Alert.svelte +76 -0
  13. package/dist/ui/Alert.svelte.d.ts +12 -0
  14. package/dist/ui/AlertDialog.svelte +79 -0
  15. package/dist/ui/AlertDialog.svelte.d.ts +24 -0
  16. package/dist/ui/AreaChart.svelte +9 -0
  17. package/dist/ui/AreaChart.svelte.d.ts +26 -0
  18. package/dist/ui/AspectRatio.svelte +33 -0
  19. package/dist/ui/AspectRatio.svelte.d.ts +10 -0
  20. package/dist/ui/Avatar.svelte +48 -0
  21. package/dist/ui/Avatar.svelte.d.ts +10 -0
  22. package/dist/ui/Badge.svelte +54 -0
  23. package/dist/ui/Badge.svelte.d.ts +10 -0
  24. package/dist/ui/BarChart.svelte +170 -0
  25. package/dist/ui/BarChart.svelte.d.ts +40 -0
  26. package/dist/ui/Breadcrumb.svelte +92 -0
  27. package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
  28. package/dist/ui/Button.svelte +136 -0
  29. package/dist/ui/Button.svelte.d.ts +18 -0
  30. package/dist/ui/ButtonGroup.svelte +61 -0
  31. package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
  32. package/dist/ui/Calendar.svelte +265 -0
  33. package/dist/ui/Calendar.svelte.d.ts +28 -0
  34. package/dist/ui/Card.svelte +74 -0
  35. package/dist/ui/Card.svelte.d.ts +18 -0
  36. package/dist/ui/Carousel.svelte +169 -0
  37. package/dist/ui/Carousel.svelte.d.ts +36 -0
  38. package/dist/ui/ChartFrame.svelte +174 -0
  39. package/dist/ui/ChartFrame.svelte.d.ts +24 -0
  40. package/dist/ui/Checkbox.svelte +112 -0
  41. package/dist/ui/Checkbox.svelte.d.ts +14 -0
  42. package/dist/ui/Collapsible.svelte +59 -0
  43. package/dist/ui/Collapsible.svelte.d.ts +12 -0
  44. package/dist/ui/Combobox.svelte +350 -0
  45. package/dist/ui/Combobox.svelte.d.ts +24 -0
  46. package/dist/ui/Command.svelte +268 -0
  47. package/dist/ui/Command.svelte.d.ts +28 -0
  48. package/dist/ui/ConsentScreen.svelte +125 -0
  49. package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
  50. package/dist/ui/ContextMenu.svelte +78 -0
  51. package/dist/ui/ContextMenu.svelte.d.ts +13 -0
  52. package/dist/ui/DataTable.svelte +277 -0
  53. package/dist/ui/DataTable.svelte.d.ts +53 -0
  54. package/dist/ui/DatePicker.svelte +138 -0
  55. package/dist/ui/DatePicker.svelte.d.ts +25 -0
  56. package/dist/ui/Dialog.svelte +301 -0
  57. package/dist/ui/Dialog.svelte.d.ts +30 -0
  58. package/dist/ui/DonutChart.svelte +115 -0
  59. package/dist/ui/DonutChart.svelte.d.ts +40 -0
  60. package/dist/ui/Drawer.svelte +19 -0
  61. package/dist/ui/Drawer.svelte.d.ts +14 -0
  62. package/dist/ui/Empty.svelte +62 -0
  63. package/dist/ui/Empty.svelte.d.ts +15 -0
  64. package/dist/ui/Field.svelte +97 -0
  65. package/dist/ui/Field.svelte.d.ts +19 -0
  66. package/dist/ui/Form.svelte +159 -0
  67. package/dist/ui/Form.svelte.d.ts +21 -0
  68. package/dist/ui/HoverCard.svelte +106 -0
  69. package/dist/ui/HoverCard.svelte.d.ts +26 -0
  70. package/dist/ui/Input.svelte +62 -0
  71. package/dist/ui/Input.svelte.d.ts +7 -0
  72. package/dist/ui/InputGroup.svelte +80 -0
  73. package/dist/ui/InputGroup.svelte.d.ts +13 -0
  74. package/dist/ui/InputOTP.svelte +140 -0
  75. package/dist/ui/InputOTP.svelte.d.ts +18 -0
  76. package/dist/ui/Item.svelte +86 -0
  77. package/dist/ui/Item.svelte.d.ts +17 -0
  78. package/dist/ui/Kbd.svelte +24 -0
  79. package/dist/ui/Kbd.svelte.d.ts +7 -0
  80. package/dist/ui/Label.svelte +29 -0
  81. package/dist/ui/Label.svelte.d.ts +10 -0
  82. package/dist/ui/LineChart.svelte +171 -0
  83. package/dist/ui/LineChart.svelte.d.ts +42 -0
  84. package/dist/ui/Menu.svelte +103 -0
  85. package/dist/ui/Menu.svelte.d.ts +15 -0
  86. package/dist/ui/MenuList.svelte +317 -0
  87. package/dist/ui/MenuList.svelte.d.ts +29 -0
  88. package/dist/ui/Menubar.svelte +133 -0
  89. package/dist/ui/Menubar.svelte.d.ts +14 -0
  90. package/dist/ui/NavigationMenu.svelte +208 -0
  91. package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
  92. package/dist/ui/Pagination.svelte +114 -0
  93. package/dist/ui/Pagination.svelte.d.ts +16 -0
  94. package/dist/ui/PasswordInput.svelte +134 -0
  95. package/dist/ui/PasswordInput.svelte.d.ts +11 -0
  96. package/dist/ui/Popover.svelte +127 -0
  97. package/dist/ui/Popover.svelte.d.ts +29 -0
  98. package/dist/ui/Progress.svelte +73 -0
  99. package/dist/ui/Progress.svelte.d.ts +12 -0
  100. package/dist/ui/RadioGroup.svelte +105 -0
  101. package/dist/ui/RadioGroup.svelte.d.ts +21 -0
  102. package/dist/ui/Resizable.svelte +122 -0
  103. package/dist/ui/Resizable.svelte.d.ts +16 -0
  104. package/dist/ui/ScrollArea.svelte +43 -0
  105. package/dist/ui/ScrollArea.svelte.d.ts +13 -0
  106. package/dist/ui/Select.svelte +77 -0
  107. package/dist/ui/Select.svelte.d.ts +18 -0
  108. package/dist/ui/SelectMenu.svelte +301 -0
  109. package/dist/ui/SelectMenu.svelte.d.ts +18 -0
  110. package/dist/ui/Separator.svelte +19 -0
  111. package/dist/ui/Separator.svelte.d.ts +7 -0
  112. package/dist/ui/Sheet.svelte +21 -0
  113. package/dist/ui/Sheet.svelte.d.ts +16 -0
  114. package/dist/ui/Sidebar.svelte +263 -0
  115. package/dist/ui/Sidebar.svelte.d.ts +35 -0
  116. package/dist/ui/SignIn.svelte +182 -0
  117. package/dist/ui/SignIn.svelte.d.ts +38 -0
  118. package/dist/ui/SignUp.svelte +106 -0
  119. package/dist/ui/SignUp.svelte.d.ts +23 -0
  120. package/dist/ui/Skeleton.svelte +29 -0
  121. package/dist/ui/Skeleton.svelte.d.ts +9 -0
  122. package/dist/ui/Slider.svelte +196 -0
  123. package/dist/ui/Slider.svelte.d.ts +19 -0
  124. package/dist/ui/Sparkline.svelte +23 -0
  125. package/dist/ui/Sparkline.svelte.d.ts +11 -0
  126. package/dist/ui/Spinner.svelte +42 -0
  127. package/dist/ui/Spinner.svelte.d.ts +8 -0
  128. package/dist/ui/Stat.svelte +91 -0
  129. package/dist/ui/Stat.svelte.d.ts +18 -0
  130. package/dist/ui/Switch.svelte +90 -0
  131. package/dist/ui/Switch.svelte.d.ts +12 -0
  132. package/dist/ui/Table.svelte +70 -0
  133. package/dist/ui/Table.svelte.d.ts +36 -0
  134. package/dist/ui/Tabs.svelte +112 -0
  135. package/dist/ui/Tabs.svelte.d.ts +19 -0
  136. package/dist/ui/Textarea.svelte +52 -0
  137. package/dist/ui/Textarea.svelte.d.ts +7 -0
  138. package/dist/ui/Toaster.svelte +146 -0
  139. package/dist/ui/Toaster.svelte.d.ts +6 -0
  140. package/dist/ui/Toggle.svelte +82 -0
  141. package/dist/ui/Toggle.svelte.d.ts +17 -0
  142. package/dist/ui/ToggleGroup.svelte +143 -0
  143. package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
  144. package/dist/ui/Tooltip.svelte +85 -0
  145. package/dist/ui/Tooltip.svelte.d.ts +16 -0
  146. package/dist/ui/chart.d.ts +26 -0
  147. package/dist/ui/chart.js +89 -0
  148. package/dist/ui/dates.d.ts +13 -0
  149. package/dist/ui/dates.js +64 -0
  150. package/dist/ui/field.d.ts +18 -0
  151. package/dist/ui/field.js +8 -0
  152. package/dist/ui/floating.d.ts +44 -0
  153. package/dist/ui/floating.js +97 -0
  154. package/dist/ui/form.d.ts +12 -0
  155. package/dist/ui/form.js +8 -0
  156. package/dist/ui/index.d.ts +78 -0
  157. package/dist/ui/index.js +70 -0
  158. package/dist/ui/menu.d.ts +25 -0
  159. package/dist/ui/menu.js +1 -0
  160. package/dist/ui/options.d.ts +17 -0
  161. package/dist/ui/options.js +22 -0
  162. package/dist/ui/table.d.ts +26 -0
  163. package/dist/ui/table.js +1 -0
  164. package/dist/ui/theme.css +176 -0
  165. package/dist/ui/toast.svelte.d.ts +19 -0
  166. package/dist/ui/toast.svelte.js +18 -0
  167. package/dist/vite/goscript.d.ts +14 -0
  168. package/dist/vite/goscript.js +45 -16
  169. package/dist/vite/index.js +15 -4
  170. package/package.json +7 -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>