@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,88 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
+ /** Placement relative to the input, independent of the order in the markup. */
7
+ align?: 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
8
+ /** Text, icons or ghost Buttons; Buttons are shrunk to fit. */
9
+ children: Snippet;
10
+ };
11
+
12
+ let { align = 'inline-start', class: className, children, ...rest }: Props = $props();
13
+
14
+ function focusInput(event: MouseEvent) {
15
+ if ((event.target as HTMLElement).closest('button, a, input')) return;
16
+ (event.currentTarget as HTMLElement).parentElement?.querySelector('input')?.focus();
17
+ }
18
+ </script>
19
+
20
+ <div
21
+ {...rest}
22
+ class={['input-group-addon', `input-group-addon--${align}`, className]}
23
+ onclick={focusInput}
24
+ >
25
+ {@render children()}
26
+ </div>
27
+
28
+ <style>
29
+ .input-group-addon {
30
+ display: flex;
31
+ flex-shrink: 0;
32
+ align-items: center;
33
+ gap: 6px;
34
+ height: 100%;
35
+ font-size: 13px;
36
+ color: var(--color-text-muted);
37
+ white-space: nowrap;
38
+ cursor: text;
39
+ user-select: none;
40
+ }
41
+
42
+ .input-group-addon :global(svg) {
43
+ width: 16px;
44
+ height: 16px;
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ /* Buttons shrink to fit inside the 32px group */
49
+ .input-group-addon > :global(.button) {
50
+ height: 24px;
51
+ border-radius: 6px;
52
+ }
53
+
54
+ .input-group-addon > :global(.button--icon-only) {
55
+ width: 24px;
56
+ }
57
+
58
+ .input-group-addon--inline-start {
59
+ order: -1;
60
+ padding-left: 10px;
61
+ }
62
+
63
+ .input-group-addon--inline-end {
64
+ order: 1;
65
+ padding-right: 10px;
66
+ }
67
+
68
+ .input-group-addon--block-start {
69
+ order: -1;
70
+ height: auto;
71
+ padding: 8px 10px 0;
72
+ }
73
+
74
+ .input-group-addon--block-end {
75
+ order: 1;
76
+ height: auto;
77
+ padding: 0 10px 8px;
78
+ }
79
+
80
+ /* Buttons sit 4px from the border, like the Input's own controls */
81
+ .input-group-addon--inline-start:has(> :global(.button:first-child)) {
82
+ padding-left: 4px;
83
+ }
84
+
85
+ .input-group-addon--inline-end:has(> :global(.button:last-child)) {
86
+ padding-right: 4px;
87
+ }
88
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
4
+ /** Placement relative to the input, independent of the order in the markup. */
5
+ align?: 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
6
+ /** Text, icons or ghost Buttons; Buttons are shrunk to fit. */
7
+ children: Snippet;
8
+ };
9
+ declare const InputGroupAddon: import("svelte").Component<Props, {}, "">;
10
+ type InputGroupAddon = ReturnType<typeof InputGroupAddon>;
11
+ export default InputGroupAddon;
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
6
+ children: Snippet;
7
+ };
8
+
9
+ let { class: className, children, ...rest }: Props = $props();
10
+ </script>
11
+
12
+ <kbd {...rest} class={['kbd', className]}>{@render children()}</kbd>
13
+
14
+ <style>
15
+ .kbd {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ gap: 4px;
20
+ box-sizing: border-box;
21
+ width: fit-content;
22
+ min-width: 20px;
23
+ height: 20px;
24
+ padding: 0 4px;
25
+ font-family: inherit;
26
+ font-size: 12px;
27
+ line-height: 1;
28
+ background-color: var(--color-surface);
29
+ color: var(--color-text-muted);
30
+ border-radius: 4px;
31
+ pointer-events: none;
32
+ user-select: none;
33
+ }
34
+
35
+ .kbd :global(svg) {
36
+ width: 12px;
37
+ height: 12px;
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ :global(.button--primary) .kbd,
42
+ :global(.tooltip-content) .kbd {
43
+ background-color: color-mix(in srgb, currentColor 20%, transparent);
44
+ color: inherit;
45
+ }
46
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
4
+ children: Snippet;
5
+ };
6
+ declare const Kbd: import("svelte").Component<Props, {}, "">;
7
+ type Kbd = ReturnType<typeof Kbd>;
8
+ export default Kbd;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
6
+ children: Snippet;
7
+ };
8
+
9
+ let { class: className, children, ...rest }: Props = $props();
10
+ </script>
11
+
12
+ <kbd {...rest} class={['kbd-group', className]}>{@render children()}</kbd>
13
+
14
+ <style>
15
+ .kbd-group {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: 4px;
19
+ font-family: inherit;
20
+ font-size: 12px;
21
+ color: var(--color-text-muted);
22
+ }
23
+
24
+ :global(.tooltip-content) .kbd-group {
25
+ color: inherit;
26
+ }
27
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
4
+ children: Snippet;
5
+ };
6
+ declare const KbdGroup: import("svelte").Component<Props, {}, "">;
7
+ type KbdGroup = ReturnType<typeof KbdGroup>;
8
+ export default KbdGroup;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLLabelAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLLabelAttributes, 'children'> & {
6
+ /** Set `for` to the control's id, or wrap the control. */
7
+ children: Snippet;
8
+ };
9
+
10
+ let { class: className, children, ...rest }: Props = $props();
11
+ </script>
12
+
13
+ <label {...rest} class={['label', className]}>
14
+ {@render children()}
15
+ </label>
16
+
17
+ <style>
18
+ .label {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 8px;
22
+ width: fit-content;
23
+ font-size: 13px;
24
+ font-weight: 500;
25
+ line-height: 1.4;
26
+ color: var(--color-text);
27
+ user-select: none;
28
+ }
29
+
30
+ /* Checkboxes and switches toggle on a label click, so show it */
31
+ .label:has(:global(:is(.checkbox, .switch))),
32
+ :global(:is(.checkbox, .switch)) + .label {
33
+ cursor: pointer;
34
+ }
35
+
36
+ /* Dim with a disabled control, whether wrapped or placed right before the label */
37
+ .label:has(:global(:disabled)),
38
+ :global(:disabled) + .label,
39
+ :global(:is(.checkbox, .switch):has(:disabled)) + .label {
40
+ cursor: not-allowed;
41
+ opacity: 0.5;
42
+ }
43
+
44
+ /* A wrapped control is already dimmed by the label */
45
+ .label:has(:global(:disabled)) :global(:disabled) {
46
+ opacity: 1;
47
+ }
48
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLLabelAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLLabelAttributes, 'children'> & {
4
+ /** Set `for` to the control's id, or wrap the control. */
5
+ children: Snippet;
6
+ };
7
+ declare const Label: import("svelte").Component<Props, {}, "">;
8
+ type Label = ReturnType<typeof Label>;
9
+ export default Label;
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAnchorAttributes, 'children'> & {
6
+ /** External (http) links open in a new tab. */
7
+ href: string;
8
+ children: Snippet;
9
+ };
10
+
11
+ let { href, target, rel, class: className, children, ...rest }: Props = $props();
12
+
13
+ let resolvedTarget = $derived(target ?? (href.startsWith('http') ? '_blank' : undefined));
14
+ let resolvedRel = $derived(rel ?? (resolvedTarget === '_blank' ? 'noopener' : undefined));
15
+ </script>
16
+
17
+ <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
18
+ <a {...rest} {href} target={resolvedTarget} rel={resolvedRel} class={['link', className]}>
19
+ {@render children()}
20
+ </a>
21
+
22
+ <style>
23
+ .link {
24
+ color: inherit;
25
+ text-decoration: underline;
26
+ text-decoration-thickness: max(1px, 0.0625em);
27
+ text-underline-offset: 4px;
28
+ outline: none;
29
+ cursor: pointer;
30
+ transition: opacity 200ms;
31
+ }
32
+
33
+ .link:hover {
34
+ opacity: 0.6;
35
+ }
36
+
37
+ .link:focus-visible {
38
+ text-decoration-thickness: max(2px, 0.125em);
39
+ }
40
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAnchorAttributes, 'children'> & {
4
+ /** External (http) links open in a new tab. */
5
+ href: string;
6
+ children: Snippet;
7
+ };
8
+ declare const Link: import("svelte").Component<Props, {}, "">;
9
+ type Link = ReturnType<typeof Link>;
10
+ export default Link;
@@ -0,0 +1,266 @@
1
+ <script module lang="ts">
2
+ export type NavbarTriggerProps = {
3
+ onclick: () => void;
4
+ 'aria-expanded': boolean;
5
+ 'aria-controls': string;
6
+ };
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import type { Snippet } from 'svelte';
11
+ import type { Attachment } from 'svelte/attachments';
12
+ import { prefersReducedMotion } from 'svelte/motion';
13
+ import { cubicOut } from 'svelte/easing';
14
+ import type { TransitionConfig } from 'svelte/transition';
15
+ import NavigationMenu from '../navigation-menu/NavigationMenu.svelte';
16
+
17
+ type Props = {
18
+ /** Whether the mobile menu is open. Bindable. */
19
+ open?: boolean;
20
+ /** Accessible name of the navigation landmark. */
21
+ label?: string;
22
+ /** Rendered at the start of the bar, e.g. a logo linking home. */
23
+ logo?: Snippet;
24
+ /** Rendered at the end of the bar on every screen size, e.g. a theme toggle. */
25
+ actions?: Snippet;
26
+ /**
27
+ * Renders the button that opens the mobile menu below 640px. Spread the given props onto it,
28
+ * e.g. `<Button {...props} icon={open ? X : Menu} label="Menu" />`.
29
+ */
30
+ trigger: Snippet<[NavbarTriggerProps, boolean]>;
31
+ /** `NavigationMenuItem`s, shown inline on desktop and stacked in the mobile menu. */
32
+ children: Snippet;
33
+ };
34
+
35
+ let {
36
+ open = $bindable(false),
37
+ label = 'Main',
38
+ logo,
39
+ actions,
40
+ trigger,
41
+ children
42
+ }: Props = $props();
43
+
44
+ const id = $props.id();
45
+ let triggerEl: HTMLDivElement;
46
+ let height = $state(0);
47
+
48
+ const triggerProps: NavbarTriggerProps = $derived({
49
+ onclick: () => (open = !open),
50
+ 'aria-expanded': open,
51
+ 'aria-controls': `${id}-panel`
52
+ });
53
+
54
+ function close({ restoreFocus = false } = {}) {
55
+ open = false;
56
+ if (restoreFocus) triggerEl.querySelector<HTMLElement>('[aria-controls]')?.focus();
57
+ }
58
+
59
+ function handlePanelClick(event: MouseEvent) {
60
+ if ((event.target as Element).closest('a[href]')) close();
61
+ }
62
+
63
+ /** Makes everything outside the navbar inert, so focus stays in the open menu. */
64
+ function inertOutside(node: HTMLElement) {
65
+ const changed: HTMLElement[] = [];
66
+ for (let el = node; el.parentElement && el !== document.body; el = el.parentElement) {
67
+ for (const sibling of el.parentElement.children) {
68
+ if (sibling !== el && sibling instanceof HTMLElement && !sibling.inert) {
69
+ sibling.inert = true;
70
+ changed.push(sibling);
71
+ }
72
+ }
73
+ }
74
+ return () => changed.forEach((el) => (el.inert = false));
75
+ }
76
+
77
+ /** Uncovers the menu from the top while its content stays in place. */
78
+ const reveal: (node: Element) => TransitionConfig = () => ({
79
+ duration: prefersReducedMotion.current ? 0 : 250,
80
+ easing: cubicOut,
81
+ css: (t) => `clip-path: inset(0 0 ${(1 - t) * 100}% 0);`
82
+ });
83
+
84
+ const panel: Attachment<HTMLDivElement> = (node) => {
85
+ node.querySelector<HTMLElement>('a[href], button')?.focus();
86
+ return inertOutside(node.parentElement!);
87
+ };
88
+
89
+ $effect(() => {
90
+ const desktop = window.matchMedia('(min-width: 640px)');
91
+ const handleChange = () => desktop.matches && close();
92
+ handleChange();
93
+ desktop.addEventListener('change', handleChange);
94
+ return () => desktop.removeEventListener('change', handleChange);
95
+ });
96
+
97
+ $effect(() => {
98
+ if (!open) return;
99
+
100
+ function handleKeydown(event: KeyboardEvent) {
101
+ if (event.key === 'Escape') close({ restoreFocus: true });
102
+ }
103
+
104
+ document.body.style.overflow = 'hidden';
105
+ window.addEventListener('keydown', handleKeydown);
106
+
107
+ return () => {
108
+ document.body.style.overflow = '';
109
+ window.removeEventListener('keydown', handleKeydown);
110
+ };
111
+ });
112
+ </script>
113
+
114
+ <header class="navbar" bind:offsetHeight={height}>
115
+ <div class="navbar-bar">
116
+ <div class="navbar-start">
117
+ {#if logo}{@render logo()}{/if}
118
+ <div class="navbar-desktop">
119
+ <NavigationMenu {label}>{@render children()}</NavigationMenu>
120
+ </div>
121
+ </div>
122
+
123
+ <div class="navbar-end">
124
+ {#if actions}{@render actions()}{/if}
125
+ <div class="navbar-trigger" bind:this={triggerEl}>
126
+ {@render trigger(triggerProps, open)}
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ {#if open}
132
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
133
+ <div
134
+ id="{id}-panel"
135
+ class="navbar-panel"
136
+ style:height="calc(100dvh - {height}px)"
137
+ onclick={handlePanelClick}
138
+ {@attach panel}
139
+ transition:reveal
140
+ >
141
+ <NavigationMenu {label} orientation="vertical">{@render children()}</NavigationMenu>
142
+ </div>
143
+ {/if}
144
+ </header>
145
+
146
+ <style>
147
+ .navbar {
148
+ position: sticky;
149
+ top: 0;
150
+ z-index: 50;
151
+ background-color: var(--color-bg);
152
+ user-select: none;
153
+ }
154
+
155
+ .navbar-bar {
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: space-between;
159
+ gap: 16px;
160
+ padding: 14px 16px;
161
+ }
162
+
163
+ .navbar-start {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 40px;
167
+ min-width: 0;
168
+ }
169
+
170
+ .navbar-end {
171
+ display: flex;
172
+ align-items: center;
173
+ gap: 6px;
174
+ }
175
+
176
+ .navbar-desktop {
177
+ display: none;
178
+ }
179
+
180
+ .navbar-trigger {
181
+ display: flex;
182
+ }
183
+
184
+ /* Mobile menu: full-height, left-aligned rows below the bar */
185
+ .navbar-panel {
186
+ position: absolute;
187
+ top: 100%;
188
+ left: 0;
189
+ right: 0;
190
+ box-sizing: border-box;
191
+ overflow-y: auto;
192
+ overscroll-behavior: contain;
193
+ background-color: var(--color-bg);
194
+ }
195
+
196
+ .navbar-panel :global(.navigation-menu) {
197
+ padding: 40px 16px 32px;
198
+ }
199
+
200
+ .navbar-panel :global(.navigation-menu-list) {
201
+ gap: 0;
202
+ }
203
+
204
+ /* Rows are padded into the gutter so the focus background has room; the text stays aligned */
205
+ .navbar-panel :global(.navigation-menu-trigger) {
206
+ box-sizing: content-box;
207
+ gap: 8px;
208
+ margin-inline: -10px;
209
+ padding: 10px;
210
+ border-radius: 10px;
211
+ font-size: 22px;
212
+ font-weight: 600;
213
+ line-height: 1.2;
214
+ }
215
+
216
+ .navbar-panel :global(:is(.navigation-menu-trigger, .navigation-menu-link):focus-visible) {
217
+ outline: none;
218
+ background-color: var(--color-surface);
219
+ }
220
+
221
+ .navbar-panel :global(.navigation-menu-chevron) {
222
+ width: 20px;
223
+ height: 20px;
224
+ color: var(--color-text-muted);
225
+ }
226
+
227
+ .navbar-panel :global(.navigation-menu-sub) {
228
+ gap: 0;
229
+ margin: 0 -10px;
230
+ padding-bottom: 8px;
231
+ }
232
+
233
+ /* Sub-items show only their title */
234
+ .navbar-panel :global(.navigation-menu-link) {
235
+ font-size: 17px;
236
+ color: var(--color-text-secondary);
237
+ }
238
+
239
+ .navbar-panel :global(.navigation-menu-link-title) {
240
+ font-weight: 400;
241
+ }
242
+
243
+ .navbar-panel :global(.navigation-menu-link--active) {
244
+ background-color: transparent;
245
+ color: var(--color-text);
246
+ }
247
+
248
+ .navbar-panel :global(.navigation-menu-link--active .navigation-menu-link-title) {
249
+ font-weight: 500;
250
+ }
251
+
252
+ .navbar-panel :global(:is(.navigation-menu-link-icon, .navigation-menu-link-description)) {
253
+ display: none;
254
+ }
255
+
256
+ @media (min-width: 640px) {
257
+ .navbar-desktop {
258
+ display: flex;
259
+ }
260
+
261
+ .navbar-trigger,
262
+ .navbar-panel {
263
+ display: none;
264
+ }
265
+ }
266
+ </style>
@@ -0,0 +1,26 @@
1
+ export type NavbarTriggerProps = {
2
+ onclick: () => void;
3
+ 'aria-expanded': boolean;
4
+ 'aria-controls': string;
5
+ };
6
+ import type { Snippet } from 'svelte';
7
+ type Props = {
8
+ /** Whether the mobile menu is open. Bindable. */
9
+ open?: boolean;
10
+ /** Accessible name of the navigation landmark. */
11
+ label?: string;
12
+ /** Rendered at the start of the bar, e.g. a logo linking home. */
13
+ logo?: Snippet;
14
+ /** Rendered at the end of the bar on every screen size, e.g. a theme toggle. */
15
+ actions?: Snippet;
16
+ /**
17
+ * Renders the button that opens the mobile menu below 640px. Spread the given props onto it,
18
+ * e.g. `<Button {...props} icon={open ? X : Menu} label="Menu" />`.
19
+ */
20
+ trigger: Snippet<[NavbarTriggerProps, boolean]>;
21
+ /** `NavigationMenuItem`s, shown inline on desktop and stacked in the mobile menu. */
22
+ children: Snippet;
23
+ };
24
+ declare const Navbar: import("svelte").Component<Props, {}, "open">;
25
+ type Navbar = ReturnType<typeof Navbar>;
26
+ export default Navbar;
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { setNavigationMenuContext } from './context.js';
4
+
5
+ type Props = {
6
+ /** Accessible name of the navigation landmark. */
7
+ label?: string;
8
+ /** `vertical` stacks the items and expands submenus in place, e.g. in a mobile menu. */
9
+ orientation?: 'horizontal' | 'vertical';
10
+ /** `NavigationMenuItem`s. */
11
+ children: Snippet;
12
+ };
13
+
14
+ let { label = 'Main', orientation = 'horizontal', children }: Props = $props();
15
+
16
+ const openDelay = 200;
17
+ const closeDelay = 200;
18
+
19
+ let current: string | null = $state(null);
20
+ let timer: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ setNavigationMenuContext({
23
+ get vertical() {
24
+ return orientation === 'vertical';
25
+ },
26
+ get current() {
27
+ return current;
28
+ },
29
+ open(id, immediate = false) {
30
+ clearTimeout(timer);
31
+ if (immediate || current !== null) current = id;
32
+ else timer = setTimeout(() => (current = id), openDelay);
33
+ },
34
+ leave() {
35
+ clearTimeout(timer);
36
+ timer = setTimeout(() => (current = null), closeDelay);
37
+ },
38
+ stay() {
39
+ clearTimeout(timer);
40
+ },
41
+ close() {
42
+ clearTimeout(timer);
43
+ current = null;
44
+ }
45
+ });
46
+
47
+ $effect(() => () => clearTimeout(timer));
48
+
49
+ function handleKeydown(event: KeyboardEvent) {
50
+ if (orientation === 'vertical') return;
51
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
52
+ const list = event.currentTarget as HTMLElement;
53
+ const triggers = Array.from(
54
+ list.querySelectorAll<HTMLElement>(':scope > li > .navigation-menu-trigger')
55
+ );
56
+ const index = triggers.indexOf(event.target as HTMLElement);
57
+ if (index === -1) return;
58
+ event.preventDefault();
59
+ const step = event.key === 'ArrowRight' ? 1 : -1;
60
+ triggers[(index + step + triggers.length) % triggers.length].focus();
61
+ }
62
+ </script>
63
+
64
+ <nav
65
+ aria-label={label}
66
+ class={['navigation-menu', { 'navigation-menu--vertical': orientation === 'vertical' }]}
67
+ >
68
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
69
+ <ul class="navigation-menu-list" onkeydown={handleKeydown}>
70
+ {@render children()}
71
+ </ul>
72
+ </nav>
73
+
74
+ <style>
75
+ .navigation-menu {
76
+ display: flex;
77
+ user-select: none;
78
+ }
79
+
80
+ .navigation-menu-list {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 24px;
84
+ margin: 0;
85
+ padding: 0;
86
+ list-style: none;
87
+ }
88
+
89
+ .navigation-menu--vertical .navigation-menu-list {
90
+ flex-direction: column;
91
+ align-items: stretch;
92
+ gap: 4px;
93
+ width: 100%;
94
+ }
95
+ </style>