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
@@ -3,12 +3,15 @@
3
3
  import Card from "../atoms/Card.svelte";
4
4
  import CardHeader from "../atoms/CardHeader.svelte";
5
5
  import CardContent from "../atoms/CardContent.svelte";
6
+ import { cn } from "../util/cn.js";
6
7
 
7
8
  interface Props {
8
9
  title: string;
9
10
  description?: string;
10
11
  code: string;
11
12
  language?: string;
13
+ class?: string;
14
+ /** @deprecated use `class`. */
12
15
  className?: string;
13
16
  }
14
17
 
@@ -17,11 +20,16 @@
17
20
  description = "",
18
21
  code,
19
22
  language = "svelte",
20
- className = "",
23
+ class: classAttr = "",
24
+ className: legacyClass = "",
21
25
  children,
22
26
  ...restProps
23
27
  } = $props<Props & { children?: any }>();
24
28
 
29
+ /** `class` is the public prop; `className` is a deprecated alias.
30
+ * Both are merged here so existing call sites keep working. */
31
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
32
+
25
33
  let showCode = $state(false);
26
34
  </script>
27
35
 
@@ -36,7 +44,7 @@
36
44
  <CardContent>
37
45
  <button
38
46
  onclick={() => (showCode = !showCode)}
39
- class="absolute top-8 right-8 z-10 flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-sm text-description transition-colors duration-200 hover:bg-base-50 hover:text-body"
47
+ class="absolute top-8 right-8 z-10 flex cursor-pointer items-center gap-2 rounded-control px-3 py-2 text-sm text-description transition-colors duration-200 hover:bg-surface-hover hover:text-body"
40
48
  aria-label={showCode ? "Show preview" : "Show code"}
41
49
  >
42
50
  {#if showCode}
@@ -3,6 +3,8 @@ interface Props {
3
3
  description?: string;
4
4
  code: string;
5
5
  language?: string;
6
+ class?: string;
7
+ /** @deprecated use `class`. */
6
8
  className?: string;
7
9
  }
8
10
  type $$ComponentProps = Props & {
@@ -8,17 +8,25 @@
8
8
  import CardHeader from "../atoms/CardHeader.svelte";
9
9
  import CardContent from "../atoms/CardContent.svelte";
10
10
  import type { ContactFormData } from "../types/page.types";
11
+ import { cn } from "../util/cn.js";
11
12
 
12
13
  interface Props {
14
+ class?: string;
15
+ /** @deprecated use `class`. */
13
16
  className?: string;
14
17
  onsubmit?: (event: SubmitEvent) => void;
15
18
  }
16
19
 
17
20
  let {
18
- className = "",
21
+ class: classAttr = "",
22
+ className: legacyClass = "",
19
23
  onsubmit,
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
  let formData = $state<ContactFormData>({
23
31
  name: "",
24
32
  email: "",
@@ -60,6 +68,8 @@
60
68
  };
61
69
 
62
70
  if (!validate(data)) {
71
+ // Block native submission so the page does not reload and wipe the errors.
72
+ event.preventDefault();
63
73
  formErrorMessage =
64
74
  "We could not submit your request. Fix the highlighted fields and try again.";
65
75
  return;
@@ -79,7 +89,7 @@
79
89
  <Form onsubmit={handleFormSubmit} className="space-y-4">
80
90
  {#if formErrorMessage}
81
91
  <div
82
- class="rounded-lg border border-error px-4 py-3 text-sm text-error"
92
+ class="rounded-control border border-error px-4 py-3 text-sm text-error"
83
93
  role="alert"
84
94
  >
85
95
  <p class="font-medium">Something went wrong</p>
@@ -1,4 +1,6 @@
1
1
  interface Props {
2
+ class?: string;
3
+ /** @deprecated use `class`. */
2
4
  className?: string;
3
5
  onsubmit?: (event: SubmitEvent) => void;
4
6
  }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { generateId } from "../util/ssr-safe.js";
3
3
  import type { Snippet } from 'svelte';
4
+ import { cn } from "../util/cn.js";
4
5
 
5
6
  export type DropdownTriggerProps = {
6
7
  'aria-expanded': boolean;
@@ -9,6 +10,8 @@
9
10
  };
10
11
 
11
12
  interface Props {
13
+ /** Extra classes for the host element. */
14
+ class?: string;
12
15
  isOpen?: boolean;
13
16
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
17
  ariaLabel?: string;
@@ -22,10 +25,13 @@
22
25
  }>;
23
26
  onOptionClick?: (value: string | number) => void;
24
27
  trigger: Snippet<[DropdownTriggerProps]>;
28
+ /** Rendered in the popup above the menu/listbox, outside its role (e.g. a search field). */
29
+ header?: Snippet;
25
30
  children?: Snippet;
26
31
  }
27
32
 
28
33
  let {
34
+ class: className = "",
29
35
  isOpen = $bindable(false),
30
36
  placement = 'bottom-start',
31
37
  ariaLabel = 'Menu',
@@ -34,6 +40,7 @@
34
40
  options = [],
35
41
  onOptionClick,
36
42
  trigger,
43
+ header,
37
44
  children,
38
45
  ...restProps
39
46
  }: Props = $props();
@@ -49,8 +56,22 @@
49
56
  'aria-controls': menuId,
50
57
  } satisfies DropdownTriggerProps);
51
58
 
59
+ function isTextEntryTarget(target: EventTarget | null): boolean {
60
+ if (!(target instanceof HTMLElement)) return false;
61
+ if (target.isContentEditable || target instanceof HTMLTextAreaElement) {
62
+ return true;
63
+ }
64
+ return (
65
+ target instanceof HTMLInputElement &&
66
+ !['checkbox', 'radio', 'button', 'submit', 'reset'].includes(target.type)
67
+ );
68
+ }
69
+
52
70
  function handleKeydown(event: KeyboardEvent) {
53
71
  if (!isOpen) {
72
+ if (event.key === ' ' && isTextEntryTarget(event.target)) {
73
+ return;
74
+ }
54
75
  if (
55
76
  event.key === 'Enter' ||
56
77
  event.key === ' ' ||
@@ -77,10 +98,13 @@
77
98
  focusPreviousItem();
78
99
  break;
79
100
  case 'Home':
101
+ // Let text fields (e.g. Select search) keep native caret movement.
102
+ if (isTextEntryTarget(event.target)) break;
80
103
  event.preventDefault();
81
104
  focusFirstItem();
82
105
  break;
83
106
  case 'End':
107
+ if (isTextEntryTarget(event.target)) break;
84
108
  event.preventDefault();
85
109
  focusLastItem();
86
110
  break;
@@ -94,9 +118,13 @@
94
118
 
95
119
  function getMenuItems(): HTMLElement[] {
96
120
  if (!menuElement) return [];
121
+ // `menuitemradio` and `menuitemcheckbox` are menu items too. Matching
122
+ // only `menuitem` left a menu built from them with nothing to focus:
123
+ // arrows and Home/End did nothing, opening by keyboard focused nothing,
124
+ // and Tab closed the menu, so its items could not be reached at all.
97
125
  return Array.from(
98
126
  menuElement.querySelectorAll<HTMLElement>(
99
- '[role="menuitem"], [role="option"]',
127
+ '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"]',
100
128
  ),
101
129
  );
102
130
  }
@@ -182,7 +210,7 @@
182
210
  const dropdownContentClasses = $derived(() => {
183
211
  return [
184
212
  placementClasses(),
185
- 'rounded-lg border border-border bg-card py-2 shadow-lg transition-all duration-200 ease-in-out',
213
+ 'rounded-control border border-border bg-surface-overlay py-2 shadow-lg transition-all duration-200 ease-in-out',
186
214
  transformClasses(),
187
215
  ]
188
216
  .join(' ')
@@ -193,12 +221,12 @@
193
221
  const itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
194
222
 
195
223
  const optionClasses =
196
- 'focus-ring flex w-full items-center justify-start rounded-md px-3 py-2 text-left text-sm text-body transition-colors hover:bg-base-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50';
224
+ 'focus-ring flex w-full items-center justify-start rounded-control px-3 py-2 text-left text-sm text-body transition-colors hover:bg-surface-overlay-hover focus:outline-none disabled:cursor-not-allowed disabled:opacity-50';
197
225
  </script>
198
226
 
199
227
  <div
200
228
  bind:this={rootEl}
201
- class="relative inline-block"
229
+ class={cn("relative inline-block", className)}
202
230
  data-placement={placement}
203
231
  onkeydown={handleKeydown}
204
232
  {...restProps}
@@ -206,13 +234,13 @@
206
234
  {@render trigger(triggerAria)}
207
235
 
208
236
  {#if isOpen}
209
- <div
210
- bind:this={menuElement}
211
- id={menuId}
212
- class={dropdownContentClasses()}
213
- role={menuRole === 'listbox' ? 'listbox' : 'menu'}
214
- aria-label={ariaLabel}
215
- >
237
+ <div bind:this={menuElement} class={dropdownContentClasses()}>
238
+ {@render header?.()}
239
+ <div
240
+ id={menuId}
241
+ role={menuRole === 'listbox' ? 'listbox' : 'menu'}
242
+ aria-label={ariaLabel}
243
+ >
216
244
  {#if options.length > 0}
217
245
  <div class="px-2 py-1">
218
246
  {#each options as option (option.value)}
@@ -236,6 +264,7 @@
236
264
  {:else if children}
237
265
  {@render children?.()}
238
266
  {/if}
267
+ </div>
239
268
  </div>
240
269
  {/if}
241
270
  </div>
@@ -5,6 +5,8 @@ export type DropdownTriggerProps = {
5
5
  'aria-controls': string;
6
6
  };
7
7
  interface Props {
8
+ /** Extra classes for the host element. */
9
+ class?: string;
8
10
  isOpen?: boolean;
9
11
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
10
12
  ariaLabel?: string;
@@ -18,6 +20,8 @@ interface Props {
18
20
  }>;
19
21
  onOptionClick?: (value: string | number) => void;
20
22
  trigger: Snippet<[DropdownTriggerProps]>;
23
+ /** Rendered in the popup above the menu/listbox, outside its role (e.g. a search field). */
24
+ header?: Snippet;
21
25
  children?: Snippet;
22
26
  }
23
27
  declare const Dropdown: import("svelte").Component<Props, {}, "isOpen">;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { generateId } from "../util/ssr-safe.js";
4
+ import { cn } from "../util/cn.js";
4
5
 
5
6
  interface Props {
6
7
  title: string;
@@ -22,7 +23,7 @@
22
23
  </script>
23
24
 
24
25
  <section
25
- class="mx-auto flex flex-col items-center justify-center gap-4 px-6 py-12 text-center {className}"
26
+ class={cn("mx-auto flex flex-col items-center justify-center gap-4 px-6 py-12 text-center", className)}
26
27
  aria-labelledby={titleId}
27
28
  >
28
29
  {#if media}
@@ -1,35 +1,46 @@
1
1
  <script lang="ts">
2
- interface Props {
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLFormAttributes } from "svelte/elements";
4
+ import { cn } from "../util/cn.js";
5
+
6
+ type Props = Omit<HTMLFormAttributes, "method" | "action" | "onsubmit" | "class"> & {
3
7
  method?: "get" | "post";
4
8
  action?: string;
9
+ class?: string;
10
+ /** @deprecated use `class`. */
5
11
  className?: string;
12
+ /** Skip native constraint validation (e.g. when validating in `onsubmit`). */
13
+ novalidate?: boolean;
14
+ /**
15
+ * Runs on submit. The form still submits natively unless you call
16
+ * `event.preventDefault()` — do so for client-side handling.
17
+ */
6
18
  onsubmit?: (event: SubmitEvent) => void;
7
- }
19
+ children?: Snippet;
20
+ };
8
21
 
9
22
  let {
10
23
  method = "post",
11
24
  action = "",
12
- className = "",
25
+ class: classAttr = "",
26
+ className: legacyClass = "",
27
+ novalidate = false,
13
28
  onsubmit,
14
29
  children,
15
30
  ...restProps
16
- } = $props<Props & { children?: any }>();
17
-
18
- function handleSubmit(event: SubmitEvent) {
19
- const formData = new FormData(event.target as HTMLFormElement);
20
- const data = Object.fromEntries(formData.entries());
31
+ }: Props = $props();
21
32
 
22
- if (onsubmit) {
23
- onsubmit(event);
24
- }
25
- }
33
+ /** `class` is the public prop; `className` is a deprecated alias.
34
+ * Both are merged here so existing call sites keep working. */
35
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
26
36
  </script>
27
37
 
28
38
  <form
29
39
  {method}
30
40
  {action}
31
- class="space-y-4 {className}"
32
- onsubmit={handleSubmit}
41
+ novalidate={novalidate || undefined}
42
+ class={cn("space-y-4", className)}
43
+ onsubmit={(event) => onsubmit?.(event)}
33
44
  {...restProps}
34
45
  >
35
46
  {@render children?.()}
@@ -1,12 +1,20 @@
1
- interface Props {
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLFormAttributes } from "svelte/elements";
3
+ type Props = Omit<HTMLFormAttributes, "method" | "action" | "onsubmit" | "class"> & {
2
4
  method?: "get" | "post";
3
5
  action?: string;
6
+ class?: string;
7
+ /** @deprecated use `class`. */
4
8
  className?: string;
9
+ /** Skip native constraint validation (e.g. when validating in `onsubmit`). */
10
+ novalidate?: boolean;
11
+ /**
12
+ * Runs on submit. The form still submits natively unless you call
13
+ * `event.preventDefault()` — do so for client-side handling.
14
+ */
5
15
  onsubmit?: (event: SubmitEvent) => void;
6
- }
7
- type $$ComponentProps = Props & {
8
- children?: any;
16
+ children?: Snippet;
9
17
  };
10
- declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ declare const Form: import("svelte").Component<Props, {}, "">;
11
19
  type Form = ReturnType<typeof Form>;
12
20
  export default Form;
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { generateId } from "../util/ssr-safe.js";
4
4
 
5
+ import { cn } from "../util/cn.js";
5
6
  export type FormFieldControlProps = {
6
7
  id: string;
7
8
  'aria-describedby'?: string;
@@ -47,7 +48,7 @@
47
48
  );
48
49
  </script>
49
50
 
50
- <div class={['space-y-1.5', className].filter(Boolean).join(' ')}>
51
+ <div class={cn('space-y-2', className)}>
51
52
  <div class="flex items-baseline justify-between gap-3">
52
53
  <label
53
54
  class={[
@@ -2,18 +2,28 @@
2
2
  import Badge from "../atoms/Badge.svelte";
3
3
  import Heading from "../atoms/Heading.svelte";
4
4
  import type { ComponentCategory } from "../types/page.types";
5
+ import { cn } from "../util/cn.js";
5
6
 
6
7
  interface Props {
8
+ /** Extra classes for the host element. */
9
+ class?: string;
7
10
  title: string;
8
11
  description: string;
9
12
  category: ComponentCategory;
10
13
  variantsStates?: string[];
11
14
  }
12
15
 
13
- let { title, description, category, variantsStates }: Props = $props();
16
+ let {
17
+ class: className = "",
18
+ title,
19
+ description,
20
+ category,
21
+ variantsStates,
22
+ ...restProps
23
+ }: Props = $props();
14
24
  </script>
15
25
 
16
- <header class="space-y-4">
26
+ <header class={cn("space-y-4", className)} {...restProps}>
17
27
  <div class="space-y-2">
18
28
  <Heading level={1} text={title} />
19
29
  <p class="text-lg whitespace-pre-line text-description">{description}</p>
@@ -32,7 +42,7 @@
32
42
  Variants / states:
33
43
  </span>
34
44
  {#each variantsStates as v (v)}
35
- <span class="rounded-md bg-base-100 px-2 py-1 text-xs text-headline">
45
+ <span class="rounded-control bg-base-100 px-2 py-1 text-xs text-headline">
36
46
  {v}
37
47
  </span>
38
48
  {/each}
@@ -1,5 +1,7 @@
1
1
  import type { ComponentCategory } from "../types/page.types";
2
2
  interface Props {
3
+ /** Extra classes for the host element. */
4
+ class?: string;
3
5
  title: string;
4
6
  description: string;
5
7
  category: ComponentCategory;
@@ -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;