zabi-components 7.0.2 → 8.0.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 (174) hide show
  1. package/README.md +46 -3
  2. package/THEMING.md +255 -15
  3. package/dist/atoms/ActionPanel.svelte +12 -7
  4. package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
  5. package/dist/atoms/Badge.svelte +93 -56
  6. package/dist/atoms/Badge.svelte.d.ts +15 -3
  7. package/dist/atoms/Button.svelte +49 -49
  8. package/dist/atoms/Button.svelte.d.ts +3 -0
  9. package/dist/atoms/Card.svelte +31 -28
  10. package/dist/atoms/Card.svelte.d.ts +5 -2
  11. package/dist/atoms/CardContent.svelte +10 -2
  12. package/dist/atoms/CardContent.svelte.d.ts +2 -0
  13. package/dist/atoms/CardFooter.svelte +10 -2
  14. package/dist/atoms/CardFooter.svelte.d.ts +2 -0
  15. package/dist/atoms/CardHeader.svelte +10 -2
  16. package/dist/atoms/CardHeader.svelte.d.ts +2 -0
  17. package/dist/atoms/Checkbox.svelte +4 -0
  18. package/dist/atoms/Checkbox.svelte.d.ts +2 -0
  19. package/dist/atoms/CodeBlock.svelte +15 -7
  20. package/dist/atoms/CodeBlock.svelte.d.ts +9 -3
  21. package/dist/atoms/ColorPicker.svelte +19 -9
  22. package/dist/atoms/ColorPicker.svelte.d.ts +3 -1
  23. package/dist/atoms/Container.svelte +2 -3
  24. package/dist/atoms/Divider.svelte +4 -4
  25. package/dist/atoms/FeatureCard.svelte +4 -3
  26. package/dist/atoms/Heading.svelte +56 -27
  27. package/dist/atoms/Heading.svelte.d.ts +17 -4
  28. package/dist/atoms/IconButton.svelte +30 -37
  29. package/dist/atoms/Input.svelte +26 -34
  30. package/dist/atoms/List.svelte +10 -2
  31. package/dist/atoms/List.svelte.d.ts +2 -0
  32. package/dist/atoms/ListItem.svelte +2 -2
  33. package/dist/atoms/ListItemLeading.svelte +4 -2
  34. package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
  35. package/dist/atoms/OptimizedImage.svelte +11 -2
  36. package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
  37. package/dist/atoms/Progress.svelte +4 -1
  38. package/dist/atoms/Progress.svelte.d.ts +2 -0
  39. package/dist/atoms/Radio.svelte +4 -0
  40. package/dist/atoms/Radio.svelte.d.ts +2 -0
  41. package/dist/atoms/Select.svelte +65 -67
  42. package/dist/atoms/Select.svelte.d.ts +4 -0
  43. package/dist/atoms/SelectionControl.svelte +5 -1
  44. package/dist/atoms/SelectionControl.svelte.d.ts +2 -0
  45. package/dist/atoms/Skeleton.svelte +5 -3
  46. package/dist/atoms/Table.svelte +2 -1
  47. package/dist/atoms/Text.svelte +34 -5
  48. package/dist/atoms/Text.svelte.d.ts +7 -1
  49. package/dist/atoms/Textarea.svelte +7 -6
  50. package/dist/atoms/ThemeToggle.svelte +17 -14
  51. package/dist/atoms/ThemeToggle.svelte.d.ts +2 -0
  52. package/dist/atoms/Toast.svelte +10 -8
  53. package/dist/atoms/Toggle.svelte +21 -13
  54. package/dist/atoms/Toggle.svelte.d.ts +7 -0
  55. package/dist/atoms/Tooltip.svelte +80 -22
  56. package/dist/atoms/Tooltip.svelte.d.ts +15 -0
  57. package/dist/components/index.d.ts +3 -1
  58. package/dist/components/index.d.ts.map +1 -1
  59. package/dist/components/organisms/index.d.ts +1 -0
  60. package/dist/components/organisms/index.d.ts.map +1 -1
  61. package/dist/components/util/cn.d.ts +8 -0
  62. package/dist/components/util/cn.d.ts.map +1 -0
  63. package/dist/components/util/ssr-safe.d.ts +4 -4
  64. package/dist/components/util/ssr-safe.d.ts.map +1 -1
  65. package/dist/index.d.ts +3 -1
  66. package/dist/index.js +3 -6
  67. package/dist/molecules/Alert.svelte +43 -64
  68. package/dist/molecules/Alert.svelte.d.ts +7 -1
  69. package/dist/molecules/ComponentDemo.svelte +10 -2
  70. package/dist/molecules/ComponentDemo.svelte.d.ts +2 -0
  71. package/dist/molecules/ContactForm.svelte +12 -2
  72. package/dist/molecules/ContactForm.svelte.d.ts +2 -0
  73. package/dist/molecules/Dropdown.svelte +40 -11
  74. package/dist/molecules/Dropdown.svelte.d.ts +4 -0
  75. package/dist/molecules/EmptyState.svelte +2 -1
  76. package/dist/molecules/Form.svelte +25 -14
  77. package/dist/molecules/Form.svelte.d.ts +13 -5
  78. package/dist/molecules/FormField.svelte +2 -1
  79. package/dist/molecules/Header.svelte +13 -3
  80. package/dist/molecules/Header.svelte.d.ts +2 -0
  81. package/dist/molecules/ImageUpload.svelte +54 -15
  82. package/dist/molecules/ImageUpload.svelte.d.ts +16 -5
  83. package/dist/molecules/Modal.svelte +110 -31
  84. package/dist/molecules/Modal.svelte.d.ts +9 -6
  85. package/dist/molecules/NavigationMenu.svelte +15 -3
  86. package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
  87. package/dist/molecules/NavigationMenuContent.svelte +36 -38
  88. package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
  89. package/dist/molecules/NavigationMenuItem.svelte +10 -2
  90. package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
  91. package/dist/molecules/NavigationMenuLink.svelte +10 -2
  92. package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
  93. package/dist/molecules/NavigationMenuList.svelte +10 -2
  94. package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
  95. package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
  96. package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
  97. package/dist/molecules/Page.svelte +8 -2
  98. package/dist/molecules/Page.svelte.d.ts +2 -0
  99. package/dist/molecules/PropsTable.svelte +6 -3
  100. package/dist/molecules/PropsTable.svelte.d.ts +2 -0
  101. package/dist/molecules/RadioGroup.svelte +5 -1
  102. package/dist/molecules/RadioGroup.svelte.d.ts +2 -0
  103. package/dist/molecules/Section.svelte +12 -4
  104. package/dist/molecules/Section.svelte.d.ts +3 -1
  105. package/dist/molecules/SidebarBrandHeader.svelte +13 -4
  106. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +2 -0
  107. package/dist/molecules/SidebarFooter.svelte +12 -4
  108. package/dist/molecules/SidebarFooter.svelte.d.ts +2 -0
  109. package/dist/molecules/SidebarNavSection.svelte +12 -4
  110. package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
  111. package/dist/molecules/SlideUp.svelte +85 -21
  112. package/dist/molecules/SlideUp.svelte.d.ts +5 -4
  113. package/dist/molecules/Tabs.svelte +25 -19
  114. package/dist/molecules/Tabs.svelte.d.ts +8 -4
  115. package/dist/molecules/Toaster.svelte +3 -2
  116. package/dist/molecules/ToasterToast.svelte +41 -10
  117. package/dist/molecules/ToasterToast.svelte.d.ts +2 -0
  118. package/dist/organisms/SidebarAccountPanel.svelte +4 -0
  119. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +2 -0
  120. package/dist/organisms/SidebarNavigation.svelte +102 -59
  121. package/dist/organisms/SidebarNavigation.svelte.d.ts +2 -0
  122. package/dist/organisms/SidebarPanel.svelte +19 -11
  123. package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
  124. package/dist/organisms/SidebarShell.svelte +112 -0
  125. package/dist/organisms/SidebarShell.svelte.d.ts +52 -0
  126. package/dist/organisms/TopNavbar.svelte +88 -17
  127. package/dist/organisms/TopNavbar.svelte.d.ts +2 -0
  128. package/dist/organisms/index.d.ts +1 -0
  129. package/dist/organisms/index.js +1 -0
  130. package/dist/routes/lib/variant-utils.d.ts +26 -0
  131. package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
  132. package/dist/types/events.js +1 -0
  133. package/dist/types/index.js +3 -0
  134. package/dist/util/cn.d.ts +7 -0
  135. package/dist/util/cn.js +48 -0
  136. package/dist/util/ssr-safe.d.ts +4 -4
  137. package/dist/zabi-components-colors.css +2 -2
  138. package/dist/zabi-components-theme-dark-only.css +139 -92
  139. package/dist/zabi-components-theme-dark.css +139 -92
  140. package/dist/zabi-components-theme-only.css +268 -122
  141. package/dist/zabi-components-theme.css +268 -122
  142. package/dist/zabi-components.css +831 -883
  143. package/docs/lucide-icons.md +3 -1
  144. package/package.json +19 -24
  145. package/dist/lib/layout-width-tokens.d.ts +0 -17
  146. package/dist/lib/layout-width-tokens.d.ts.map +0 -1
  147. package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
  148. package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
  149. package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
  150. package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
  151. package/dist/lib/showcase/component-docs/List.d.ts +0 -3
  152. package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
  153. package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
  154. package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
  155. package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
  156. package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
  157. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
  158. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
  159. package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
  160. package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
  161. package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
  162. package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
  163. package/dist/lib/showcase/component-docs/index.d.ts +0 -4
  164. package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
  165. package/dist/lib/showcase/components-catalog.d.ts +0 -3
  166. package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
  167. package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
  168. package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
  169. package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
  170. package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
  171. package/dist/routes/lib/focus-utils.d.ts +0 -12
  172. package/dist/routes/lib/focus-utils.ts +0 -110
  173. package/dist/routes/lib/ssr-safe.ts +0 -58
  174. package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import {
3
- trapFocus,
4
+ getFocusableElements,
4
5
  saveFocus,
5
6
  returnFocus,
6
7
  focusFirstElement,
@@ -11,6 +12,7 @@
11
12
  import CardContent from '../atoms/CardContent.svelte';
12
13
  import CardFooter from '../atoms/CardFooter.svelte';
13
14
 
15
+ import { cn } from "../util/cn.js";
14
16
  type Size = 'sm' | 'md' | 'lg';
15
17
 
16
18
  interface Props {
@@ -18,10 +20,16 @@
18
20
  title?: string;
19
21
  description?: string;
20
22
  size?: Size;
23
+ /** Render the close button even when there is no `title`. */
24
+ showClose?: boolean;
21
25
  onclick?: (event: Event) => void;
22
26
  onkeydown?: (event: Event) => void;
23
- /** On the `role="dialog"` root (testing, analytics). */
27
+ /** On the `role="dialog"` panel (testing, analytics). */
24
28
  "data-testid"?: string;
29
+ /** Extra classes for the dialog panel. */
30
+ class?: string;
31
+ children?: Snippet;
32
+ footer?: Snippet;
25
33
  }
26
34
 
27
35
  let {
@@ -29,19 +37,21 @@
29
37
  title = '',
30
38
  description = '',
31
39
  size = 'md',
40
+ showClose = true,
41
+ class: className = "",
32
42
  onclick,
33
43
  onkeydown,
34
44
  "data-testid": dataTestId = undefined,
35
45
  children,
36
46
  footer,
37
47
  ...restProps
38
- }: Props & { children?: any; footer?: any } = $props();
48
+ }: Props = $props();
39
49
 
40
50
  const modalTitleId = generateId('modal-title');
41
51
  const modalDescriptionId = generateId('modal-description');
42
52
 
43
53
  let modalContainer = $state<HTMLDivElement>();
44
- let cleanupFocusTrap: (() => void) | null = null;
54
+ let focusActive = false;
45
55
 
46
56
  const sizeClasses = $derived(
47
57
  {
@@ -51,13 +61,33 @@
51
61
  }[size] || 'w-full md:w-[28rem]',
52
62
  );
53
63
 
64
+ /** Ref-counted on `<body>` so nested Modal/SlideUp overlays share one lock. */
65
+ function lockBodyScroll(): () => void {
66
+ const body = document.body;
67
+ const count = Number(body.dataset.zabiScrollLock ?? '0');
68
+ if (count === 0) {
69
+ body.dataset.zabiScrollLockOverflow = body.style.overflow;
70
+ body.style.overflow = 'hidden';
71
+ }
72
+ body.dataset.zabiScrollLock = String(count + 1);
73
+ return () => {
74
+ const next = Number(body.dataset.zabiScrollLock ?? '1') - 1;
75
+ if (next <= 0) {
76
+ body.style.overflow = body.dataset.zabiScrollLockOverflow ?? '';
77
+ delete body.dataset.zabiScrollLock;
78
+ delete body.dataset.zabiScrollLockOverflow;
79
+ } else {
80
+ body.dataset.zabiScrollLock = String(next);
81
+ }
82
+ };
83
+ }
84
+
54
85
  function closeModal(event?: Event) {
55
86
  isOpen = false;
56
- if (cleanupFocusTrap) {
57
- cleanupFocusTrap();
58
- cleanupFocusTrap = null;
87
+ if (focusActive) {
88
+ focusActive = false;
89
+ returnFocus();
59
90
  }
60
- returnFocus();
61
91
  if (onclick && event) {
62
92
  onclick(event);
63
93
  }
@@ -67,17 +97,52 @@
67
97
  const container = modalContainer;
68
98
  if (isOpen && container) {
69
99
  saveFocus();
70
- cleanupFocusTrap = trapFocus(container);
71
- setTimeout(() => {
100
+ focusActive = true;
101
+ const unlockScroll = lockBodyScroll();
102
+ const t = setTimeout(() => {
72
103
  focusFirstElement(container);
73
104
  }, 0);
74
- } else if (!isOpen && cleanupFocusTrap) {
75
- cleanupFocusTrap();
76
- cleanupFocusTrap = null;
77
- returnFocus();
105
+ return () => {
106
+ clearTimeout(t);
107
+ unlockScroll();
108
+ if (focusActive) {
109
+ focusActive = false;
110
+ returnFocus();
111
+ }
112
+ };
78
113
  }
79
114
  });
80
115
 
116
+ /** Re-queries focusables on every Tab so content added while open stays inside the trap. */
117
+ function handleTrapKeydown(event: KeyboardEvent) {
118
+ const container = modalContainer;
119
+ if (event.key !== 'Tab' || !container) return;
120
+ // A nested dialog handles its own Tab cycle.
121
+ const owner = (event.target as Element | null)?.closest?.('[role="dialog"]');
122
+ if (owner && owner !== container) return;
123
+
124
+ const focusable = getFocusableElements(container);
125
+ if (focusable.length === 0) {
126
+ event.preventDefault();
127
+ container.focus();
128
+ return;
129
+ }
130
+ const first = focusable[0];
131
+ const last = focusable[focusable.length - 1];
132
+ const active = document.activeElement as HTMLElement | null;
133
+
134
+ if (!active || !focusable.includes(active)) {
135
+ event.preventDefault();
136
+ first.focus();
137
+ } else if (event.shiftKey && active === first) {
138
+ event.preventDefault();
139
+ last.focus();
140
+ } else if (!event.shiftKey && active === last) {
141
+ event.preventDefault();
142
+ first.focus();
143
+ }
144
+ }
145
+
81
146
  function handleBackdropClick(event: Event) {
82
147
  if (event.target === event.currentTarget) {
83
148
  closeModal(event);
@@ -86,7 +151,9 @@
86
151
 
87
152
  function handleKeydown(event: Event) {
88
153
  const keyboardEvent = event as KeyboardEvent;
89
- if (keyboardEvent.key === 'Escape') {
154
+ // `defaultPrevented` means a nested dialog already closed itself.
155
+ if (keyboardEvent.key === 'Escape' && !keyboardEvent.defaultPrevented) {
156
+ keyboardEvent.preventDefault();
90
157
  closeModal(event);
91
158
  }
92
159
  onkeydown?.(event);
@@ -94,52 +161,64 @@
94
161
  </script>
95
162
 
96
163
  {#if isOpen}
164
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
97
165
  <div
98
166
  class="fixed inset-0 z-modal flex cursor-pointer items-end justify-center bg-overlay p-0 md:items-center md:p-4"
99
167
  onclick={handleBackdropClick}
100
168
  onkeydown={handleKeydown}
101
- role="dialog"
102
- aria-modal="true"
103
- aria-labelledby={title ? modalTitleId : undefined}
104
- aria-describedby={description ? modalDescriptionId : undefined}
105
- tabindex="-1"
106
- data-testid={dataTestId}
107
- {...restProps}
169
+ role="presentation"
108
170
  >
109
171
  <div
110
172
  bind:this={modalContainer}
111
- class="flex max-h-[90vh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-3xl bg-card p-0 shadow-xl animate-[slideUp_0.3s_ease-out] md:animate-none md:rounded-3xl {sizeClasses}"
173
+ class={cn(
174
+ "flex max-h-[90vh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-overlay border border-border-overlay bg-surface-overlay p-0 shadow-lg animate-[slideUp_0.3s_ease-out] md:animate-none md:rounded-overlay",
175
+ sizeClasses,
176
+ className,
177
+ )}
178
+ role="dialog"
179
+ aria-modal="true"
180
+ aria-labelledby={title ? modalTitleId : undefined}
181
+ aria-describedby={description ? modalDescriptionId : undefined}
182
+ tabindex="-1"
183
+ data-testid={dataTestId}
184
+ onkeydown={handleTrapKeydown}
185
+ {...restProps}
112
186
  >
113
- <Card variant="default" fullWidth={false}>
114
- {#if title || description}
187
+ <!-- The panel owns padding: Card's own p-6 would stack with the header's px-6/pt-6 and indent the title 24px past the body. -->
188
+ <Card variant="flat" fullWidth={false} className="bg-transparent! p-0!">
189
+ {#if title || description || showClose}
115
190
  <CardHeader
116
191
  {description}
117
192
  descriptionId={description ? modalDescriptionId : undefined}
118
193
  className="px-6 pt-6 pb-4"
119
194
  >
120
- {#if title}
121
- <div class="flex items-center justify-between">
195
+ <div class="flex items-center {title ? 'justify-between' : 'justify-end'}">
196
+ {#if title}
122
197
  <h2
123
198
  id={modalTitleId}
124
199
  class="text-2xl font-normal leading-8 tracking-normal text-headline"
125
200
  >
126
201
  {title}
127
202
  </h2>
203
+ {/if}
204
+ {#if showClose}
128
205
  <button
129
206
  type="button"
130
207
  onclick={closeModal}
131
- class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-2xl text-description transition-colors hover:bg-base-100 hover:text-headline"
208
+ class="focus-ring flex size-8 cursor-pointer items-center justify-center rounded-control text-2xl text-description transition-colors hover:bg-surface-overlay-hover hover:text-headline"
132
209
  aria-label="Close"
133
210
  >
134
211
  ×
135
212
  </button>
136
- </div>
137
- {/if}
213
+ {/if}
214
+ </div>
138
215
  </CardHeader>
139
216
  {/if}
140
217
 
141
218
  {#if children}
142
- <CardContent className="flex-1">
219
+ <CardContent
220
+ className="flex-1 px-6 {title || description || showClose ? '' : 'pt-6'} {footer ? '' : 'pb-6'}"
221
+ >
143
222
  {@render children?.()}
144
223
  </CardContent>
145
224
  {/if}
@@ -1,18 +1,21 @@
1
+ import type { Snippet } from 'svelte';
1
2
  type Size = 'sm' | 'md' | 'lg';
2
3
  interface Props {
3
4
  isOpen?: boolean;
4
5
  title?: string;
5
6
  description?: string;
6
7
  size?: Size;
8
+ /** Render the close button even when there is no `title`. */
9
+ showClose?: boolean;
7
10
  onclick?: (event: Event) => void;
8
11
  onkeydown?: (event: Event) => void;
9
- /** On the `role="dialog"` root (testing, analytics). */
12
+ /** On the `role="dialog"` panel (testing, analytics). */
10
13
  "data-testid"?: string;
14
+ /** Extra classes for the dialog panel. */
15
+ class?: string;
16
+ children?: Snippet;
17
+ footer?: Snippet;
11
18
  }
12
- type $$ComponentProps = Props & {
13
- children?: any;
14
- footer?: any;
15
- };
16
- declare const Modal: import("svelte").Component<$$ComponentProps, {}, "isOpen">;
19
+ declare const Modal: import("svelte").Component<Props, {}, "isOpen">;
17
20
  type Modal = ReturnType<typeof Modal>;
18
21
  export default Modal;
@@ -9,6 +9,8 @@
9
9
  import NavigationMenuLink from "./NavigationMenuLink.svelte";
10
10
 
11
11
  import { generateId } from "../util/ssr-safe.js";
12
+
13
+ import { cn } from "../util/cn.js";
12
14
  import {
13
15
  NAVIGATION_MENU_CONTEXT_KEY,
14
16
  navigationMenuStableInstanceId,
@@ -33,24 +35,34 @@
33
35
 
34
36
  interface Props {
35
37
  viewport?: boolean | "mobile";
38
+ class?: string;
39
+ /** @deprecated use `class`. */
36
40
  className?: string;
37
41
  items?: NavigationMenuItemData[];
38
42
  children?: Snippet;
39
43
  listClassName?: string;
40
44
  /** Panel id / `aria-controls` prefix; pass for SSR or multiple menus. If omitted with non-empty `items`, a deterministic hash is used. */
41
45
  menuId?: string;
46
+ /** Accessible name for the `<nav>` landmark. */
47
+ ariaLabel?: string;
42
48
  }
43
49
 
44
50
  let {
45
51
  viewport = true,
46
- className = "",
52
+ class: classAttr = "",
53
+ className: legacyClass = "",
47
54
  items = [],
48
55
  children,
49
56
  listClassName = "",
50
57
  menuId = "",
58
+ ariaLabel = "Main navigation",
51
59
  ...restProps
52
60
  }: Props = $props();
53
61
 
62
+ /** `class` is the public prop; `className` is a deprecated alias.
63
+ * Both are merged here so existing call sites keep working. */
64
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
65
+
54
66
  let activeItem = $state<string | null>(null);
55
67
  let isMobile = $state(false);
56
68
  let containerElement = $state<HTMLElement | null>(null);
@@ -104,8 +116,8 @@
104
116
 
105
117
  <nav
106
118
  bind:this={containerElement}
107
- class="relative {className}"
108
- aria-label="Main navigation"
119
+ class={cn("relative", className)}
120
+ aria-label={ariaLabel}
109
121
  {...restProps}
110
122
  >
111
123
  {#if items.length > 0}
@@ -15,12 +15,16 @@ export interface NavigationMenuItemData {
15
15
  }
16
16
  interface Props {
17
17
  viewport?: boolean | "mobile";
18
+ class?: string;
19
+ /** @deprecated use `class`. */
18
20
  className?: string;
19
21
  items?: NavigationMenuItemData[];
20
22
  children?: Snippet;
21
23
  listClassName?: string;
22
24
  /** Panel id / `aria-controls` prefix; pass for SSR or multiple menus. If omitted with non-empty `items`, a deterministic hash is used. */
23
25
  menuId?: string;
26
+ /** Accessible name for the `<nav>` landmark. */
27
+ ariaLabel?: string;
24
28
  }
25
29
  declare const NavigationMenu: import("svelte").Component<Props, {}, "">;
26
30
  type NavigationMenu = ReturnType<typeof NavigationMenu>;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { getContext } from "svelte";
4
- import { onMount } from "svelte";
4
+ import { cn } from "../util/cn.js";
5
5
  import {
6
6
  NAVIGATION_MENU_CONTEXT_KEY,
7
7
  navigationMenuPanelId,
@@ -10,19 +10,25 @@
10
10
 
11
11
  interface Props {
12
12
  value?: string;
13
+ class?: string;
14
+ /** @deprecated use `class`. */
13
15
  className?: string;
14
16
  children?: Snippet;
15
17
  }
16
18
 
17
19
  let {
18
20
  value = "",
19
- className = "",
21
+ class: classAttr = "",
22
+ className: legacyClass = "",
20
23
  children,
21
24
  ...restProps
22
25
  }: Props = $props();
23
26
 
27
+ /** `class` is the public prop; `className` is a deprecated alias.
28
+ * Both are merged here so existing call sites keep working. */
29
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
30
+
24
31
  let contentElement = $state<HTMLElement | null>(null);
25
- let parentItem = $state<HTMLElement | null>(null);
26
32
 
27
33
  const context = getContext<NavigationMenuContextValue>(
28
34
  NAVIGATION_MENU_CONTEXT_KEY,
@@ -36,42 +42,37 @@
36
42
  : "",
37
43
  );
38
44
 
39
- function handleKeydown(event: KeyboardEvent) {
40
- if (event.key === "Escape") {
41
- event.preventDefault();
42
- context?.setActiveItem(null);
43
- const trigger = parentItem?.querySelector('[data-navigation-menu-trigger]') as HTMLElement;
44
- trigger?.focus();
45
- }
46
- }
45
+ /** Disclosure panel: one document listener while open, removed on close/unmount. */
46
+ $effect(() => {
47
+ if (!isActive || !contentElement) return;
48
+ const panel: HTMLElement = contentElement;
47
49
 
48
- function handleClickOutside(event: MouseEvent) {
49
- if (
50
- contentElement &&
51
- !contentElement.contains(event.target as Node) &&
52
- parentItem &&
53
- !parentItem.contains(event.target as Node)
54
- ) {
55
- context?.setActiveItem(null);
56
- }
57
- }
50
+ const parentItem = panel.closest<HTMLElement>(
51
+ "[data-navigation-menu-item]",
52
+ );
58
53
 
59
- onMount(() => {
60
- if (contentElement) {
61
- parentItem = contentElement.closest('[data-navigation-menu-item]') as HTMLElement;
62
- }
63
- if (isActive) {
64
- document.addEventListener("mousedown", handleClickOutside);
54
+ function handleClickOutside(event: MouseEvent) {
55
+ const target = event.target as Node;
56
+ if (!panel.contains(target) && !parentItem?.contains(target)) {
57
+ context?.setActiveItem(null);
58
+ }
65
59
  }
66
- });
67
60
 
68
- $effect(() => {
69
- if (isActive) {
70
- document.addEventListener("mousedown", handleClickOutside);
71
- return () => {
72
- document.removeEventListener("mousedown", handleClickOutside);
73
- };
61
+ function handleKeydown(event: KeyboardEvent) {
62
+ if (event.key !== "Escape") return;
63
+ event.preventDefault();
64
+ context?.setActiveItem(null);
65
+ parentItem
66
+ ?.querySelector<HTMLElement>("[data-navigation-menu-trigger]")
67
+ ?.focus();
74
68
  }
69
+
70
+ document.addEventListener("mousedown", handleClickOutside);
71
+ panel.addEventListener("keydown", handleKeydown);
72
+ return () => {
73
+ document.removeEventListener("mousedown", handleClickOutside);
74
+ panel.removeEventListener("keydown", handleKeydown);
75
+ };
75
76
  });
76
77
  </script>
77
78
 
@@ -79,11 +80,8 @@
79
80
  <div
80
81
  bind:this={contentElement}
81
82
  id={panelId || undefined}
82
- class="absolute left-0 top-full mt-2 bg-card rounded-lg shadow-lg border border-border p-4 z-dropdown min-w-[200px] transition-all duration-200 ease-in-out {className}"
83
- role="menu"
84
- onkeydown={handleKeydown}
83
+ class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-control shadow-lg border border-border p-4 z-dropdown min-w-[200px] transition-all duration-200 ease-in-out", className)}
85
84
  data-navigation-menu-content
86
- tabindex="-1"
87
85
  {...restProps}
88
86
  >
89
87
  {@render children?.()}
@@ -1,6 +1,8 @@
1
1
  import type { Snippet } from "svelte";
2
2
  interface Props {
3
3
  value?: string;
4
+ class?: string;
5
+ /** @deprecated use `class`. */
4
6
  className?: string;
5
7
  children?: Snippet;
6
8
  }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { getContext } from "svelte";
4
+ import { cn } from "../util/cn.js";
4
5
  import {
5
6
  NAVIGATION_MENU_CONTEXT_KEY,
6
7
  type NavigationMenuContextValue,
@@ -8,17 +9,24 @@
8
9
 
9
10
  interface Props {
10
11
  value?: string;
12
+ class?: string;
13
+ /** @deprecated use `class`. */
11
14
  className?: string;
12
15
  children?: Snippet;
13
16
  }
14
17
 
15
18
  let {
16
19
  value = "",
17
- className = "",
20
+ class: classAttr = "",
21
+ className: legacyClass = "",
18
22
  children,
19
23
  ...restProps
20
24
  }: Props = $props();
21
25
 
26
+ /** `class` is the public prop; `className` is a deprecated alias.
27
+ * Both are merged here so existing call sites keep working. */
28
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
29
+
22
30
  const context = getContext<NavigationMenuContextValue>(
23
31
  NAVIGATION_MENU_CONTEXT_KEY,
24
32
  );
@@ -27,7 +35,7 @@
27
35
  </script>
28
36
 
29
37
  <li
30
- class="relative {className}"
38
+ class={cn("relative", className)}
31
39
  role="none"
32
40
  data-navigation-menu-item
33
41
  data-active={isActive}
@@ -1,6 +1,8 @@
1
1
  import type { Snippet } from "svelte";
2
2
  interface Props {
3
3
  value?: string;
4
+ class?: string;
5
+ /** @deprecated use `class`. */
4
6
  className?: string;
5
7
  children?: Snippet;
6
8
  }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { getContext } from "svelte";
4
+ import { cn } from "../util/cn.js";
4
5
  import {
5
6
  NAVIGATION_MENU_CONTEXT_KEY,
6
7
  type NavigationMenuContextValue,
@@ -9,6 +10,8 @@
9
10
  interface Props {
10
11
  href?: string;
11
12
  asChild?: boolean;
13
+ class?: string;
14
+ /** @deprecated use `class`. */
12
15
  className?: string;
13
16
  children?: Snippet;
14
17
  }
@@ -16,11 +19,16 @@
16
19
  let {
17
20
  href = "#",
18
21
  asChild = false,
19
- className = "",
22
+ class: classAttr = "",
23
+ className: legacyClass = "",
20
24
  children,
21
25
  ...restProps
22
26
  }: Props = $props();
23
27
 
28
+ /** `class` is the public prop; `className` is a deprecated alias.
29
+ * Both are merged here so existing call sites keep working. */
30
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
31
+
24
32
  const context = getContext<NavigationMenuContextValue>(
25
33
  NAVIGATION_MENU_CONTEXT_KEY,
26
34
  );
@@ -30,7 +38,7 @@
30
38
  }
31
39
 
32
40
  const linkClasses =
33
- "focus-ring focus-ring--nav block cursor-pointer rounded-md px-4 py-2 text-sm text-nav-menu-item no-underline transition-colors duration-150 outline-none hover:bg-nav-menu-hover hover:text-nav-menu-item-hover {className}";
41
+ "focus-ring focus-ring--nav block cursor-pointer rounded-control px-4 py-2 text-sm text-nav-menu-item no-underline transition-colors duration-150 outline-none hover:bg-nav-menu-hover hover:text-nav-menu-item-hover {className}";
34
42
  </script>
35
43
 
36
44
  {#if asChild}
@@ -2,6 +2,8 @@ import type { Snippet } from "svelte";
2
2
  interface Props {
3
3
  href?: string;
4
4
  asChild?: boolean;
5
+ class?: string;
6
+ /** @deprecated use `class`. */
5
7
  className?: string;
6
8
  children?: Snippet;
7
9
  }
@@ -1,20 +1,28 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
+ import { cn } from "../util/cn.js";
3
4
 
4
5
  interface Props {
6
+ class?: string;
7
+ /** @deprecated use `class`. */
5
8
  className?: string;
6
9
  children?: Snippet;
7
10
  }
8
11
 
9
12
  let {
10
- className = "",
13
+ class: classAttr = "",
14
+ className: legacyClass = "",
11
15
  children,
12
16
  ...restProps
13
17
  }: Props = $props();
18
+
19
+ /** `class` is the public prop; `className` is a deprecated alias.
20
+ * Both are merged here so existing call sites keep working. */
21
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
14
22
  </script>
15
23
 
16
24
  <ul
17
- class="flex flex-row items-center gap-1 list-none m-0 p-0 {className}"
25
+ class={cn("flex flex-row items-center gap-1 list-none m-0 p-0", className)}
18
26
  role="menubar"
19
27
  aria-orientation="horizontal"
20
28
  data-navigation-menu-list
@@ -1,5 +1,7 @@
1
1
  import type { Snippet } from "svelte";
2
2
  interface Props {
3
+ class?: string;
4
+ /** @deprecated use `class`. */
3
5
  className?: string;
4
6
  children?: Snippet;
5
7
  }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { getContext } from "svelte";
4
+ import { cn } from "../util/cn.js";
4
5
  import {
5
6
  NAVIGATION_MENU_CONTEXT_KEY,
6
7
  navigationMenuPanelId,
@@ -9,17 +10,24 @@
9
10
 
10
11
  interface Props {
11
12
  value?: string;
13
+ class?: string;
14
+ /** @deprecated use `class`. */
12
15
  className?: string;
13
16
  children?: Snippet;
14
17
  }
15
18
 
16
19
  let {
17
20
  value = "",
18
- className = "",
21
+ class: classAttr = "",
22
+ className: legacyClass = "",
19
23
  children,
20
24
  ...restProps
21
25
  }: Props = $props();
22
26
 
27
+ /** `class` is the public prop; `className` is a deprecated alias.
28
+ * Both are merged here so existing call sites keep working. */
29
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
30
+
23
31
  const context = getContext<NavigationMenuContextValue>(
24
32
  NAVIGATION_MENU_CONTEXT_KEY,
25
33
  );
@@ -58,10 +66,15 @@
58
66
 
59
67
  <button
60
68
  type="button"
61
- class="focus-ring focus-ring--nav inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-nav-menu-item transition-colors duration-150 outline-none hover:bg-nav-menu-hover hover:text-nav-menu-item-hover data-[active=true]:bg-nav-menu-active data-[active=true]:text-nav-menu-item-active {className}"
69
+ class={cn(
70
+ "focus-ring focus-ring--nav inline-flex cursor-pointer items-center justify-center gap-2 rounded-control bg-transparent px-4 py-2 text-sm font-medium transition-colors duration-150 outline-none",
71
+ isActive
72
+ ? "bg-nav-menu-active text-nav-menu-item-active"
73
+ : "text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover",
74
+ className,
75
+ )}
62
76
  aria-expanded={isActive ? 'true' : 'false'}
63
77
  aria-controls={panelId || undefined}
64
- aria-haspopup="true"
65
78
  onclick={handleClick}
66
79
  onkeydown={handleKeydown}
67
80
  data-navigation-menu-trigger