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,6 +3,7 @@
3
3
  import Badge from "../atoms/Badge.svelte";
4
4
  import { Search, X } from "@lucide/svelte";
5
5
  import type { Component } from "svelte";
6
+ import { cn } from "../util/cn.js";
6
7
 
7
8
  export interface SidebarPanelItem {
8
9
  id: string;
@@ -13,6 +14,8 @@
13
14
  }
14
15
 
15
16
  interface Props {
17
+ class?: string;
18
+ /** @deprecated use `class`. */
16
19
  className?: string;
17
20
  widthClass?: string;
18
21
  variant?: "plain" | "elevated";
@@ -35,7 +38,8 @@
35
38
  }
36
39
 
37
40
  let {
38
- className = "",
41
+ class: classAttr = "",
42
+ className: legacyClass = "",
39
43
  widthClass = "w-80",
40
44
  variant = "elevated",
41
45
  ariaLabel = "Picker panel",
@@ -57,6 +61,10 @@
57
61
  ...restProps
58
62
  }: Props = $props();
59
63
 
64
+ /** `class` is the public prop; `className` is a deprecated alias.
65
+ * Both are merged here so existing call sites keep working. */
66
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
67
+
60
68
  const normalizedSearchTerm = $derived(searchValue.trim().toLowerCase());
61
69
  const filteredItems = $derived(
62
70
  normalizedSearchTerm
@@ -71,19 +79,19 @@
71
79
  const containerClasses = $derived.by(() => {
72
80
  const resolvedWidthClass = widthClass.trim() || "w-80";
73
81
  const shell = isElevated
74
- ? "rounded-3xl border border-border bg-card text-headline shadow-md ring-1 ring-border/50"
75
- : "rounded-3xl border border-border bg-card text-headline shadow-sm";
76
- return `${resolvedWidthClass} shrink-0 p-5 ${shell} ${className}`.trim();
82
+ ? "rounded-container border border-border bg-card text-headline shadow-sm ring-1 ring-border/50"
83
+ : "rounded-container border border-border bg-card text-headline shadow-sm";
84
+ return cn(`${resolvedWidthClass} shrink-0 p-5 ${shell} ${className}`);
77
85
  });
78
86
 
79
87
  function getItemClasses(itemId: string): string {
80
88
  const isActive = selectedItemId === itemId;
81
89
  const baseClasses =
82
- "focus-ring focus-ring--nav w-full cursor-pointer rounded-lg px-3 py-2.5 text-left transition-colors duration-150";
90
+ "focus-ring focus-ring--nav w-full cursor-pointer rounded-control px-3 py-2 text-left transition-colors duration-150";
83
91
  if (isActive) {
84
92
  return `${baseClasses} bg-nav-menu-active text-inherit shadow-sm ring-1 ring-border/80`;
85
93
  }
86
- return `${baseClasses} text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-base-200`;
94
+ return `${baseClasses} text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-surface-active`;
87
95
  }
88
96
 
89
97
  function handleSelect(item: SidebarPanelItem): void {
@@ -116,7 +124,7 @@
116
124
  </div>
117
125
  <button
118
126
  type="button"
119
- class="-mr-0.5 -mt-0.5 inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-nav-menu-item transition-colors hover:bg-nav-menu-hover hover:text-nav-menu-item-hover focus-ring focus-ring--nav"
127
+ class="-mr-0.5 -mt-0.5 inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-control text-nav-menu-item transition-colors hover:bg-nav-menu-hover hover:text-nav-menu-item-hover focus-ring focus-ring--nav"
120
128
  aria-label={closeLabel}
121
129
  onclick={handleClose}
122
130
  >
@@ -137,7 +145,7 @@
137
145
  bind:value={searchValue}
138
146
  placeholder={searchPlaceholder}
139
147
  aria-label={searchPlaceholder}
140
- class="w-full min-w-0 min-h-10 rounded-xl border-transparent !bg-transparent py-2 pl-10 text-sm ring-1 ring-border/60 hover:!bg-nav-menu-hover focus:!bg-transparent focus-ring focus-ring--nav"
148
+ class="w-full min-w-0 min-h-10 rounded-container border-transparent !bg-transparent py-2 pl-10 text-sm ring-1 ring-border/60 hover:!bg-nav-menu-hover focus:!bg-transparent focus-ring focus-ring--nav"
141
149
  />
142
150
  </div>
143
151
  {/if}
@@ -154,7 +162,7 @@
154
162
  aria-selected={selectedItemId === item.id}
155
163
  >
156
164
  <span class="flex items-center justify-between gap-3">
157
- <span class="flex min-w-0 items-start gap-2.5">
165
+ <span class="flex min-w-0 items-start gap-3">
158
166
  {#if item.icon}
159
167
  {@const Icon = item.icon}
160
168
  <span
@@ -189,14 +197,14 @@
189
197
  </ul>
190
198
  {:else}
191
199
  <div
192
- class="rounded-xl border border-dashed border-border bg-transparent px-3.5 py-4 ring-1 ring-border/40"
200
+ class="rounded-container border border-dashed border-border bg-transparent px-4 py-4 ring-1 ring-border/40"
193
201
  >
194
202
  <h4 class="text-sm font-semibold text-headline">{emptyStateTitle}</h4>
195
203
  <p class="mt-1 text-sm leading-relaxed text-description">{emptyStateDescription}</p>
196
204
  {#if emptyStateActionLabel.trim() && onEmptyStateAction}
197
205
  <button
198
206
  type="button"
199
- class="mt-3 inline-flex min-h-10 cursor-pointer items-center rounded-lg bg-action-primary px-3 py-2 text-sm font-medium text-action-primary transition-colors hover:bg-action-primary-hover focus-ring focus-ring--nav"
207
+ class="mt-3 inline-flex min-h-10 cursor-pointer items-center rounded-control bg-action-primary px-3 py-2 text-sm font-medium text-action-primary transition-colors hover:bg-action-primary-hover focus-ring focus-ring--nav"
200
208
  onclick={handleEmptyStateAction}
201
209
  >
202
210
  {emptyStateActionLabel.trim()}
@@ -10,6 +10,8 @@ export interface SidebarPanelItem {
10
10
  badgeText?: string;
11
11
  }
12
12
  interface Props {
13
+ class?: string;
14
+ /** @deprecated use `class`. */
13
15
  className?: string;
14
16
  widthClass?: string;
15
17
  variant?: "plain" | "elevated";
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "../util/cn.js";
5
+
6
+ /**
7
+ * The chrome of a sidebar and nothing else: width, surface, collapse
8
+ * behaviour, the inset its regions share, and a scroll container for the
9
+ * middle.
10
+ *
11
+ * `SidebarNavigation` is a fully-specified sidebar driven by ~44 props —
12
+ * excellent when your sidebar looks like that one, useless when it does
13
+ * not, because the parts are not reachable. This is the same shell with
14
+ * the regions left open, so you can drop in `SidebarBrandHeader`,
15
+ * `SidebarNavSection` and `SidebarFooter` (or your own markup) and skip
16
+ * the props you would never set.
17
+ *
18
+ * `SidebarNavigation` is built on it, so the two cannot drift apart.
19
+ *
20
+ * ```svelte
21
+ * <SidebarShell mode="collapsed">
22
+ * {#snippet header({ collapsed })}
23
+ * <SidebarBrandHeader {collapsed} brandName="Zabi" />
24
+ * {/snippet}
25
+ * <SidebarNavSection title="Main" sectionKey="main">…</SidebarNavSection>
26
+ * {#snippet footer({ collapsed, insetX })}
27
+ * <SidebarFooter {collapsed} className={insetX} />
28
+ * {/snippet}
29
+ * </SidebarShell>
30
+ * ```
31
+ */
32
+
33
+ /** Passed to every region so it can match the shell's collapse and inset. */
34
+ export interface SidebarRegionContext {
35
+ collapsed: boolean;
36
+ /** Horizontal padding utility the shell's own regions use. */
37
+ insetX: string;
38
+ }
39
+
40
+ type Props = Omit<HTMLAttributes<HTMLElement>, "class"> & {
41
+ mode?: "expanded" | "collapsed";
42
+ /** `rail` attaches to the viewport edge; `card` is a floating panel. */
43
+ layout?: "rail" | "card";
44
+ ariaLabel?: string;
45
+ class?: string;
46
+ /** @deprecated use `class`. */
47
+ className?: string;
48
+ /** Brand row, search — anything above the scrolling nav area. */
49
+ header?: Snippet<[SidebarRegionContext]>;
50
+ /** The scrolling middle. */
51
+ children?: Snippet<[SidebarRegionContext]>;
52
+ /** Account row, logout, theme toggle. Outside the scroll container. */
53
+ footer?: Snippet<[SidebarRegionContext]>;
54
+ };
55
+
56
+ let {
57
+ mode = "expanded",
58
+ layout = "rail",
59
+ ariaLabel = "Sidebar navigation",
60
+ class: classAttr = "",
61
+ className: legacyClass = "",
62
+ header,
63
+ children,
64
+ footer,
65
+ ...restProps
66
+ }: Props = $props();
67
+
68
+ const className = $derived(cn(classAttr, legacyClass));
69
+ const isCollapsed = $derived(mode === "collapsed");
70
+ const isCard = $derived(layout === "card");
71
+
72
+ const insetX = $derived(isCollapsed ? "px-2" : "px-4");
73
+
74
+ const containerClasses = $derived.by(() => {
75
+ const widthClass = isCollapsed ? "w-[72px]" : "w-[266px]";
76
+ const railSurface = "border-r border-border bg-background text-headline";
77
+ const cardSurface =
78
+ "border-r border-border bg-background text-headline shadow-sm";
79
+ const surfaceClasses = isCard ? cardSurface : railSurface;
80
+ const verticalPad = isCard ? "py-4" : "py-5";
81
+ const baseClasses = `flex h-full min-h-0 max-h-full flex-col overflow-visible ${verticalPad}`;
82
+
83
+ return cn(`${baseClasses} ${widthClass} ${surfaceClasses} ${className}`);
84
+ });
85
+
86
+ const region = $derived({ collapsed: isCollapsed, insetX });
87
+ </script>
88
+
89
+ <nav class={containerClasses} aria-label={ariaLabel} {...restProps}>
90
+ <div class="flex min-h-0 w-full min-w-0 flex-1 flex-col">
91
+ {#if header}
92
+ <div class={cn("flex w-full shrink-0 flex-col gap-5 pb-3", insetX)}>
93
+ {@render header(region)}
94
+ </div>
95
+ {/if}
96
+
97
+ <div
98
+ class={cn(
99
+ "flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden overscroll-y-contain pt-4 pb-2",
100
+ insetX,
101
+ )}
102
+ role="region"
103
+ aria-label="Navigation links"
104
+ >
105
+ {@render children?.(region)}
106
+ </div>
107
+ </div>
108
+
109
+ {#if footer}
110
+ {@render footer(region)}
111
+ {/if}
112
+ </nav>
@@ -0,0 +1,52 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ /**
4
+ * The chrome of a sidebar and nothing else: width, surface, collapse
5
+ * behaviour, the inset its regions share, and a scroll container for the
6
+ * middle.
7
+ *
8
+ * `SidebarNavigation` is a fully-specified sidebar driven by ~44 props —
9
+ * excellent when your sidebar looks like that one, useless when it does
10
+ * not, because the parts are not reachable. This is the same shell with
11
+ * the regions left open, so you can drop in `SidebarBrandHeader`,
12
+ * `SidebarNavSection` and `SidebarFooter` (or your own markup) and skip
13
+ * the props you would never set.
14
+ *
15
+ * `SidebarNavigation` is built on it, so the two cannot drift apart.
16
+ *
17
+ * ```svelte
18
+ * <SidebarShell mode="collapsed">
19
+ * {#snippet header({ collapsed })}
20
+ * <SidebarBrandHeader {collapsed} brandName="Zabi" />
21
+ * {/snippet}
22
+ * <SidebarNavSection title="Main" sectionKey="main">…</SidebarNavSection>
23
+ * {#snippet footer({ collapsed, insetX })}
24
+ * <SidebarFooter {collapsed} className={insetX} />
25
+ * {/snippet}
26
+ * </SidebarShell>
27
+ * ```
28
+ */
29
+ /** Passed to every region so it can match the shell's collapse and inset. */
30
+ export interface SidebarRegionContext {
31
+ collapsed: boolean;
32
+ /** Horizontal padding utility the shell's own regions use. */
33
+ insetX: string;
34
+ }
35
+ type Props = Omit<HTMLAttributes<HTMLElement>, "class"> & {
36
+ mode?: "expanded" | "collapsed";
37
+ /** `rail` attaches to the viewport edge; `card` is a floating panel. */
38
+ layout?: "rail" | "card";
39
+ ariaLabel?: string;
40
+ class?: string;
41
+ /** @deprecated use `class`. */
42
+ className?: string;
43
+ /** Brand row, search — anything above the scrolling nav area. */
44
+ header?: Snippet<[SidebarRegionContext]>;
45
+ /** The scrolling middle. */
46
+ children?: Snippet<[SidebarRegionContext]>;
47
+ /** Account row, logout, theme toggle. Outside the scroll container. */
48
+ footer?: Snippet<[SidebarRegionContext]>;
49
+ };
50
+ declare const SidebarShell: import("svelte").Component<Props, {}, "">;
51
+ type SidebarShell = ReturnType<typeof SidebarShell>;
52
+ export default SidebarShell;
@@ -1,6 +1,10 @@
1
1
  <script lang="ts">
2
2
  import ThemeToggle from "../atoms/ThemeToggle.svelte";
3
+ import IconButton from "../atoms/IconButton.svelte";
4
+ import { ExternalLink, Menu, X } from "@lucide/svelte";
3
5
  import type { Component, Snippet } from "svelte";
6
+ import { generateId } from "../util/ssr-safe.js";
7
+ import { cn } from "../util/cn.js";
4
8
 
5
9
  export interface TopNavbarNavItem {
6
10
  label: string;
@@ -15,6 +19,8 @@
15
19
  /** `aria-label` on `<nav>` when multiple nav landmarks exist. */
16
20
  ariaLabel?: string;
17
21
  showThemeToggle?: boolean;
22
+ class?: string;
23
+ /** @deprecated use `class`. */
18
24
  className?: string;
19
25
  /** Slim mode: only the `nav` region (no full chrome bar). */
20
26
  embedded?: boolean;
@@ -30,7 +36,8 @@
30
36
  brandHref,
31
37
  ariaLabel,
32
38
  showThemeToggle = true,
33
- className = "",
39
+ class: classAttr = "",
40
+ className: legacyClass = "",
34
41
  embedded = false,
35
42
  items = [],
36
43
  navVariant = "header",
@@ -42,12 +49,24 @@
42
49
  ...restProps
43
50
  }: Props & { nav?: Snippet; actions?: Snippet } = $props();
44
51
 
52
+ /** `class` is the public prop; `className` is a deprecated alias.
53
+ * Both are merged here so existing call sites keep working. */
54
+ const className = $derived(cn(`${classAttr} ${legacyClass}`));
55
+
56
+ const mobileMenuId = generateId("topnavbar-menu");
57
+
45
58
  let isMenuOpen = $state(false);
46
59
 
47
60
  function toggleMenu() {
48
61
  isMenuOpen = !isMenuOpen;
49
62
  }
50
63
 
64
+ function handleWindowKeydown(event: KeyboardEvent) {
65
+ if (event.key === "Escape" && isMenuOpen) {
66
+ isMenuOpen = false;
67
+ }
68
+ }
69
+
51
70
  function handleNavLinkClick(event: MouseEvent) {
52
71
  if (preventNavigation) {
53
72
  event.preventDefault();
@@ -57,6 +76,11 @@
57
76
  }
58
77
  }
59
78
 
79
+ /** Absolute URLs leave the site, so they are marked and open in a new tab. */
80
+ function isExternal(href: string): boolean {
81
+ return href.startsWith("http://") || href.startsWith("https://");
82
+ }
83
+
60
84
  /** App routes: prefix match except `/` and absolute URLs (exact match). */
61
85
  function isNavItemActive(href: string): boolean {
62
86
  if (href.startsWith("http://") || href.startsWith("https://")) {
@@ -78,8 +102,30 @@
78
102
  return "flex flex-col gap-1 grow h-full items-center justify-center min-h-0 min-w-0 relative shrink-0 cursor-pointer w-full md:w-auto";
79
103
  }
80
104
 
81
- function getIconContainerClasses(): string {
82
- return "focus-ring focus-ring--nav group/nav-item flex flex-col items-center justify-center overflow-clip relative rounded-[20px] shrink-0 text-nav-menu-item transition-colors duration-200 outline-none hover:bg-nav-menu-hover hover:text-nav-menu-item-hover aria-[current=page]:bg-nav-menu-active aria-[current=page]:text-nav-menu-item-active";
105
+ /**
106
+ * The active state is resolved here rather than with
107
+ * `aria-[current=page]:text-nav-menu-item-active`.
108
+ *
109
+ * That variant was dead: the semantic utilities are hand-written in
110
+ * app.css and sit outside Tailwind's cascade layer, so an unlayered
111
+ * `.text-nav-menu-item` beats anything Tailwind generates for a variant of
112
+ * the same property. The fill happened to survive only because no plain
113
+ * `bg-*` class competed with it — so the selected tab painted its pill but
114
+ * kept the idle label colour (#27272a where #3c52ba was intended), which
115
+ * is exactly the "active state does almost nothing" failure the sidebar
116
+ * had.
117
+ *
118
+ * Plain classes chosen in JS cannot lose that way.
119
+ */
120
+ function getIconContainerClasses(isActive: boolean): string {
121
+ const base =
122
+ "focus-ring focus-ring--nav group/nav-item flex flex-col items-center justify-center overflow-clip relative rounded-pill shrink-0 transition-colors duration-200 outline-none";
123
+ return isActive
124
+ ? cn(base, "bg-nav-menu-active text-nav-menu-item-active")
125
+ : cn(
126
+ base,
127
+ "text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover",
128
+ );
83
129
  }
84
130
 
85
131
  function getStateLayerClasses(): string {
@@ -87,7 +133,9 @@
87
133
  }
88
134
 
89
135
  function getLabelClasses(): string {
90
- return "font-medium leading-4 relative shrink-0 text-center text-nowrap tracking-[0.5px] whitespace-pre text-xs text-inherit";
136
+ // `whitespace-pre` used to sit beside `text-nowrap` — same property,
137
+ // and it preserved literal whitespace as a side effect.
138
+ return "font-medium leading-4 relative shrink-0 text-center text-nowrap tracking-wide text-xs text-inherit";
91
139
  }
92
140
 
93
141
  function getIconWrapperClasses(): string {
@@ -99,12 +147,15 @@
99
147
  <ul class="{ulClasses} list-none m-0 p-0">
100
148
  {#each items as item (item.href)}
101
149
  {@const isActive = isNavItemActive(item.href)}
150
+ {@const external = isExternal(item.href)}
102
151
  <li class={getNavItemClasses()}>
103
152
  <a
104
153
  href={item.href}
105
- class={getIconContainerClasses()}
154
+ class={getIconContainerClasses(isActive)}
106
155
  onclick={handleNavLinkClick}
107
156
  aria-current={isActive ? "page" : undefined}
157
+ target={external ? "_blank" : undefined}
158
+ rel={external ? "noopener noreferrer" : undefined}
108
159
  >
109
160
  <div class={getStateLayerClasses()}>
110
161
  {#if item.iconFilled && isActive}
@@ -118,9 +169,17 @@
118
169
  <Icon size={16} class="w-4 h-4" />
119
170
  </div>
120
171
  {/if}
121
- <p class={getLabelClasses()}>
172
+ <span class={getLabelClasses()}>
122
173
  {item.label}
123
- </p>
174
+ </span>
175
+ {#if external}
176
+ <ExternalLink
177
+ size={12}
178
+ class="shrink-0 text-current opacity-70"
179
+ aria-hidden="true"
180
+ />
181
+ <span class="sr-only">(opens in a new tab)</span>
182
+ {/if}
124
183
  </div>
125
184
  </a>
126
185
  </li>
@@ -128,6 +187,8 @@
128
187
  </ul>
129
188
  {/snippet}
130
189
 
190
+ <svelte:window onkeydown={handleWindowKeydown} />
191
+
131
192
  {#if embedded}
132
193
  <nav class={className} aria-label={ariaLabel} {...restProps}>
133
194
  {#if nav}
@@ -138,7 +199,7 @@
138
199
  </nav>
139
200
  {:else}
140
201
  <nav
141
- class="border-b border-border bg-background sticky top-0 z-50 {className}"
202
+ class={cn("border-b border-border bg-background sticky top-0 z-sticky", className)}
142
203
  aria-label={ariaLabel}
143
204
  {...restProps}
144
205
  >
@@ -148,7 +209,7 @@
148
209
  {#if brandHref}
149
210
  <a
150
211
  href={brandHref}
151
- class="focus-ring text-xl font-bold text-headline hover:opacity-90"
212
+ class="focus-ring text-xl font-bold text-headline transition-colors hover:text-link"
152
213
  >
153
214
  {brand}
154
215
  </a>
@@ -158,7 +219,7 @@
158
219
  </div>
159
220
 
160
221
  <div class="hidden md:block">
161
- <div class="ml-10 flex items-baseline space-x-4">
222
+ <div class="ml-10 flex items-center space-x-4">
162
223
  {#if nav}
163
224
  {@render nav()}
164
225
  {:else if items.length > 0}
@@ -177,19 +238,29 @@
177
238
  </div>
178
239
 
179
240
  <div class="md:hidden">
180
- <button
181
- type="button"
182
- class="cursor-pointer text-description hover:text-body"
241
+ <!--
242
+ Was a bare ☰ glyph in a text-2xl span: font-dependent,
243
+ off the icon scale, and with no disclosure semantics, so
244
+ a screen reader could not tell the menu was open.
245
+ -->
246
+ <IconButton
247
+ variant="ghost"
248
+ label={isMenuOpen ? "Close menu" : "Open menu"}
183
249
  onclick={toggleMenu}
184
- aria-label="Toggle menu"
250
+ aria-expanded={isMenuOpen}
251
+ aria-controls={mobileMenuId}
185
252
  >
186
- <span class="text-2xl">☰</span>
187
- </button>
253
+ {#if isMenuOpen}
254
+ <X size={20} />
255
+ {:else}
256
+ <Menu size={20} />
257
+ {/if}
258
+ </IconButton>
188
259
  </div>
189
260
  </div>
190
261
 
191
262
  {#if isMenuOpen}
192
- <div class="md:hidden">
263
+ <div class="md:hidden" id={mobileMenuId}>
193
264
  <div
194
265
  class="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-border"
195
266
  >
@@ -17,6 +17,8 @@ interface Props {
17
17
  /** `aria-label` on `<nav>` when multiple nav landmarks exist. */
18
18
  ariaLabel?: string;
19
19
  showThemeToggle?: boolean;
20
+ class?: string;
21
+ /** @deprecated use `class`. */
20
22
  className?: string;
21
23
  /** Slim mode: only the `nav` region (no full chrome bar). */
22
24
  embedded?: boolean;
@@ -1,4 +1,5 @@
1
1
  export { default as TopNavbar } from './TopNavbar.svelte';
2
2
  export { default as SidebarNavigation } from './SidebarNavigation.svelte';
3
+ export { default as SidebarShell } from './SidebarShell.svelte';
3
4
  export { default as SidebarAccountPanel } from './SidebarAccountPanel.svelte';
4
5
  export { default as SidebarPanel } from './SidebarPanel.svelte';
@@ -1,4 +1,5 @@
1
1
  export { default as TopNavbar } from './TopNavbar.svelte';
2
2
  export { default as SidebarNavigation } from './SidebarNavigation.svelte';
3
+ export { default as SidebarShell } from './SidebarShell.svelte';
3
4
  export { default as SidebarAccountPanel } from './SidebarAccountPanel.svelte';
4
5
  export { default as SidebarPanel } from './SidebarPanel.svelte';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Variant class helpers — semantic design tokens only (see src/app.css).
3
+ */
4
+ import type { SemanticVariant } from '../../types/variants.js';
5
+ export type Variant = SemanticVariant;
6
+ /**
7
+ * Border + focus ring classes for text inputs (Input, Textarea).
8
+ */
9
+ export declare function getInputVariantClasses(variant: Variant): string;
10
+ /**
11
+ * Card surfaces — border + background using semantic tokens.
12
+ */
13
+ export declare function getCardVariantClasses(variant: Variant): string;
14
+ /**
15
+ * Generic variant classes for borders, text, or backgrounds.
16
+ */
17
+ export declare function getVariantClasses(variant: Variant, type: 'border' | 'text' | 'bg'): string;
18
+ export declare function getAllVariantClasses(variant: Variant): {
19
+ border: string;
20
+ text: string;
21
+ bg: string;
22
+ };
23
+ /**
24
+ * Create a variant class map for use in reactive statements
25
+ */
26
+ export declare function createVariantClassMap<T extends string>(variants: T[], prefix: string): Record<T, string>;
@@ -1,55 +1,37 @@
1
1
  /**
2
2
  * Variant class helpers — semantic design tokens only (see src/app.css).
3
3
  */
4
-
5
- import type { SemanticVariant } from '../../types/variants.js';
6
-
7
- export type Variant = SemanticVariant;
8
-
9
4
  /**
10
5
  * Border + focus ring classes for text inputs (Input, Textarea).
11
6
  */
12
- export function getInputVariantClasses(variant: Variant): string {
13
- const variantMap: Record<Variant, string> = {
14
- default:
15
- 'focus-ring border-input-border focus-visible:border-input-border',
16
- success:
17
- 'focus-ring border-success focus-visible:border-success',
18
- warning:
19
- 'focus-ring border-warning focus-visible:border-warning',
7
+ export function getInputVariantClasses(variant) {
8
+ const variantMap = {
9
+ default: 'focus-ring border-input-border focus-visible:border-input-border',
10
+ success: 'focus-ring border-success focus-visible:border-success',
11
+ warning: 'focus-ring border-warning focus-visible:border-warning',
20
12
  error: 'focus-ring border-error focus-visible:border-error',
21
13
  info: 'focus-ring border-info focus-visible:border-info',
22
14
  };
23
-
24
15
  return variantMap[variant] || variantMap.default;
25
16
  }
26
-
27
17
  /**
28
18
  * Card surfaces — border + background using semantic tokens.
29
19
  */
30
- export function getCardVariantClasses(variant: Variant): string {
31
- const variantMap: Record<Variant, string> = {
20
+ export function getCardVariantClasses(variant) {
21
+ const variantMap = {
32
22
  default: 'border-border bg-card',
33
23
  success: 'border-success bg-card',
34
24
  warning: 'border-warning bg-card',
35
25
  error: 'border-error bg-card',
36
26
  info: 'border-info bg-card',
37
27
  };
38
-
39
28
  return variantMap[variant] || variantMap.default;
40
29
  }
41
-
42
30
  /**
43
31
  * Generic variant classes for borders, text, or backgrounds.
44
32
  */
45
- export function getVariantClasses(
46
- variant: Variant,
47
- type: 'border' | 'text' | 'bg',
48
- ): string {
49
- const variantMap: Record<
50
- Variant,
51
- Record<'border' | 'text' | 'bg', string>
52
- > = {
33
+ export function getVariantClasses(variant, type) {
34
+ const variantMap = {
53
35
  default: {
54
36
  border: 'border-border',
55
37
  text: 'text-body',
@@ -76,34 +58,21 @@ export function getVariantClasses(
76
58
  bg: 'bg-card',
77
59
  },
78
60
  };
79
-
80
61
  return variantMap[variant]?.[type] || variantMap.default[type];
81
62
  }
82
-
83
- export function getAllVariantClasses(variant: Variant): {
84
- border: string;
85
- text: string;
86
- bg: string;
87
- } {
63
+ export function getAllVariantClasses(variant) {
88
64
  return {
89
65
  border: getVariantClasses(variant, 'border'),
90
66
  text: getVariantClasses(variant, 'text'),
91
67
  bg: getVariantClasses(variant, 'bg'),
92
68
  };
93
69
  }
94
-
95
70
  /**
96
71
  * Create a variant class map for use in reactive statements
97
72
  */
98
- export function createVariantClassMap<T extends string>(
99
- variants: T[],
100
- prefix: string,
101
- ): Record<T, string> {
102
- return variants.reduce(
103
- (acc, variant) => {
104
- acc[variant] = `${prefix}-${variant}`;
105
- return acc;
106
- },
107
- {} as Record<T, string>,
108
- );
73
+ export function createVariantClassMap(variants, prefix) {
74
+ return variants.reduce((acc, variant) => {
75
+ acc[variant] = `${prefix}-${variant}`;
76
+ return acc;
77
+ }, {});
109
78
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './events.js';
2
+ export * from './variants.js';
3
+ export * from './page.types.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Merge class values, last conflicting utility wins.
3
+ *
4
+ * Falsy entries are dropped, so `cn(base, isActive && activeClasses, className)`
5
+ * is safe.
6
+ */
7
+ export declare function cn(...classes: (string | false | null | undefined)[]): string;