@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.
Files changed (131) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +2 -1
  3. package/dist/runtime/deferred.d.ts +5 -1
  4. package/dist/runtime/deferred.js +27 -11
  5. package/dist/runtime/validate.d.ts +31 -0
  6. package/dist/runtime/validate.js +168 -0
  7. package/dist/server/intl.js +55 -12
  8. package/dist/ui/AlertDialog.svelte +79 -0
  9. package/dist/ui/AlertDialog.svelte.d.ts +24 -0
  10. package/dist/ui/AreaChart.svelte +9 -0
  11. package/dist/ui/AreaChart.svelte.d.ts +26 -0
  12. package/dist/ui/AspectRatio.svelte +33 -0
  13. package/dist/ui/AspectRatio.svelte.d.ts +10 -0
  14. package/dist/ui/BarChart.svelte +170 -0
  15. package/dist/ui/BarChart.svelte.d.ts +40 -0
  16. package/dist/ui/Breadcrumb.svelte +92 -0
  17. package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
  18. package/dist/ui/ButtonGroup.svelte +61 -0
  19. package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
  20. package/dist/ui/Calendar.svelte +265 -0
  21. package/dist/ui/Calendar.svelte.d.ts +28 -0
  22. package/dist/ui/Carousel.svelte +169 -0
  23. package/dist/ui/Carousel.svelte.d.ts +36 -0
  24. package/dist/ui/ChartFrame.svelte +174 -0
  25. package/dist/ui/ChartFrame.svelte.d.ts +24 -0
  26. package/dist/ui/Checkbox.svelte +13 -1
  27. package/dist/ui/Collapsible.svelte +59 -0
  28. package/dist/ui/Collapsible.svelte.d.ts +12 -0
  29. package/dist/ui/Combobox.svelte +350 -0
  30. package/dist/ui/Combobox.svelte.d.ts +24 -0
  31. package/dist/ui/Command.svelte +268 -0
  32. package/dist/ui/Command.svelte.d.ts +28 -0
  33. package/dist/ui/ConsentScreen.svelte +125 -0
  34. package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
  35. package/dist/ui/ContextMenu.svelte +78 -0
  36. package/dist/ui/ContextMenu.svelte.d.ts +13 -0
  37. package/dist/ui/DataTable.svelte +277 -0
  38. package/dist/ui/DataTable.svelte.d.ts +53 -0
  39. package/dist/ui/DatePicker.svelte +138 -0
  40. package/dist/ui/DatePicker.svelte.d.ts +25 -0
  41. package/dist/ui/Dialog.svelte +168 -27
  42. package/dist/ui/Dialog.svelte.d.ts +10 -0
  43. package/dist/ui/DonutChart.svelte +115 -0
  44. package/dist/ui/DonutChart.svelte.d.ts +40 -0
  45. package/dist/ui/Drawer.svelte +19 -0
  46. package/dist/ui/Drawer.svelte.d.ts +14 -0
  47. package/dist/ui/Empty.svelte +62 -0
  48. package/dist/ui/Empty.svelte.d.ts +15 -0
  49. package/dist/ui/Field.svelte +22 -2
  50. package/dist/ui/Field.svelte.d.ts +2 -0
  51. package/dist/ui/Form.svelte +159 -0
  52. package/dist/ui/Form.svelte.d.ts +21 -0
  53. package/dist/ui/HoverCard.svelte +106 -0
  54. package/dist/ui/HoverCard.svelte.d.ts +26 -0
  55. package/dist/ui/Input.svelte +2 -1
  56. package/dist/ui/InputGroup.svelte +80 -0
  57. package/dist/ui/InputGroup.svelte.d.ts +13 -0
  58. package/dist/ui/InputOTP.svelte +140 -0
  59. package/dist/ui/InputOTP.svelte.d.ts +18 -0
  60. package/dist/ui/Item.svelte +86 -0
  61. package/dist/ui/Item.svelte.d.ts +17 -0
  62. package/dist/ui/Label.svelte +29 -0
  63. package/dist/ui/Label.svelte.d.ts +10 -0
  64. package/dist/ui/LineChart.svelte +171 -0
  65. package/dist/ui/LineChart.svelte.d.ts +42 -0
  66. package/dist/ui/Menu.svelte +22 -151
  67. package/dist/ui/Menu.svelte.d.ts +3 -10
  68. package/dist/ui/MenuList.svelte +317 -0
  69. package/dist/ui/MenuList.svelte.d.ts +29 -0
  70. package/dist/ui/Menubar.svelte +133 -0
  71. package/dist/ui/Menubar.svelte.d.ts +14 -0
  72. package/dist/ui/NavigationMenu.svelte +208 -0
  73. package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
  74. package/dist/ui/Pagination.svelte +114 -0
  75. package/dist/ui/Pagination.svelte.d.ts +16 -0
  76. package/dist/ui/PasswordInput.svelte +134 -0
  77. package/dist/ui/PasswordInput.svelte.d.ts +11 -0
  78. package/dist/ui/Popover.svelte +127 -0
  79. package/dist/ui/Popover.svelte.d.ts +29 -0
  80. package/dist/ui/Resizable.svelte +122 -0
  81. package/dist/ui/Resizable.svelte.d.ts +16 -0
  82. package/dist/ui/ScrollArea.svelte +43 -0
  83. package/dist/ui/ScrollArea.svelte.d.ts +13 -0
  84. package/dist/ui/Select.svelte +3 -2
  85. package/dist/ui/SelectMenu.svelte +301 -0
  86. package/dist/ui/SelectMenu.svelte.d.ts +18 -0
  87. package/dist/ui/Sheet.svelte +21 -0
  88. package/dist/ui/Sheet.svelte.d.ts +16 -0
  89. package/dist/ui/Sidebar.svelte +263 -0
  90. package/dist/ui/Sidebar.svelte.d.ts +35 -0
  91. package/dist/ui/SignIn.svelte +182 -0
  92. package/dist/ui/SignIn.svelte.d.ts +38 -0
  93. package/dist/ui/SignUp.svelte +106 -0
  94. package/dist/ui/SignUp.svelte.d.ts +23 -0
  95. package/dist/ui/Slider.svelte +196 -0
  96. package/dist/ui/Slider.svelte.d.ts +19 -0
  97. package/dist/ui/Sparkline.svelte +23 -0
  98. package/dist/ui/Sparkline.svelte.d.ts +11 -0
  99. package/dist/ui/Stat.svelte +91 -0
  100. package/dist/ui/Stat.svelte.d.ts +18 -0
  101. package/dist/ui/Table.svelte +70 -0
  102. package/dist/ui/Table.svelte.d.ts +36 -0
  103. package/dist/ui/Textarea.svelte +2 -1
  104. package/dist/ui/Toggle.svelte +82 -0
  105. package/dist/ui/Toggle.svelte.d.ts +17 -0
  106. package/dist/ui/ToggleGroup.svelte +143 -0
  107. package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
  108. package/dist/ui/Tooltip.svelte +27 -19
  109. package/dist/ui/Tooltip.svelte.d.ts +2 -1
  110. package/dist/ui/chart.d.ts +26 -0
  111. package/dist/ui/chart.js +89 -0
  112. package/dist/ui/dates.d.ts +13 -0
  113. package/dist/ui/dates.js +64 -0
  114. package/dist/ui/field.d.ts +8 -0
  115. package/dist/ui/floating.d.ts +44 -0
  116. package/dist/ui/floating.js +97 -0
  117. package/dist/ui/form.d.ts +12 -0
  118. package/dist/ui/form.js +8 -0
  119. package/dist/ui/index.d.ts +54 -0
  120. package/dist/ui/index.js +43 -0
  121. package/dist/ui/menu.d.ts +25 -0
  122. package/dist/ui/menu.js +1 -0
  123. package/dist/ui/options.d.ts +17 -0
  124. package/dist/ui/options.js +22 -0
  125. package/dist/ui/table.d.ts +26 -0
  126. package/dist/ui/table.js +1 -0
  127. package/dist/ui/theme.css +25 -0
  128. package/dist/vite/index.js +12 -3
  129. package/dist/vite/native.d.ts +3 -0
  130. package/dist/vite/native.js +122 -0
  131. package/package.json +3 -2
@@ -0,0 +1,301 @@
1
+ <script lang="ts">
2
+ import { getField } from './field.js';
3
+ import { float } from './floating.js';
4
+ import { grouped, type Option } from './options.js';
5
+
6
+ type Props = {
7
+ options: Option[];
8
+ /** The chosen value; bind it. */
9
+ value?: string | null;
10
+ /** Shown when nothing is chosen. */
11
+ placeholder?: string;
12
+ /** Labels it when it isn't in a Field. */
13
+ label?: string;
14
+ /** Posts the value under this name. */
15
+ name?: string;
16
+ disabled?: boolean;
17
+ class?: string;
18
+ onchange?: (value: string, option: Option) => void;
19
+ };
20
+ const field = getField();
21
+ let { options, value = $bindable(field?.value ?? null), placeholder = 'Choose…', label, name: nameProp, disabled = false, class: klass = '', onchange }: Props = $props();
22
+ const name = $derived(nameProp ?? field?.name);
23
+
24
+ const uid = $props.id();
25
+ const id = `tern-selectmenu-${uid}`;
26
+ field?.custom();
27
+ let button = $state<HTMLButtonElement>();
28
+ let list = $state<HTMLDivElement>();
29
+ let open = $state(false);
30
+ let active = $state(-1);
31
+ let typed = '';
32
+ let typedTimer: ReturnType<typeof setTimeout> | undefined;
33
+
34
+ const selectedIndex = $derived(options.findIndex((o) => o.value === value));
35
+ const selected = $derived(options[selectedIndex]);
36
+ const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0));
37
+
38
+ function show() {
39
+ if (disabled || field?.disabled) return;
40
+ active = selectedIndex >= 0 && !options[selectedIndex]?.disabled ? selectedIndex : (enabledIdx[0] ?? -1);
41
+ open = true;
42
+ }
43
+ function choose(i: number) {
44
+ const o = options[i];
45
+ if (!o || o.disabled) return;
46
+ value = o.value;
47
+ open = false;
48
+ onchange?.(o.value, o);
49
+ button?.focus();
50
+ }
51
+ function step(by: number | 'first' | 'last') {
52
+ const list = enabledIdx;
53
+ if (!list.length) return;
54
+ const pos = list.indexOf(active);
55
+ const next = by === 'first' ? 0 : by === 'last' ? list.length - 1 : Math.max(0, Math.min(list.length - 1, (pos < 0 ? 0 : pos) + by));
56
+ active = list[next]!;
57
+ document.getElementById(`${id}-opt-${active}`)?.scrollIntoView?.({ block: 'nearest' });
58
+ }
59
+
60
+ function onkeydown(e: KeyboardEvent) {
61
+ const opening = ['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'];
62
+ if (!open) {
63
+ if (opening.includes(e.key)) {
64
+ e.preventDefault();
65
+ show();
66
+ if (e.key === 'Home') step('first');
67
+ if (e.key === 'End') step('last');
68
+ return;
69
+ }
70
+ } else {
71
+ switch (e.key) {
72
+ case 'ArrowDown':
73
+ e.preventDefault();
74
+ return e.altKey ? choose(active) : step(1);
75
+ case 'ArrowUp':
76
+ e.preventDefault();
77
+ return e.altKey ? choose(active) : step(-1);
78
+ case 'Home':
79
+ e.preventDefault();
80
+ return step('first');
81
+ case 'End':
82
+ e.preventDefault();
83
+ return step('last');
84
+ case 'PageDown':
85
+ e.preventDefault();
86
+ return step(10);
87
+ case 'PageUp':
88
+ e.preventDefault();
89
+ return step(-10);
90
+ case 'Enter':
91
+ case ' ':
92
+ if (e.key === ' ' && typed) break; // a space inside a typed name
93
+ e.preventDefault();
94
+ return choose(active);
95
+ case 'Escape':
96
+ e.preventDefault();
97
+ e.stopPropagation();
98
+ open = false;
99
+ return;
100
+ case 'Tab':
101
+ open = false;
102
+ return;
103
+ }
104
+ }
105
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
106
+ // Type to jump, open or closed, like a native select.
107
+ e.preventDefault();
108
+ typed += e.key.toLowerCase();
109
+ clearTimeout(typedTimer);
110
+ typedTimer = setTimeout(() => (typed = ''), 500);
111
+ const from = open ? active : selectedIndex;
112
+ const start = typed.length === 1 ? from + 1 : Math.max(from, 0);
113
+ const order = [...enabledIdx.filter((i) => i >= start), ...enabledIdx.filter((i) => i < start)];
114
+ const hit = order.find((i) => options[i]!.label.toLowerCase().startsWith(typed));
115
+ if (hit === undefined) return;
116
+ if (open) active = hit;
117
+ else choose(hit);
118
+ }
119
+ }
120
+
121
+ $effect(() => {
122
+ if (!open || !list || !button) return;
123
+ list.showPopover?.();
124
+ const stop = float(list, button, { side: 'bottom', align: 'start', offset: 4, matchWidth: true });
125
+ document.getElementById(`${id}-opt-${active}`)?.scrollIntoView?.({ block: 'nearest' });
126
+ const away = (e: PointerEvent) => {
127
+ const t = e.target as Node;
128
+ if (!list?.contains(t) && !button?.contains(t)) open = false;
129
+ };
130
+ document.addEventListener('pointerdown', away, true);
131
+ return () => {
132
+ stop();
133
+ document.removeEventListener('pointerdown', away, true);
134
+ };
135
+ });
136
+ </script>
137
+
138
+ <button
139
+ bind:this={button}
140
+ type="button"
141
+ class="tern-selectmenu {klass}"
142
+ role="combobox"
143
+ id={field?.id ?? id}
144
+ aria-haspopup="listbox"
145
+ aria-expanded={open}
146
+ aria-controls="{id}-list"
147
+ aria-activedescendant={open && active >= 0 ? `${id}-opt-${active}` : undefined}
148
+ aria-label={field ? undefined : label}
149
+ aria-labelledby={field ? `${field.labelId} ${id}-value` : undefined}
150
+ aria-describedby={field?.describedBy}
151
+ aria-invalid={field?.invalid || undefined}
152
+ aria-required={field?.required || undefined}
153
+ disabled={disabled || field?.disabled}
154
+ onclick={() => (open ? (open = false) : show())}
155
+ {onkeydown}
156
+ onblur={(e) => !list?.contains(e.relatedTarget as Node) && (open = false)}
157
+ >
158
+ <span class="value" class:placeholder={!selected} id="{id}-value">{selected?.label ?? placeholder}</span>
159
+ <svg aria-hidden="true" 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>
160
+ </button>
161
+ {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
162
+ <div bind:this={list} id="{id}-list" class="tern-selectmenu-list" class:open role="listbox" aria-label={label} aria-labelledby={field?.labelId} tabindex="-1" popover="manual">
163
+ {#if open}
164
+ {#each grouped(options) as g, gi (gi)}
165
+ {#snippet items()}
166
+ {#each g.options as o (o.value)}
167
+ {@const i = options.indexOf(o)}
168
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
169
+ <div
170
+ id="{id}-opt-{i}"
171
+ class="option"
172
+ class:active={i === active}
173
+ role="option"
174
+ tabindex="-1"
175
+ aria-selected={o.value === value}
176
+ aria-disabled={o.disabled || undefined}
177
+ onpointerdown={(e) => e.preventDefault()}
178
+ onpointermove={() => !o.disabled && (active = i)}
179
+ onclick={() => choose(i)}
180
+ >
181
+ <span class="check" aria-hidden="true">
182
+ {#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}
183
+ </span>
184
+ <span class="text">
185
+ <span>{o.label}</span>
186
+ {#if o.description}<span class="description">{o.description}</span>{/if}
187
+ </span>
188
+ </div>
189
+ {/each}
190
+ {/snippet}
191
+ {#if g.group}
192
+ <div role="group" aria-labelledby="{id}-group-{gi}">
193
+ <div class="heading" id="{id}-group-{gi}" role="presentation">{g.group}</div>
194
+ {@render items()}
195
+ </div>
196
+ {:else}
197
+ {@render items()}
198
+ {/if}
199
+ {/each}
200
+ {/if}
201
+ </div>
202
+
203
+ <style>
204
+ .tern-selectmenu {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ gap: 0.5rem;
209
+ width: 100%;
210
+ height: var(--tern-control-height);
211
+ padding: 0 0.6rem 0 0.75rem;
212
+ font: inherit;
213
+ font-size: 0.875rem;
214
+ color: var(--tern-fg);
215
+ text-align: start;
216
+ background: var(--tern-surface);
217
+ border: 1px solid var(--tern-border-strong);
218
+ border-radius: var(--tern-radius-sm);
219
+ cursor: pointer;
220
+ transition:
221
+ border-color var(--tern-duration),
222
+ box-shadow var(--tern-duration);
223
+ }
224
+ .tern-selectmenu svg {
225
+ flex: none;
226
+ color: var(--tern-muted);
227
+ }
228
+ .tern-selectmenu:hover:not(:disabled) {
229
+ border-color: var(--tern-muted);
230
+ }
231
+ .tern-selectmenu:focus-visible {
232
+ outline: none;
233
+ border-color: var(--tern-accent);
234
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--tern-ring) 30%, transparent);
235
+ }
236
+ .tern-selectmenu[aria-invalid='true'] {
237
+ border-color: var(--tern-danger);
238
+ }
239
+ .tern-selectmenu:disabled {
240
+ opacity: 0.6;
241
+ cursor: not-allowed;
242
+ }
243
+ .value {
244
+ overflow: hidden;
245
+ text-overflow: ellipsis;
246
+ white-space: nowrap;
247
+ }
248
+ .placeholder {
249
+ color: var(--tern-muted);
250
+ }
251
+ .tern-selectmenu-list {
252
+ max-height: 18rem;
253
+ overflow: auto;
254
+ padding: 0.3rem;
255
+ color: var(--tern-fg);
256
+ background: var(--tern-surface);
257
+ border: 1px solid var(--tern-border);
258
+ border-radius: var(--tern-radius);
259
+ box-shadow: var(--tern-shadow-lg);
260
+ font-size: 0.875rem;
261
+ outline: none;
262
+ }
263
+ .tern-selectmenu-list:not(.open) {
264
+ display: none;
265
+ }
266
+ .option {
267
+ display: flex;
268
+ align-items: flex-start;
269
+ gap: 0.4rem;
270
+ padding: 0.4rem 0.6rem 0.4rem 0.35rem;
271
+ border-radius: calc(var(--tern-radius-sm) - 2px);
272
+ cursor: pointer;
273
+ }
274
+ .option.active {
275
+ background: var(--tern-accent-soft);
276
+ }
277
+ .option[aria-disabled='true'] {
278
+ opacity: 0.45;
279
+ cursor: not-allowed;
280
+ }
281
+ .check {
282
+ display: inline-flex;
283
+ width: 1rem;
284
+ padding-top: 0.15rem;
285
+ justify-content: center;
286
+ color: var(--tern-accent);
287
+ }
288
+ .text {
289
+ display: grid;
290
+ }
291
+ .description {
292
+ font-size: 0.8125rem;
293
+ color: var(--tern-muted);
294
+ }
295
+ .heading {
296
+ padding: 0.45rem 0.6rem 0.25rem 1.75rem;
297
+ font-size: 0.75rem;
298
+ font-weight: 600;
299
+ color: var(--tern-muted);
300
+ }
301
+ </style>
@@ -0,0 +1,18 @@
1
+ import { type Option } from './options.js';
2
+ type Props = {
3
+ options: Option[];
4
+ /** The chosen value; bind it. */
5
+ value?: string | null;
6
+ /** Shown when nothing is chosen. */
7
+ placeholder?: string;
8
+ /** Labels it when it isn't in a Field. */
9
+ label?: string;
10
+ /** Posts the value under this name. */
11
+ name?: string;
12
+ disabled?: boolean;
13
+ class?: string;
14
+ onchange?: (value: string, option: Option) => void;
15
+ };
16
+ declare const SelectMenu: import("svelte").Component<Props, {}, "value">;
17
+ type SelectMenu = ReturnType<typeof SelectMenu>;
18
+ export default SelectMenu;
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Dialog from './Dialog.svelte';
4
+
5
+ type Props = {
6
+ /** Whether it's open; bind it. */
7
+ open?: boolean;
8
+ title: string;
9
+ description?: string;
10
+ /** The edge it slides in from. */
11
+ side?: 'left' | 'right' | 'top' | 'bottom';
12
+ class?: string;
13
+ children?: Snippet;
14
+ footer?: Snippet;
15
+ onclose?: () => void;
16
+ };
17
+ let { open = $bindable(false), side = 'right', ...rest }: Props = $props();
18
+ </script>
19
+
20
+ <!-- A modal panel against an edge of the screen, for navigation, filters or details. -->
21
+ <Dialog bind:open {side} {...rest} />
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Props = {
3
+ /** Whether it's open; bind it. */
4
+ open?: boolean;
5
+ title: string;
6
+ description?: string;
7
+ /** The edge it slides in from. */
8
+ side?: 'left' | 'right' | 'top' | 'bottom';
9
+ class?: string;
10
+ children?: Snippet;
11
+ footer?: Snippet;
12
+ onclose?: () => void;
13
+ };
14
+ declare const Sheet: import("svelte").Component<Props, {}, "open">;
15
+ type Sheet = ReturnType<typeof Sheet>;
16
+ export default Sheet;
@@ -0,0 +1,263 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { page } from '../runtime/state.svelte.js';
4
+ import Dialog from './Dialog.svelte';
5
+
6
+ export type SidebarLink = {
7
+ label: string;
8
+ href: string;
9
+ /** An icon, shown alone when the sidebar is collapsed. */
10
+ icon?: Snippet;
11
+ /** A count or label after the link, such as "3". */
12
+ badge?: string;
13
+ /** Links under this one, shown while it or one of them is current. */
14
+ items?: { label: string; href: string }[];
15
+ };
16
+ export type SidebarGroup = { label?: string; items: SidebarLink[] };
17
+
18
+ type Props = {
19
+ groups: SidebarGroup[];
20
+ /** The app's name or logo, at the top. */
21
+ header?: Snippet;
22
+ /** Things at the bottom, such as the signed-in user. */
23
+ footer?: Snippet;
24
+ /** Show icons only on wide screens; bind it. */
25
+ collapsed?: boolean;
26
+ /** Names the navigation. */
27
+ label?: string;
28
+ /** The page's content, next to the sidebar. */
29
+ children: Snippet;
30
+ class?: string;
31
+ };
32
+ let { groups, header, footer, collapsed = $bindable(false), label = 'Main', children, class: klass = '' }: Props = $props();
33
+
34
+ let mobileOpen = $state(false);
35
+ const path = $derived(page.url?.pathname ?? '');
36
+ const isCurrent = (href: string) => path === href;
37
+ const inSection = (l: SidebarLink) => isCurrent(l.href) || !!l.items?.some((s) => path === s.href || path.startsWith(s.href + '/'));
38
+
39
+ // Close the mobile sheet after navigating.
40
+ $effect(() => {
41
+ void path;
42
+ mobileOpen = false;
43
+ });
44
+ </script>
45
+
46
+ {#snippet nav(compact: boolean)}
47
+ <nav aria-label={label} class="nav" class:compact>
48
+ {#each groups as group, gi (gi)}
49
+ <div class="group">
50
+ {#if group.label && !compact}<div class="group-label" id="tern-sidebar-group-{gi}">{group.label}</div>{/if}
51
+ <ul aria-labelledby={group.label && !compact ? `tern-sidebar-group-${gi}` : undefined}>
52
+ {#each group.items as link (link.href)}
53
+ <li>
54
+ <a href={link.href} class="link" aria-current={isCurrent(link.href) ? 'page' : undefined} aria-label={compact ? link.label : undefined} title={compact ? link.label : undefined}>
55
+ {#if link.icon}<span class="icon" aria-hidden="true">{@render link.icon()}</span>{/if}
56
+ {#if !compact}
57
+ <span class="label">{link.label}</span>
58
+ {#if link.badge}<span class="badge">{link.badge}</span>{/if}
59
+ {/if}
60
+ </a>
61
+ {#if link.items && !compact && inSection(link)}
62
+ <ul class="sub">
63
+ {#each link.items as sub (sub.href)}
64
+ <li><a href={sub.href} class="link" aria-current={isCurrent(sub.href) ? 'page' : undefined}>{sub.label}</a></li>
65
+ {/each}
66
+ </ul>
67
+ {/if}
68
+ </li>
69
+ {/each}
70
+ </ul>
71
+ </div>
72
+ {/each}
73
+ </nav>
74
+ {/snippet}
75
+
76
+ <div class="tern-sidebar-layout {klass}" class:collapsed>
77
+ <aside class="sidebar">
78
+ <div class="top">
79
+ {#if header && !collapsed}<div class="header">{@render header()}</div>{/if}
80
+ <button type="button" class="collapse" aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} aria-pressed={collapsed} onclick={() => (collapsed = !collapsed)}>
81
+ <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="2.5" width="12" height="11" rx="2" /><path d="M6 2.5v11" /></svg>
82
+ </button>
83
+ </div>
84
+ {@render nav(collapsed)}
85
+ {#if footer && !collapsed}<div class="footer">{@render footer()}</div>{/if}
86
+ </aside>
87
+ <div class="mobilebar">
88
+ <button type="button" class="collapse" aria-label="Open navigation" aria-expanded={mobileOpen} onclick={() => (mobileOpen = true)}>
89
+ <svg aria-hidden="true" viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11" /></svg>
90
+ </button>
91
+ {#if header}<div class="header">{@render header()}</div>{/if}
92
+ </div>
93
+ <div class="main">{@render children()}</div>
94
+ </div>
95
+
96
+ <Dialog bind:open={mobileOpen} title="Navigation" side="left" class="tern-sidebar-sheet">
97
+ {@render nav(false)}
98
+ {#if footer}<div class="footer">{@render footer()}</div>{/if}
99
+ </Dialog>
100
+
101
+ <style>
102
+ .tern-sidebar-layout {
103
+ display: grid;
104
+ grid-template-columns: 15rem 1fr;
105
+ min-height: 100vh;
106
+ }
107
+ .collapsed {
108
+ grid-template-columns: 3.75rem 1fr;
109
+ }
110
+ .sidebar {
111
+ position: sticky;
112
+ top: 0;
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 0.75rem;
116
+ height: 100vh;
117
+ box-sizing: border-box;
118
+ padding: 0.75rem;
119
+ overflow-y: auto;
120
+ background: var(--tern-surface);
121
+ border-right: 1px solid var(--tern-border);
122
+ }
123
+ .top {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 0.5rem;
128
+ min-height: 2.25rem;
129
+ }
130
+ .collapsed .top {
131
+ justify-content: center;
132
+ }
133
+ .header {
134
+ font-weight: 700;
135
+ letter-spacing: -0.01em;
136
+ min-width: 0;
137
+ }
138
+ .collapse {
139
+ display: grid;
140
+ place-items: center;
141
+ width: 2rem;
142
+ height: 2rem;
143
+ flex: none;
144
+ color: var(--tern-muted);
145
+ background: transparent;
146
+ border: 0;
147
+ border-radius: var(--tern-radius-sm);
148
+ cursor: pointer;
149
+ }
150
+ .collapse:hover {
151
+ color: var(--tern-fg);
152
+ background: var(--tern-surface-2);
153
+ }
154
+ .collapse:focus-visible,
155
+ .link:focus-visible {
156
+ outline: 2px solid var(--tern-ring);
157
+ outline-offset: -2px;
158
+ }
159
+ .nav {
160
+ display: grid;
161
+ gap: 1rem;
162
+ align-content: start;
163
+ flex: 1;
164
+ }
165
+ .group-label {
166
+ padding: 0 0.6rem 0.3rem;
167
+ font-size: 0.75rem;
168
+ font-weight: 600;
169
+ color: var(--tern-muted);
170
+ }
171
+ ul {
172
+ display: grid;
173
+ gap: 0.125rem;
174
+ margin: 0;
175
+ padding: 0;
176
+ list-style: none;
177
+ }
178
+ .link {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 0.6rem;
182
+ min-height: 2.125rem;
183
+ padding: 0 0.6rem;
184
+ font-size: 0.875rem;
185
+ font-weight: 500;
186
+ color: var(--tern-muted);
187
+ text-decoration: none;
188
+ border-radius: var(--tern-radius-sm);
189
+ }
190
+ .compact .link {
191
+ justify-content: center;
192
+ padding: 0;
193
+ }
194
+ .link:hover {
195
+ color: var(--tern-fg);
196
+ background: var(--tern-surface-2);
197
+ }
198
+ .link[aria-current='page'] {
199
+ color: var(--tern-fg);
200
+ background: var(--tern-accent-soft);
201
+ font-weight: 600;
202
+ }
203
+ .icon {
204
+ display: inline-flex;
205
+ width: 1.125rem;
206
+ justify-content: center;
207
+ flex: none;
208
+ }
209
+ .label {
210
+ flex: 1;
211
+ }
212
+ .badge {
213
+ min-width: 1.25rem;
214
+ padding: 0 0.35rem;
215
+ font-size: 0.75rem;
216
+ font-weight: 600;
217
+ text-align: center;
218
+ color: var(--tern-fg);
219
+ background: var(--tern-surface-2);
220
+ border-radius: 1rem;
221
+ }
222
+ .sub {
223
+ margin: 0.125rem 0 0.25rem 1.45rem;
224
+ padding-left: 0.6rem;
225
+ border-left: 1px solid var(--tern-border);
226
+ }
227
+ .sub .link {
228
+ min-height: 1.875rem;
229
+ }
230
+ .footer {
231
+ padding-top: 0.75rem;
232
+ border-top: 1px solid var(--tern-border);
233
+ }
234
+ .main {
235
+ min-width: 0;
236
+ }
237
+ .mobilebar {
238
+ display: none;
239
+ }
240
+ @media (max-width: 48rem) {
241
+ .tern-sidebar-layout,
242
+ .collapsed {
243
+ grid-template-columns: 1fr;
244
+ }
245
+ .sidebar {
246
+ display: none;
247
+ }
248
+ .mobilebar {
249
+ position: sticky;
250
+ top: 0;
251
+ z-index: 10;
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 0.5rem;
255
+ padding: 0.5rem 0.75rem;
256
+ background: var(--tern-surface);
257
+ border-bottom: 1px solid var(--tern-border);
258
+ }
259
+ }
260
+ :global(.tern-sidebar-sheet.left) {
261
+ width: min(18rem, 100vw - 3rem);
262
+ }
263
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type SidebarLink = {
3
+ label: string;
4
+ href: string;
5
+ /** An icon, shown alone when the sidebar is collapsed. */
6
+ icon?: Snippet;
7
+ /** A count or label after the link, such as "3". */
8
+ badge?: string;
9
+ /** Links under this one, shown while it or one of them is current. */
10
+ items?: {
11
+ label: string;
12
+ href: string;
13
+ }[];
14
+ };
15
+ export type SidebarGroup = {
16
+ label?: string;
17
+ items: SidebarLink[];
18
+ };
19
+ type Props = {
20
+ groups: SidebarGroup[];
21
+ /** The app's name or logo, at the top. */
22
+ header?: Snippet;
23
+ /** Things at the bottom, such as the signed-in user. */
24
+ footer?: Snippet;
25
+ /** Show icons only on wide screens; bind it. */
26
+ collapsed?: boolean;
27
+ /** Names the navigation. */
28
+ label?: string;
29
+ /** The page's content, next to the sidebar. */
30
+ children: Snippet;
31
+ class?: string;
32
+ };
33
+ declare const Sidebar: import("svelte").Component<Props, {}, "collapsed">;
34
+ type Sidebar = ReturnType<typeof Sidebar>;
35
+ export default Sidebar;