zabi-components 7.0.1 → 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 +89 -57
  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 +3 -437
  138. package/dist/zabi-components-theme-dark-only.css +223 -103
  139. package/dist/zabi-components-theme-dark.css +223 -103
  140. package/dist/zabi-components-theme-only.css +347 -107
  141. package/dist/zabi-components-theme.css +347 -107
  142. package/dist/zabi-components.css +953 -843
  143. package/docs/lucide-icons.md +3 -1
  144. package/package.json +20 -25
  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,27 +1,47 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
3
4
  import Button from "../atoms/Button.svelte";
4
5
  import { Image } from "@lucide/svelte";
6
+ import { cn } from "../util/cn.js";
5
7
 
6
- interface Props {
8
+ export type ImageUploadFileDetail = {
9
+ /** Selected file, or `null` when the image is removed. */
10
+ file: File | null;
11
+ /** Object URL used for the preview, or `null`. */
12
+ url: string | null;
13
+ };
14
+
15
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick"> & {
16
+ /** Extra classes for the host element. */
17
+ class?: string;
18
+ /** Preview URL; `bind:value` to read it. */
7
19
  value?: string | null;
8
20
  disabled?: boolean;
9
21
  accept?: string;
10
22
  placeholder?: string;
11
23
  errorMessage?: string;
24
+ /** Native `change` event from the hidden file input. */
12
25
  onchange?: (event: Event) => void;
26
+ /** Fires when the file chooser is opened. */
13
27
  onclick?: (event: Event) => void;
14
- }
28
+ /** Selected file and preview URL; `{ file: null, url: null }` on remove. */
29
+ onfileselect?: (detail: ImageUploadFileDetail) => void;
30
+ };
15
31
 
16
32
  let {
17
- value = null,
33
+ class: className = "",
34
+ value = $bindable(null),
18
35
  disabled = false,
19
36
  accept = "image/*",
20
37
  placeholder = "No image selected",
21
38
  errorMessage = "",
22
- children,
39
+ onchange,
40
+ onclick,
41
+ onfileselect,
42
+ children: _children,
23
43
  ...restProps
24
- } = $props<Props & { children?: any }>();
44
+ }: Props = $props();
25
45
 
26
46
  let fileInput = $state<HTMLInputElement>();
27
47
  let currentObjectUrl = $state<string | null>(null);
@@ -40,13 +60,17 @@
40
60
  if (!input.files || input.files.length === 0) return;
41
61
 
42
62
  const file = input.files[0];
63
+ let url: string | null = null;
43
64
 
44
65
  if (typeof URL !== "undefined" && URL.createObjectURL) {
45
66
  revokeCurrentObjectUrl();
46
- const url = URL.createObjectURL(file);
67
+ url = URL.createObjectURL(file);
47
68
  value = url;
48
69
  currentObjectUrl = url;
49
70
  }
71
+
72
+ onchange?.(event);
73
+ onfileselect?.({ file, url });
50
74
  }
51
75
 
52
76
  function removeImage() {
@@ -54,28 +78,38 @@
54
78
 
55
79
  revokeCurrentObjectUrl();
56
80
  value = null;
81
+ if (fileInput) fileInput.value = "";
82
+ onfileselect?.({ file: null, url: null });
57
83
  }
58
84
 
59
- function triggerFileSelect() {
85
+ function triggerFileSelect(event: Event) {
60
86
  if (disabled) return;
87
+ onclick?.(event);
61
88
  fileInput?.click();
62
89
  }
63
90
 
91
+ function handleDropZoneKeydown(event: KeyboardEvent) {
92
+ if (event.key === "Enter" || event.key === " ") {
93
+ event.preventDefault();
94
+ triggerFileSelect(event);
95
+ }
96
+ }
97
+
64
98
  onDestroy(() => {
65
99
  revokeCurrentObjectUrl();
66
100
  });
67
101
  </script>
68
102
 
69
- <div class="space-y-3">
103
+ <div class={cn("space-y-3", className)} {...restProps}>
70
104
  {#if value}
71
105
  <div class="relative group">
72
106
  <img
73
107
  src={value}
74
108
  alt=""
75
- class="w-full h-32 min-w-64 object-cover rounded-2xl border-0"
109
+ class="w-full h-32 min-w-64 object-cover rounded-container border-0"
76
110
  />
77
111
  <div
78
- class="absolute inset-0 border border-input-border bg-input min-w-64 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl flex items-center justify-center"
112
+ class="absolute inset-0 border border-border-overlay bg-surface-overlay/60 backdrop-blur-md min-w-64 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity rounded-container flex items-center justify-center"
79
113
  >
80
114
  <div class="flex gap-2">
81
115
  <Button
@@ -99,19 +133,18 @@
99
133
  </div>
100
134
  {:else}
101
135
  <div
102
- class="border-2 border-dashed border-input rounded-2xl min-w-64 p-6 text-center hover:border-brand-500 transition-colors {disabled
136
+ class="border-2 border-dashed border-input-border rounded-container min-w-64 p-6 text-center hover:border-action-primary transition-colors {disabled
103
137
  ? 'cursor-not-allowed opacity-50'
104
138
  : 'cursor-pointer'}"
105
139
  onclick={triggerFileSelect}
106
140
  role="button"
107
141
  tabindex={disabled ? -1 : 0}
108
- onkeydown={(e) =>
109
- e.key === "Enter" && !disabled && triggerFileSelect()}
142
+ onkeydown={handleDropZoneKeydown}
110
143
  aria-disabled={disabled}
111
144
  >
112
145
  <div class="space-y-3">
113
146
  <div
114
- class="w-12 h-12 mx-auto bg-action-secondary rounded-lg flex items-center justify-center"
147
+ class="w-12 h-12 mx-auto bg-action-secondary rounded-control flex items-center justify-center"
115
148
  >
116
149
  <Image size={24} class="text-description" />
117
150
  </div>
@@ -132,10 +165,16 @@
132
165
  onchange={handleFileSelect}
133
166
  {disabled}
134
167
  class="hidden"
168
+ data-testid="image-upload-input"
135
169
  />
136
170
 
137
171
  {#if errorMessage}
138
- <div class="rounded-lg border border-error px-3 py-2 text-sm text-error" role="alert">
172
+ <!-- Same tinted fill + tinted border Alert uses, rather than a bare
173
+ step-600 outline on a transparent ground. -->
174
+ <div
175
+ class="rounded-container border bg-error-subtle border-error-border text-error-text px-3 py-2 text-sm"
176
+ role="alert"
177
+ >
139
178
  <p class="font-medium">Image upload failed</p>
140
179
  <p>{errorMessage}</p>
141
180
  <p class="mt-1">Recovery action: try another file or retry upload.</p>
@@ -1,15 +1,26 @@
1
- interface Props {
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ export type ImageUploadFileDetail = {
3
+ /** Selected file, or `null` when the image is removed. */
4
+ file: File | null;
5
+ /** Object URL used for the preview, or `null`. */
6
+ url: string | null;
7
+ };
8
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick"> & {
9
+ /** Extra classes for the host element. */
10
+ class?: string;
11
+ /** Preview URL; `bind:value` to read it. */
2
12
  value?: string | null;
3
13
  disabled?: boolean;
4
14
  accept?: string;
5
15
  placeholder?: string;
6
16
  errorMessage?: string;
17
+ /** Native `change` event from the hidden file input. */
7
18
  onchange?: (event: Event) => void;
19
+ /** Fires when the file chooser is opened. */
8
20
  onclick?: (event: Event) => void;
9
- }
10
- type $$ComponentProps = Props & {
11
- children?: any;
21
+ /** Selected file and preview URL; `{ file: null, url: null }` on remove. */
22
+ onfileselect?: (detail: ImageUploadFileDetail) => void;
12
23
  };
13
- declare const ImageUpload: import("svelte").Component<$$ComponentProps, {}, "">;
24
+ declare const ImageUpload: import("svelte").Component<Props, {}, "value">;
14
25
  type ImageUpload = ReturnType<typeof ImageUpload>;
15
26
  export default ImageUpload;
@@ -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
  }