@noahbaumgartner/svelte-ui 0.0.1

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 (149) hide show
  1. package/README.md +65 -0
  2. package/dist/components/alert-dialog/AlertDialog.svelte +161 -0
  3. package/dist/components/alert-dialog/AlertDialog.svelte.d.ts +20 -0
  4. package/dist/components/avatar/Avatar.svelte +85 -0
  5. package/dist/components/avatar/Avatar.svelte.d.ts +12 -0
  6. package/dist/components/badge/Badge.svelte +49 -0
  7. package/dist/components/badge/Badge.svelte.d.ts +12 -0
  8. package/dist/components/breadcrumb/Breadcrumb.svelte +37 -0
  9. package/dist/components/breadcrumb/Breadcrumb.svelte.d.ts +9 -0
  10. package/dist/components/breadcrumb/BreadcrumbEllipsis.svelte +32 -0
  11. package/dist/components/breadcrumb/BreadcrumbEllipsis.svelte.d.ts +10 -0
  12. package/dist/components/breadcrumb/BreadcrumbItem.svelte +71 -0
  13. package/dist/components/breadcrumb/BreadcrumbItem.svelte.d.ts +12 -0
  14. package/dist/components/breadcrumb/BreadcrumbSeparator.svelte +18 -0
  15. package/dist/components/breadcrumb/BreadcrumbSeparator.svelte.d.ts +18 -0
  16. package/dist/components/button/Button.svelte +247 -0
  17. package/dist/components/button/Button.svelte.d.ts +24 -0
  18. package/dist/components/button-group/ButtonGroup.svelte +89 -0
  19. package/dist/components/button-group/ButtonGroup.svelte.d.ts +12 -0
  20. package/dist/components/calendar/Calendar.svelte +401 -0
  21. package/dist/components/calendar/Calendar.svelte.d.ts +17 -0
  22. package/dist/components/calendar/dates.d.ts +12 -0
  23. package/dist/components/calendar/dates.js +48 -0
  24. package/dist/components/card/Card.svelte +55 -0
  25. package/dist/components/card/Card.svelte.d.ts +10 -0
  26. package/dist/components/card/CardAction.svelte +24 -0
  27. package/dist/components/card/CardAction.svelte.d.ts +9 -0
  28. package/dist/components/card/CardContent.svelte +20 -0
  29. package/dist/components/card/CardContent.svelte.d.ts +8 -0
  30. package/dist/components/card/CardDescription.svelte +23 -0
  31. package/dist/components/card/CardDescription.svelte.d.ts +8 -0
  32. package/dist/components/card/CardFooter.svelte +28 -0
  33. package/dist/components/card/CardFooter.svelte.d.ts +9 -0
  34. package/dist/components/card/CardHeader.svelte +29 -0
  35. package/dist/components/card/CardHeader.svelte.d.ts +9 -0
  36. package/dist/components/card/CardTitle.svelte +23 -0
  37. package/dist/components/card/CardTitle.svelte.d.ts +8 -0
  38. package/dist/components/checkbox/Checkbox.svelte +93 -0
  39. package/dist/components/checkbox/Checkbox.svelte.d.ts +5 -0
  40. package/dist/components/color-picker/ColorPicker.svelte +233 -0
  41. package/dist/components/color-picker/ColorPicker.svelte.d.ts +10 -0
  42. package/dist/components/context-menu/ContextMenu.svelte +201 -0
  43. package/dist/components/context-menu/ContextMenu.svelte.d.ts +14 -0
  44. package/dist/components/context-menu/ContextMenuItem.svelte +105 -0
  45. package/dist/components/context-menu/ContextMenuItem.svelte.d.ts +14 -0
  46. package/dist/components/context-menu/context.d.ts +14 -0
  47. package/dist/components/context-menu/context.js +2 -0
  48. package/dist/components/dialog/Dialog.svelte +225 -0
  49. package/dist/components/dialog/Dialog.svelte.d.ts +22 -0
  50. package/dist/components/field/Field.svelte +78 -0
  51. package/dist/components/field/Field.svelte.d.ts +11 -0
  52. package/dist/components/field/FieldContent.svelte +25 -0
  53. package/dist/components/field/FieldContent.svelte.d.ts +9 -0
  54. package/dist/components/field/FieldDescription.svelte +34 -0
  55. package/dist/components/field/FieldDescription.svelte.d.ts +9 -0
  56. package/dist/components/field/FieldError.svelte +45 -0
  57. package/dist/components/field/FieldError.svelte.d.ts +11 -0
  58. package/dist/components/field/FieldGroup.svelte +30 -0
  59. package/dist/components/field/FieldGroup.svelte.d.ts +9 -0
  60. package/dist/components/field/FieldLegend.svelte +34 -0
  61. package/dist/components/field/FieldLegend.svelte.d.ts +10 -0
  62. package/dist/components/field/FieldSeparator.svelte +31 -0
  63. package/dist/components/field/FieldSeparator.svelte.d.ts +9 -0
  64. package/dist/components/field/FieldSet.svelte +33 -0
  65. package/dist/components/field/FieldSet.svelte.d.ts +9 -0
  66. package/dist/components/input/Input.svelte +459 -0
  67. package/dist/components/input/Input.svelte.d.ts +10 -0
  68. package/dist/components/input-group/InputGroup.svelte +88 -0
  69. package/dist/components/input-group/InputGroup.svelte.d.ts +9 -0
  70. package/dist/components/input-group/InputGroupAddon.svelte +88 -0
  71. package/dist/components/input-group/InputGroupAddon.svelte.d.ts +11 -0
  72. package/dist/components/kbd/Kbd.svelte +46 -0
  73. package/dist/components/kbd/Kbd.svelte.d.ts +8 -0
  74. package/dist/components/kbd/KbdGroup.svelte +27 -0
  75. package/dist/components/kbd/KbdGroup.svelte.d.ts +8 -0
  76. package/dist/components/label/Label.svelte +48 -0
  77. package/dist/components/label/Label.svelte.d.ts +9 -0
  78. package/dist/components/link/Link.svelte +40 -0
  79. package/dist/components/link/Link.svelte.d.ts +10 -0
  80. package/dist/components/navbar/Navbar.svelte +266 -0
  81. package/dist/components/navbar/Navbar.svelte.d.ts +26 -0
  82. package/dist/components/navigation-menu/NavigationMenu.svelte +95 -0
  83. package/dist/components/navigation-menu/NavigationMenu.svelte.d.ts +12 -0
  84. package/dist/components/navigation-menu/NavigationMenuItem.svelte +257 -0
  85. package/dist/components/navigation-menu/NavigationMenuItem.svelte.d.ts +15 -0
  86. package/dist/components/navigation-menu/NavigationMenuLink.svelte +91 -0
  87. package/dist/components/navigation-menu/NavigationMenuLink.svelte.d.ts +16 -0
  88. package/dist/components/navigation-menu/context.d.ts +15 -0
  89. package/dist/components/navigation-menu/context.js +2 -0
  90. package/dist/components/popover/Popover.svelte +295 -0
  91. package/dist/components/popover/Popover.svelte.d.ts +34 -0
  92. package/dist/components/radio-group/RadioGroup.svelte +86 -0
  93. package/dist/components/radio-group/RadioGroup.svelte.d.ts +16 -0
  94. package/dist/components/radio-group/RadioGroupItem.svelte +95 -0
  95. package/dist/components/radio-group/RadioGroupItem.svelte.d.ts +7 -0
  96. package/dist/components/radio-group/context.d.ts +9 -0
  97. package/dist/components/radio-group/context.js +2 -0
  98. package/dist/components/separator/Separator.svelte +41 -0
  99. package/dist/components/separator/Separator.svelte.d.ts +10 -0
  100. package/dist/components/sidebar/Sidebar.svelte +180 -0
  101. package/dist/components/sidebar/Sidebar.svelte.d.ts +14 -0
  102. package/dist/components/sidebar/SidebarContent.svelte +30 -0
  103. package/dist/components/sidebar/SidebarContent.svelte.d.ts +9 -0
  104. package/dist/components/sidebar/SidebarFooter.svelte +23 -0
  105. package/dist/components/sidebar/SidebarFooter.svelte.d.ts +8 -0
  106. package/dist/components/sidebar/SidebarGroup.svelte +55 -0
  107. package/dist/components/sidebar/SidebarGroup.svelte.d.ts +11 -0
  108. package/dist/components/sidebar/SidebarHeader.svelte +23 -0
  109. package/dist/components/sidebar/SidebarHeader.svelte.d.ts +8 -0
  110. package/dist/components/sidebar/SidebarInset.svelte +25 -0
  111. package/dist/components/sidebar/SidebarInset.svelte.d.ts +9 -0
  112. package/dist/components/sidebar/SidebarMenu.svelte +27 -0
  113. package/dist/components/sidebar/SidebarMenu.svelte.d.ts +9 -0
  114. package/dist/components/sidebar/SidebarMenuItem.svelte +221 -0
  115. package/dist/components/sidebar/SidebarMenuItem.svelte.d.ts +23 -0
  116. package/dist/components/sidebar/SidebarProvider.svelte +62 -0
  117. package/dist/components/sidebar/SidebarProvider.svelte.d.ts +11 -0
  118. package/dist/components/sidebar/SidebarSeparator.svelte +5 -0
  119. package/dist/components/sidebar/SidebarSeparator.svelte.d.ts +18 -0
  120. package/dist/components/sidebar/SidebarTrigger.svelte +24 -0
  121. package/dist/components/sidebar/SidebarTrigger.svelte.d.ts +7 -0
  122. package/dist/components/sidebar/context.d.ts +15 -0
  123. package/dist/components/sidebar/context.js +2 -0
  124. package/dist/components/skeleton/Skeleton.svelte +30 -0
  125. package/dist/components/skeleton/Skeleton.svelte.d.ts +5 -0
  126. package/dist/components/spinner/Spinner.svelte +72 -0
  127. package/dist/components/spinner/Spinner.svelte.d.ts +9 -0
  128. package/dist/components/switch/Switch.svelte +88 -0
  129. package/dist/components/switch/Switch.svelte.d.ts +5 -0
  130. package/dist/components/table/Table.svelte +276 -0
  131. package/dist/components/table/Table.svelte.d.ts +60 -0
  132. package/dist/components/textarea/Textarea.svelte +57 -0
  133. package/dist/components/textarea/Textarea.svelte.d.ts +4 -0
  134. package/dist/components/time-picker/TimePicker.svelte +162 -0
  135. package/dist/components/time-picker/TimePicker.svelte.d.ts +11 -0
  136. package/dist/components/toaster/Toaster.svelte +151 -0
  137. package/dist/components/toaster/Toaster.svelte.d.ts +7 -0
  138. package/dist/components/toaster/toast.svelte.d.ts +28 -0
  139. package/dist/components/toaster/toast.svelte.js +22 -0
  140. package/dist/components/toggle/Toggle.svelte +31 -0
  141. package/dist/components/toggle/Toggle.svelte.d.ts +8 -0
  142. package/dist/components/tooltip/Tooltip.svelte +224 -0
  143. package/dist/components/tooltip/Tooltip.svelte.d.ts +27 -0
  144. package/dist/icons.d.ts +2 -0
  145. package/dist/icons.js +4 -0
  146. package/dist/index.d.ts +73 -0
  147. package/dist/index.js +66 -0
  148. package/dist/styles/tokens.css +50 -0
  149. package/package.json +88 -0
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Props = {
3
+ /** Accessible name of the navigation landmark. */
4
+ label?: string;
5
+ /** `vertical` stacks the items and expands submenus in place, e.g. in a mobile menu. */
6
+ orientation?: 'horizontal' | 'vertical';
7
+ /** `NavigationMenuItem`s. */
8
+ children: Snippet;
9
+ };
10
+ declare const NavigationMenu: import("svelte").Component<Props, {}, "">;
11
+ type NavigationMenu = ReturnType<typeof NavigationMenu>;
12
+ export default NavigationMenu;
@@ -0,0 +1,257 @@
1
+ <script lang="ts">
2
+ import { tick, type Snippet } from 'svelte';
3
+ import type { Attachment } from 'svelte/attachments';
4
+ import { prefersReducedMotion } from 'svelte/motion';
5
+ import { scale, slide } from 'svelte/transition';
6
+ import ChevronDown from '@lucide/svelte/icons/chevron-down';
7
+ import { getNavigationMenuContext } from './context.js';
8
+
9
+ type Props = {
10
+ /** Links the item. Ignored when the item has `items`. */
11
+ href?: string;
12
+ /** Marks the item as the current page, or the section containing it. */
13
+ active?: boolean;
14
+ /** `NavigationMenuLink`s, shown in a panel the item opens on hover or click, or expanded in place in a vertical menu. */
15
+ items?: Snippet;
16
+ /** Number of columns the panel lays its links out in. Ignored in a vertical menu. */
17
+ columns?: 1 | 2 | 3;
18
+ children: Snippet;
19
+ };
20
+
21
+ let { href, active = false, items, columns = 1, children }: Props = $props();
22
+
23
+ const id = $props.id();
24
+ const menu = getNavigationMenuContext();
25
+ const gap = 8;
26
+ const edge = 8;
27
+
28
+ let itemEl: HTMLLIElement;
29
+ let triggerEl: HTMLButtonElement | undefined = $state();
30
+ let contentEl: HTMLElement | undefined = $state();
31
+ let position = $state({ top: 0, left: 0 });
32
+ let open = $derived(menu.current === id);
33
+ let classes = $derived([
34
+ 'navigation-menu-trigger',
35
+ { 'navigation-menu-trigger--active': active }
36
+ ]);
37
+
38
+ function links() {
39
+ return Array.from(contentEl?.querySelectorAll<HTMLElement>('a[href]') ?? []);
40
+ }
41
+
42
+ function hoverable(event: PointerEvent) {
43
+ return !menu.vertical && event.pointerType !== 'touch';
44
+ }
45
+
46
+ function close({ restoreFocus = false } = {}) {
47
+ menu.close();
48
+ if (restoreFocus) triggerEl?.focus();
49
+ }
50
+
51
+ async function handleTriggerKeydown(event: KeyboardEvent) {
52
+ if (menu.vertical || event.key !== 'ArrowDown') return;
53
+ event.preventDefault();
54
+ menu.open(id, true);
55
+ await tick();
56
+ links()[0]?.focus();
57
+ }
58
+
59
+ function handleContentKeydown(event: KeyboardEvent) {
60
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
61
+ event.preventDefault();
62
+ const list = links();
63
+ const index = list.indexOf(document.activeElement as HTMLElement);
64
+ const step = event.key === 'ArrowDown' ? 1 : -1;
65
+ list[(index + step + list.length) % list.length]?.focus();
66
+ }
67
+
68
+ function place() {
69
+ if (!triggerEl || !contentEl) return;
70
+ const rect = triggerEl.getBoundingClientRect();
71
+ const max = window.innerWidth - contentEl.offsetWidth - edge;
72
+ position = { top: rect.bottom + gap, left: Math.max(Math.min(rect.left, max), edge) };
73
+ }
74
+
75
+ const popover: Attachment<HTMLDivElement> = (node) => {
76
+ node.showPopover();
77
+ place();
78
+ };
79
+
80
+ $effect(() => {
81
+ if (!open || menu.vertical) return;
82
+
83
+ function handlePointerDown(event: PointerEvent) {
84
+ if (!itemEl.contains(event.target as Node)) close();
85
+ }
86
+
87
+ function handleKeydown(event: KeyboardEvent) {
88
+ if (event.key === 'Escape') close({ restoreFocus: itemEl.contains(document.activeElement) });
89
+ }
90
+
91
+ window.addEventListener('pointerdown', handlePointerDown);
92
+ window.addEventListener('keydown', handleKeydown);
93
+ window.addEventListener('scroll', place, true);
94
+ window.addEventListener('resize', place);
95
+
96
+ return () => {
97
+ window.removeEventListener('pointerdown', handlePointerDown);
98
+ window.removeEventListener('keydown', handleKeydown);
99
+ window.removeEventListener('scroll', place, true);
100
+ window.removeEventListener('resize', place);
101
+ };
102
+ });
103
+ </script>
104
+
105
+ <li
106
+ class={['navigation-menu-item', { 'navigation-menu-item--vertical': menu.vertical }]}
107
+ bind:this={itemEl}
108
+ onfocusout={(event) => {
109
+ if (open && !menu.vertical && !itemEl.contains(event.relatedTarget as Node | null))
110
+ menu.close();
111
+ }}
112
+ >
113
+ {#if items}
114
+ <button
115
+ type="button"
116
+ class={classes}
117
+ aria-expanded={open}
118
+ aria-controls={open ? `${id}-content` : undefined}
119
+ bind:this={triggerEl}
120
+ onclick={() => (open ? close() : menu.open(id, true))}
121
+ onkeydown={handleTriggerKeydown}
122
+ onpointerenter={(event) => hoverable(event) && menu.open(id)}
123
+ onpointerleave={(event) => hoverable(event) && menu.leave()}
124
+ >
125
+ {@render children()}
126
+ <ChevronDown class="navigation-menu-chevron" aria-hidden="true" />
127
+ </button>
128
+
129
+ {#if open && menu.vertical}
130
+ <ul
131
+ id="{id}-content"
132
+ class="navigation-menu-sub"
133
+ bind:this={contentEl}
134
+ transition:slide={{ duration: prefersReducedMotion.current ? 0 : 200 }}
135
+ >
136
+ {@render items()}
137
+ </ul>
138
+ {:else if open}
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
140
+ <div
141
+ id="{id}-content"
142
+ popover="manual"
143
+ class="navigation-menu-content"
144
+ style:top="{position.top}px"
145
+ style:left="{position.left}px"
146
+ bind:this={contentEl}
147
+ onpointerenter={menu.stay}
148
+ onpointerleave={(event) => event.pointerType !== 'touch' && menu.leave()}
149
+ onkeydown={handleContentKeydown}
150
+ {@attach popover}
151
+ transition:scale={{ duration: prefersReducedMotion.current ? 0 : 140, start: 0.95 }}
152
+ >
153
+ <ul class="navigation-menu-links" style:--columns={columns}>
154
+ {@render items()}
155
+ </ul>
156
+ </div>
157
+ {/if}
158
+ {:else}
159
+ <!-- href comes from the consumer, who resolves it; this library has no routes -->
160
+ <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
161
+ <a {href} aria-current={active ? 'page' : undefined} class={classes}>{@render children()}</a>
162
+ {/if}
163
+ </li>
164
+
165
+ <style>
166
+ .navigation-menu-item {
167
+ display: flex;
168
+ }
169
+
170
+ .navigation-menu-item--vertical {
171
+ flex-direction: column;
172
+ }
173
+
174
+ .navigation-menu-item--vertical .navigation-menu-trigger {
175
+ justify-content: space-between;
176
+ width: 100%;
177
+ text-align: left;
178
+ }
179
+
180
+ .navigation-menu-trigger {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 4px;
184
+ padding: 0;
185
+ background: none;
186
+ border: none;
187
+ border-radius: 4px;
188
+ font: inherit;
189
+ font-size: 14px;
190
+ line-height: 28px;
191
+ color: var(--color-text);
192
+ text-decoration: none;
193
+ outline: none;
194
+ cursor: pointer;
195
+ transition: opacity 200ms;
196
+ }
197
+
198
+ .navigation-menu-trigger:hover {
199
+ opacity: 0.6;
200
+ }
201
+
202
+ .navigation-menu-trigger:focus-visible {
203
+ outline: 2px solid var(--color-accent);
204
+ outline-offset: 2px;
205
+ }
206
+
207
+ .navigation-menu-trigger--active {
208
+ font-weight: 500;
209
+ }
210
+
211
+ .navigation-menu-item :global(.navigation-menu-chevron) {
212
+ width: 14px;
213
+ height: 14px;
214
+ flex-shrink: 0;
215
+ transition: rotate 200ms ease;
216
+ }
217
+
218
+ .navigation-menu-trigger[aria-expanded='true'] :global(.navigation-menu-chevron) {
219
+ rotate: 180deg;
220
+ }
221
+
222
+ .navigation-menu-content {
223
+ position: fixed;
224
+ inset: auto;
225
+ box-sizing: border-box;
226
+ max-width: calc(100vw - 16px);
227
+ margin: 0;
228
+ padding: 6px;
229
+ overflow: visible;
230
+ background-color: var(--color-bg);
231
+ color: var(--color-text);
232
+ border: 1px solid var(--color-border);
233
+ border-radius: 12px;
234
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
235
+ transform-origin: top left;
236
+ user-select: none;
237
+ }
238
+
239
+ .navigation-menu-links {
240
+ display: grid;
241
+ grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
242
+ width: min(var(--columns) * 240px, 100vw - 30px);
243
+ gap: 2px;
244
+ margin: 0;
245
+ padding: 0;
246
+ list-style: none;
247
+ }
248
+
249
+ .navigation-menu-sub {
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: 2px;
253
+ margin: 4px 0 0;
254
+ padding: 0;
255
+ list-style: none;
256
+ }
257
+ </style>
@@ -0,0 +1,15 @@
1
+ import { type Snippet } from 'svelte';
2
+ type Props = {
3
+ /** Links the item. Ignored when the item has `items`. */
4
+ href?: string;
5
+ /** Marks the item as the current page, or the section containing it. */
6
+ active?: boolean;
7
+ /** `NavigationMenuLink`s, shown in a panel the item opens on hover or click, or expanded in place in a vertical menu. */
8
+ items?: Snippet;
9
+ /** Number of columns the panel lays its links out in. Ignored in a vertical menu. */
10
+ columns?: 1 | 2 | 3;
11
+ children: Snippet;
12
+ };
13
+ declare const NavigationMenuItem: import("svelte").Component<Props, {}, "">;
14
+ type NavigationMenuItem = ReturnType<typeof NavigationMenuItem>;
15
+ export default NavigationMenuItem;
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { LucideIcon } from '@lucide/svelte';
4
+ import { getNavigationMenuContext } from './context.js';
5
+
6
+ type Props = {
7
+ href: string;
8
+ /** Any Lucide icon (import from `svelte-ui/icons`). */
9
+ icon?: LucideIcon;
10
+ /** Short text below the title, clamped to two lines. */
11
+ description?: string;
12
+ /** Marks the link as the current page. */
13
+ active?: boolean;
14
+ /** The link's title. */
15
+ children: Snippet;
16
+ };
17
+
18
+ let { href, icon: Icon, description, active = false, children }: Props = $props();
19
+
20
+ const menu = getNavigationMenuContext();
21
+ let classes = $derived(['navigation-menu-link', { 'navigation-menu-link--active': active }]);
22
+ </script>
23
+
24
+ <li>
25
+ <!-- href comes from the consumer, who resolves it; this library has no routes -->
26
+ <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
27
+ <a {href} aria-current={active ? 'page' : undefined} class={classes} onclick={menu.close}>
28
+ {#if Icon}<Icon class="navigation-menu-link-icon" aria-hidden="true" />{/if}
29
+ <span class="navigation-menu-link-text">
30
+ <span class="navigation-menu-link-title">{@render children()}</span>
31
+ {#if description}
32
+ <span class="navigation-menu-link-description">{description}</span>
33
+ {/if}
34
+ </span>
35
+ </a>
36
+ </li>
37
+
38
+ <style>
39
+ .navigation-menu-link {
40
+ display: flex;
41
+ align-items: flex-start;
42
+ gap: 10px;
43
+ padding: 8px 10px;
44
+ border-radius: 8px;
45
+ font-size: 13px;
46
+ line-height: 1.4;
47
+ color: var(--color-text);
48
+ text-decoration: none;
49
+ outline: none;
50
+ transition: background-color 150ms ease;
51
+ }
52
+
53
+ .navigation-menu-link:hover,
54
+ .navigation-menu-link:focus-visible,
55
+ .navigation-menu-link--active {
56
+ background-color: var(--color-surface);
57
+ }
58
+
59
+ .navigation-menu-link:focus-visible {
60
+ outline: 2px solid var(--color-accent);
61
+ outline-offset: -2px;
62
+ }
63
+
64
+ .navigation-menu-link :global(.navigation-menu-link-icon) {
65
+ width: 16px;
66
+ height: 16px;
67
+ margin-top: 1px;
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ .navigation-menu-link-text {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 2px;
75
+ min-width: 0;
76
+ }
77
+
78
+ .navigation-menu-link-title {
79
+ font-weight: 500;
80
+ }
81
+
82
+ .navigation-menu-link-description {
83
+ display: -webkit-box;
84
+ -webkit-box-orient: vertical;
85
+ -webkit-line-clamp: 2;
86
+ line-clamp: 2;
87
+ overflow: hidden;
88
+ font-size: 12px;
89
+ color: var(--color-text-muted);
90
+ }
91
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { LucideIcon } from '@lucide/svelte';
3
+ type Props = {
4
+ href: string;
5
+ /** Any Lucide icon (import from `svelte-ui/icons`). */
6
+ icon?: LucideIcon;
7
+ /** Short text below the title, clamped to two lines. */
8
+ description?: string;
9
+ /** Marks the link as the current page. */
10
+ active?: boolean;
11
+ /** The link's title. */
12
+ children: Snippet;
13
+ };
14
+ declare const NavigationMenuLink: import("svelte").Component<Props, {}, "">;
15
+ type NavigationMenuLink = ReturnType<typeof NavigationMenuLink>;
16
+ export default NavigationMenuLink;
@@ -0,0 +1,15 @@
1
+ export type NavigationMenuContext = {
2
+ /** Vertical menus expand submenus in place instead of opening a panel. */
3
+ readonly vertical: boolean;
4
+ /** Id of the item whose panel is open, or null. */
5
+ readonly current: string | null;
6
+ /** Opens an item's panel, after a short delay unless another one is already open. */
7
+ open: (id: string, immediate?: boolean) => void;
8
+ /** Closes the open panel after a short delay, e.g. when the pointer leaves it. */
9
+ leave: () => void;
10
+ /** Cancels a pending open or close. */
11
+ stay: () => void;
12
+ /** Closes the open panel right away. */
13
+ close: () => void;
14
+ };
15
+ export declare const getNavigationMenuContext: () => NavigationMenuContext, setNavigationMenuContext: (context: NavigationMenuContext) => NavigationMenuContext;
@@ -0,0 +1,2 @@
1
+ import { createContext } from 'svelte';
2
+ export const [getNavigationMenuContext, setNavigationMenuContext] = createContext();
@@ -0,0 +1,295 @@
1
+ <script module lang="ts">
2
+ import { createAttachmentKey, type Attachment } from 'svelte/attachments';
3
+
4
+ export type PopoverTriggerProps = {
5
+ onclick?: () => void;
6
+ onpointerenter?: (event: PointerEvent) => void;
7
+ onpointerleave?: () => void;
8
+ onfocus?: () => void;
9
+ onblur?: (event: FocusEvent) => void;
10
+ 'aria-haspopup'?: 'dialog';
11
+ 'aria-expanded'?: boolean;
12
+ 'aria-controls'?: string;
13
+ [key: symbol]: Attachment<HTMLElement>;
14
+ };
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import type { Snippet } from 'svelte';
19
+ import { scale } from 'svelte/transition';
20
+
21
+ type Side = 'top' | 'right' | 'bottom' | 'left';
22
+
23
+ type Props = {
24
+ /** Whether the popover is open. Bindable. */
25
+ open?: boolean;
26
+ /** Opens on hover and keyboard focus instead of on click, like a hover card. */
27
+ hover?: boolean;
28
+ /** Milliseconds to wait on hover before opening. Only used with `hover`. */
29
+ delay?: number;
30
+ /** Preferred side; flips to the opposite one when there is not enough room. */
31
+ side?: Side;
32
+ /** Alignment along the trigger's edge. */
33
+ align?: 'start' | 'center' | 'end';
34
+ title?: string;
35
+ description?: string;
36
+ /** Renders the trigger. Spread the given props onto it, e.g. `<Button {...props} />`. */
37
+ trigger: Snippet<[PopoverTriggerProps]>;
38
+ children?: Snippet;
39
+ };
40
+
41
+ let {
42
+ open = $bindable(false),
43
+ hover = false,
44
+ delay = 500,
45
+ side = 'bottom',
46
+ align = 'center',
47
+ title,
48
+ description,
49
+ trigger,
50
+ children
51
+ }: Props = $props();
52
+
53
+ const id = $props.id();
54
+ const gap = 8;
55
+ const edge = 8;
56
+ const closeDelay = 200;
57
+ const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
58
+
59
+ let wrapperEl: HTMLSpanElement;
60
+ let triggerEl: HTMLElement | undefined;
61
+ let contentEl: HTMLDivElement | undefined = $state();
62
+ let placement: Side = $state('bottom');
63
+ let position = $state({ top: 0, left: 0 });
64
+ let timer: ReturnType<typeof setTimeout> | undefined;
65
+
66
+ function schedule(value: boolean, wait: number) {
67
+ clearTimeout(timer);
68
+ if (wait > 0) timer = setTimeout(() => (open = value), wait);
69
+ else open = value;
70
+ }
71
+
72
+ function close({ restoreFocus = false } = {}) {
73
+ clearTimeout(timer);
74
+ open = false;
75
+ if (restoreFocus) triggerEl?.focus();
76
+ }
77
+
78
+ function leftWrapper(event: FocusEvent) {
79
+ return !wrapperEl.contains(event.relatedTarget as Node | null);
80
+ }
81
+
82
+ const attach = createAttachmentKey();
83
+
84
+ const triggerProps: PopoverTriggerProps = $derived.by(() => {
85
+ const ref = (node: HTMLElement) => {
86
+ triggerEl = node;
87
+ return () => clearTimeout(timer);
88
+ };
89
+
90
+ if (hover) {
91
+ return {
92
+ onpointerenter: (event: PointerEvent) => {
93
+ if (event.pointerType !== 'touch') schedule(true, delay);
94
+ },
95
+ onpointerleave: () => schedule(false, closeDelay),
96
+ onfocus: () => {
97
+ if (triggerEl?.matches(':focus-visible')) schedule(true, 0);
98
+ },
99
+ onblur: (event: FocusEvent) => {
100
+ if (leftWrapper(event)) close();
101
+ },
102
+ [attach]: ref
103
+ };
104
+ }
105
+
106
+ return {
107
+ onclick: () => (open ? close() : (open = true)),
108
+ 'aria-haspopup': 'dialog',
109
+ 'aria-expanded': open,
110
+ 'aria-controls': `${id}-popover`,
111
+ [attach]: ref
112
+ };
113
+ });
114
+
115
+ function fits(rect: DOMRect, width: number, height: number, s: Side) {
116
+ if (s === 'top') return rect.top - gap - height >= edge;
117
+ if (s === 'bottom') return rect.bottom + gap + height <= window.innerHeight - edge;
118
+ if (s === 'left') return rect.left - gap - width >= edge;
119
+ return rect.right + gap + width <= window.innerWidth - edge;
120
+ }
121
+
122
+ function aligned(start: number, length: number, size: number) {
123
+ if (align === 'start') return start;
124
+ if (align === 'end') return start + length - size;
125
+ return start + length / 2 - size / 2;
126
+ }
127
+
128
+ function place() {
129
+ if (!triggerEl || !contentEl) return;
130
+ const rect = triggerEl.getBoundingClientRect();
131
+ const { offsetWidth: width, offsetHeight: height } = contentEl;
132
+ const s =
133
+ fits(rect, width, height, side) || !fits(rect, width, height, opposite[side])
134
+ ? side
135
+ : opposite[side];
136
+ const clamp = (value: number, max: number) => Math.min(Math.max(value, edge), max - edge);
137
+
138
+ if (s === 'top' || s === 'bottom') {
139
+ position = {
140
+ top: s === 'top' ? rect.top - gap - height : rect.bottom + gap,
141
+ left: clamp(aligned(rect.left, rect.width, width), window.innerWidth - width)
142
+ };
143
+ } else {
144
+ position = {
145
+ top: clamp(aligned(rect.top, rect.height, height), window.innerHeight - height),
146
+ left: s === 'left' ? rect.left - gap - width : rect.right + gap
147
+ };
148
+ }
149
+ placement = s;
150
+ }
151
+
152
+ const popover: Attachment<HTMLDivElement> = (node) => {
153
+ node.showPopover();
154
+ place();
155
+ if (!hover) {
156
+ const first = node.querySelector<HTMLElement>(
157
+ 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
158
+ );
159
+ (first ?? node).focus();
160
+ }
161
+ };
162
+
163
+ $effect(() => {
164
+ if (!open) return;
165
+
166
+ function handlePointerDown(event: PointerEvent) {
167
+ if (!hover && !wrapperEl.contains(event.target as Node)) close();
168
+ }
169
+
170
+ function handleKeydown(event: KeyboardEvent) {
171
+ if (event.key === 'Escape') close({ restoreFocus: !hover });
172
+ }
173
+
174
+ window.addEventListener('pointerdown', handlePointerDown);
175
+ window.addEventListener('keydown', handleKeydown);
176
+ window.addEventListener('scroll', place, true);
177
+ window.addEventListener('resize', place);
178
+
179
+ return () => {
180
+ window.removeEventListener('pointerdown', handlePointerDown);
181
+ window.removeEventListener('keydown', handleKeydown);
182
+ window.removeEventListener('scroll', place, true);
183
+ window.removeEventListener('resize', place);
184
+ };
185
+ });
186
+ </script>
187
+
188
+ <span class="popover" bind:this={wrapperEl}>
189
+ {@render trigger(triggerProps)}
190
+
191
+ {#if open}
192
+ <div
193
+ id="{id}-popover"
194
+ role={hover ? undefined : 'dialog'}
195
+ aria-labelledby={title ? `${id}-title` : undefined}
196
+ aria-describedby={description ? `${id}-description` : undefined}
197
+ tabindex="-1"
198
+ popover="manual"
199
+ class={['popover-content', `popover-content--${placement}`, `popover-content--${align}`]}
200
+ style:top="{position.top}px"
201
+ style:left="{position.left}px"
202
+ bind:this={contentEl}
203
+ onpointerenter={() => hover && clearTimeout(timer)}
204
+ onpointerleave={() => hover && schedule(false, closeDelay)}
205
+ onfocusout={(event) => {
206
+ if (leftWrapper(event)) close();
207
+ }}
208
+ {@attach popover}
209
+ transition:scale={{ duration: 140, start: 0.95 }}
210
+ >
211
+ {#if title}
212
+ <h3 id="{id}-title" class="popover-title">{title}</h3>
213
+ {/if}
214
+ {#if description}
215
+ <p id="{id}-description" class="popover-description">{description}</p>
216
+ {/if}
217
+ {#if children}
218
+ <div class="popover-body">{@render children()}</div>
219
+ {/if}
220
+ </div>
221
+ {/if}
222
+ </span>
223
+
224
+ <style>
225
+ .popover {
226
+ display: inline-flex;
227
+ height: fit-content;
228
+ }
229
+
230
+ .popover-content {
231
+ position: fixed;
232
+ inset: auto;
233
+ box-sizing: border-box;
234
+ width: 288px;
235
+ max-width: calc(100vw - 16px);
236
+ margin: 0;
237
+ padding: 16px;
238
+ overflow: visible;
239
+ font-size: 13px;
240
+ line-height: 1.5;
241
+ background-color: var(--color-bg);
242
+ color: var(--color-text);
243
+ border: 1px solid var(--color-border);
244
+ border-radius: 12px;
245
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
246
+ outline: none;
247
+ }
248
+
249
+ .popover-title {
250
+ margin: 0;
251
+ font-size: 14px;
252
+ font-weight: 600;
253
+ }
254
+
255
+ .popover-description {
256
+ margin: 4px 0 0;
257
+ color: var(--color-text-muted);
258
+ }
259
+
260
+ :is(.popover-title, .popover-description) + .popover-body {
261
+ margin-top: 14px;
262
+ }
263
+
264
+ .popover-content--bottom {
265
+ transform-origin: top center;
266
+ }
267
+
268
+ .popover-content--top {
269
+ transform-origin: bottom center;
270
+ }
271
+
272
+ .popover-content--left {
273
+ transform-origin: center right;
274
+ }
275
+
276
+ .popover-content--right {
277
+ transform-origin: center left;
278
+ }
279
+
280
+ .popover-content--bottom.popover-content--start {
281
+ transform-origin: top left;
282
+ }
283
+
284
+ .popover-content--bottom.popover-content--end {
285
+ transform-origin: top right;
286
+ }
287
+
288
+ .popover-content--top.popover-content--start {
289
+ transform-origin: bottom left;
290
+ }
291
+
292
+ .popover-content--top.popover-content--end {
293
+ transform-origin: bottom right;
294
+ }
295
+ </style>