zabi-components 6.0.0 → 7.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 (197) hide show
  1. package/README.md +137 -1473
  2. package/dist/atoms/ActionPanel.svelte +116 -0
  3. package/dist/atoms/ActionPanel.svelte.d.ts +19 -0
  4. package/dist/atoms/Badge.svelte +1 -1
  5. package/dist/atoms/Badge.svelte.d.ts +1 -1
  6. package/dist/atoms/Button.svelte +35 -16
  7. package/dist/atoms/Button.svelte.d.ts +3 -1
  8. package/dist/atoms/Card.svelte +12 -3
  9. package/dist/atoms/Card.svelte.d.ts +2 -2
  10. package/dist/atoms/CardHeader.svelte +8 -4
  11. package/dist/atoms/CardHeader.svelte.d.ts +4 -2
  12. package/dist/atoms/Checkbox.svelte +50 -76
  13. package/dist/atoms/Checkbox.svelte.d.ts +7 -2
  14. package/dist/atoms/CodeBlock.svelte +20 -34
  15. package/dist/atoms/CodeBlock.svelte.d.ts +1 -0
  16. package/dist/atoms/ColorPicker.svelte +2 -6
  17. package/dist/atoms/Container.svelte +41 -0
  18. package/dist/atoms/Container.svelte.d.ts +12 -0
  19. package/dist/atoms/Divider.svelte +68 -0
  20. package/dist/atoms/Divider.svelte.d.ts +13 -0
  21. package/dist/atoms/FeatureCard.svelte +35 -50
  22. package/dist/atoms/FeatureCard.svelte.d.ts +9 -4
  23. package/dist/atoms/IconButton.svelte +33 -24
  24. package/dist/atoms/IconButton.svelte.d.ts +4 -8
  25. package/dist/atoms/Input.svelte +58 -26
  26. package/dist/atoms/Input.svelte.d.ts +6 -1
  27. package/dist/atoms/List.svelte +2 -7
  28. package/dist/atoms/List.svelte.d.ts +1 -6
  29. package/dist/atoms/ListItem.svelte +54 -65
  30. package/dist/atoms/ListItem.svelte.d.ts +7 -13
  31. package/dist/atoms/ListItemLeading.svelte +19 -0
  32. package/dist/atoms/ListItemLeading.svelte.d.ts +9 -0
  33. package/dist/atoms/Progress.svelte +1 -1
  34. package/dist/atoms/Radio.svelte +51 -0
  35. package/dist/atoms/Radio.svelte.d.ts +14 -0
  36. package/dist/atoms/Select.svelte +23 -30
  37. package/dist/atoms/Select.svelte.d.ts +0 -10
  38. package/dist/atoms/SelectionControl.svelte +98 -0
  39. package/dist/atoms/SelectionControl.svelte.d.ts +26 -0
  40. package/dist/atoms/Skeleton.svelte +39 -15
  41. package/dist/atoms/Skeleton.svelte.d.ts +7 -3
  42. package/dist/atoms/Table.svelte +27 -0
  43. package/dist/atoms/Table.svelte.d.ts +10 -0
  44. package/dist/atoms/Text.svelte +46 -0
  45. package/dist/atoms/Text.svelte.d.ts +13 -0
  46. package/dist/atoms/Textarea.svelte +74 -53
  47. package/dist/atoms/Textarea.svelte.d.ts +6 -2
  48. package/dist/atoms/ThemeToggle.svelte +35 -24
  49. package/dist/atoms/ThemeToggle.svelte.d.ts +1 -0
  50. package/dist/atoms/Toast.svelte +55 -31
  51. package/dist/atoms/Toast.svelte.d.ts +4 -1
  52. package/dist/atoms/Toggle.svelte +37 -22
  53. package/dist/atoms/Toggle.svelte.d.ts +2 -1
  54. package/dist/atoms/Tooltip.svelte +90 -11
  55. package/dist/atoms/index.d.ts +6 -0
  56. package/dist/atoms/index.js +6 -0
  57. package/dist/atoms/selection-control.styles.d.ts +25 -0
  58. package/dist/atoms/selection-control.styles.js +30 -0
  59. package/dist/components/atoms/index.d.ts +6 -0
  60. package/dist/components/atoms/index.d.ts.map +1 -1
  61. package/dist/components/atoms/selection-control.styles.d.ts +26 -0
  62. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -0
  63. package/dist/components/index.d.ts +41 -5
  64. package/dist/components/index.d.ts.map +1 -1
  65. package/dist/components/molecules/index.d.ts +10 -0
  66. package/dist/components/molecules/index.d.ts.map +1 -1
  67. package/dist/components/molecules/navigation-menu-context.d.ts +11 -0
  68. package/dist/components/molecules/navigation-menu-context.d.ts.map +1 -1
  69. package/dist/components/molecules/toast-store.d.ts +39 -0
  70. package/dist/components/molecules/toast-store.d.ts.map +1 -0
  71. package/dist/components/organisms/index.d.ts +2 -1
  72. package/dist/components/organisms/index.d.ts.map +1 -1
  73. package/dist/components/types/page.types.d.ts +69 -0
  74. package/dist/components/types/page.types.d.ts.map +1 -0
  75. package/dist/components/types/page.types.js +1 -0
  76. package/dist/components/types/variants.d.ts +64 -0
  77. package/dist/components/types/variants.d.ts.map +1 -0
  78. package/dist/components/types/variants.js +44 -0
  79. package/dist/components/util/fixed-sidebar-flyout.d.ts +23 -0
  80. package/dist/components/util/fixed-sidebar-flyout.d.ts.map +1 -0
  81. package/dist/components/util/focus-utils.d.ts +12 -0
  82. package/dist/components/util/focus-utils.d.ts.map +1 -0
  83. package/dist/components/util/ssr-safe.d.ts +17 -0
  84. package/dist/components/util/ssr-safe.d.ts.map +1 -0
  85. package/dist/index.d.ts +41 -5
  86. package/dist/index.js +44 -8
  87. package/dist/lib/layout-width-tokens.d.ts +17 -0
  88. package/dist/lib/layout-width-tokens.d.ts.map +1 -0
  89. package/dist/lib/showcase/component-docs/Button.d.ts +3 -0
  90. package/dist/lib/showcase/component-docs/Button.d.ts.map +1 -0
  91. package/dist/lib/showcase/component-docs/Input.d.ts +3 -0
  92. package/dist/lib/showcase/component-docs/Input.d.ts.map +1 -0
  93. package/dist/lib/showcase/component-docs/List.d.ts +3 -0
  94. package/dist/lib/showcase/component-docs/List.d.ts.map +1 -0
  95. package/dist/lib/showcase/component-docs/Modal.d.ts +3 -0
  96. package/dist/lib/showcase/component-docs/Modal.d.ts.map +1 -0
  97. package/dist/lib/showcase/component-docs/Radio.d.ts +3 -0
  98. package/dist/lib/showcase/component-docs/Radio.d.ts.map +1 -0
  99. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +3 -0
  100. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +1 -0
  101. package/dist/lib/showcase/component-docs/Skeleton.d.ts +3 -0
  102. package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +1 -0
  103. package/dist/lib/showcase/component-docs/_shared.d.ts +12 -0
  104. package/dist/lib/showcase/component-docs/_shared.d.ts.map +1 -0
  105. package/dist/lib/showcase/component-docs/index.d.ts +4 -0
  106. package/dist/lib/showcase/component-docs/index.d.ts.map +1 -0
  107. package/dist/lib/showcase/components-catalog.d.ts.map +1 -1
  108. package/dist/lib/showcase/components-showcase-constants.d.ts +26 -0
  109. package/dist/lib/showcase/components-showcase-constants.d.ts.map +1 -1
  110. package/dist/molecules/Alert.svelte +50 -23
  111. package/dist/molecules/Alert.svelte.d.ts +1 -1
  112. package/dist/molecules/ComponentDemo.svelte +2 -2
  113. package/dist/molecules/ContactForm.svelte +1 -1
  114. package/dist/molecules/Dropdown.svelte +99 -109
  115. package/dist/molecules/Dropdown.svelte.d.ts +12 -6
  116. package/dist/molecules/EmptyState.svelte +44 -0
  117. package/dist/molecules/EmptyState.svelte.d.ts +11 -0
  118. package/dist/molecules/FormField.svelte +89 -0
  119. package/dist/molecules/FormField.svelte.d.ts +23 -0
  120. package/dist/molecules/Header.svelte +41 -0
  121. package/dist/molecules/Header.svelte.d.ts +10 -0
  122. package/dist/molecules/Modal.svelte +37 -24
  123. package/dist/molecules/Modal.svelte.d.ts +4 -2
  124. package/dist/molecules/NavigationMenu.svelte +16 -0
  125. package/dist/molecules/NavigationMenu.svelte.d.ts +2 -0
  126. package/dist/molecules/NavigationMenuContent.svelte +9 -1
  127. package/dist/molecules/NavigationMenuLink.svelte +1 -1
  128. package/dist/molecules/NavigationMenuTrigger.svelte +9 -1
  129. package/dist/molecules/Page.svelte +16 -0
  130. package/dist/molecules/Page.svelte.d.ts +8 -0
  131. package/dist/molecules/PropsTable.svelte +61 -0
  132. package/dist/molecules/PropsTable.svelte.d.ts +8 -0
  133. package/dist/molecules/RadioGroup.svelte +197 -0
  134. package/dist/molecules/RadioGroup.svelte.d.ts +20 -0
  135. package/dist/molecules/Section.svelte +2 -11
  136. package/dist/molecules/Section.svelte.d.ts +2 -11
  137. package/dist/molecules/SidebarBrandHeader.svelte +68 -0
  138. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +10 -0
  139. package/dist/molecules/SidebarFooter.svelte +119 -0
  140. package/dist/molecules/SidebarFooter.svelte.d.ts +23 -0
  141. package/dist/molecules/SidebarNavSection.svelte +49 -0
  142. package/dist/molecules/SidebarNavSection.svelte.d.ts +14 -0
  143. package/dist/molecules/SlideUp.svelte +19 -14
  144. package/dist/molecules/SlideUp.svelte.d.ts +1 -1
  145. package/dist/molecules/Tabs.svelte +4 -9
  146. package/dist/molecules/Toaster.svelte +21 -0
  147. package/dist/molecules/Toaster.svelte.d.ts +6 -0
  148. package/dist/molecules/ToasterToast.svelte +217 -0
  149. package/dist/molecules/ToasterToast.svelte.d.ts +7 -0
  150. package/dist/molecules/index.d.ts +10 -0
  151. package/dist/molecules/index.js +10 -0
  152. package/dist/molecules/navigation-menu-context.d.ts +11 -0
  153. package/dist/molecules/navigation-menu-context.js +18 -0
  154. package/dist/molecules/toast-store.d.ts +38 -0
  155. package/dist/molecules/toast-store.js +34 -0
  156. package/dist/organisms/SidebarAccountPanel.svelte +118 -0
  157. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +20 -0
  158. package/dist/organisms/SidebarNavigation.svelte +267 -237
  159. package/dist/organisms/SidebarNavigation.svelte.d.ts +21 -5
  160. package/dist/organisms/SidebarPanel.svelte +54 -22
  161. package/dist/organisms/SidebarPanel.svelte.d.ts +3 -0
  162. package/dist/organisms/{Navbar.svelte → TopNavbar.svelte} +22 -17
  163. package/dist/organisms/TopNavbar.svelte.d.ts +35 -0
  164. package/dist/organisms/index.d.ts +2 -1
  165. package/dist/organisms/index.js +2 -1
  166. package/dist/routes/lib/focus-utils.d.ts +7 -18
  167. package/dist/routes/lib/focus-utils.ts +32 -34
  168. package/dist/routes/lib/ssr-safe.ts +5 -4
  169. package/dist/routes/lib/variant-utils.ts +48 -39
  170. package/dist/types/components.d.ts +187 -0
  171. package/dist/types/components.d.ts.map +1 -0
  172. package/dist/types/events.d.ts +200 -0
  173. package/dist/types/events.d.ts.map +1 -0
  174. package/dist/types/index.d.ts +171 -0
  175. package/dist/types/index.d.ts.map +1 -0
  176. package/dist/types/page.types.d.ts +2 -31
  177. package/dist/types/page.types.d.ts.map +1 -1
  178. package/dist/types/page.types.js +1 -0
  179. package/dist/types/page.types.ts +79 -0
  180. package/dist/types/variants.d.ts +3 -0
  181. package/dist/types/variants.d.ts.map +1 -0
  182. package/dist/types/variants.js +1 -0
  183. package/dist/types/variants.ts +5 -6
  184. package/dist/util/fixed-sidebar-flyout.d.ts +22 -0
  185. package/dist/util/fixed-sidebar-flyout.js +148 -0
  186. package/dist/util/focus-utils.d.ts +11 -0
  187. package/dist/util/focus-utils.js +94 -0
  188. package/dist/util/ssr-safe.d.ts +16 -0
  189. package/dist/util/ssr-safe.js +48 -0
  190. package/dist/zabi-components-colors.css +14 -1
  191. package/dist/zabi-components-theme-dark-only.css +5 -0
  192. package/dist/zabi-components-theme-dark.css +5 -0
  193. package/dist/zabi-components-theme-only.css +9 -1
  194. package/dist/zabi-components-theme.css +9 -1
  195. package/dist/zabi-components.css +877 -192
  196. package/package.json +19 -7
  197. package/dist/organisms/Navbar.svelte.d.ts +0 -41
@@ -1,8 +1,15 @@
1
1
  <script lang="ts">
2
2
  import Badge from "../atoms/Badge.svelte";
3
+ import Button from "../atoms/Button.svelte";
4
+ import IconButton from "../atoms/IconButton.svelte";
3
5
  import Input from "../atoms/Input.svelte";
4
- import { Search, LogOut, Sun, Moon } from "@lucide/svelte";
6
+ import SidebarBrandHeader from "../molecules/SidebarBrandHeader.svelte";
7
+ import SidebarFooter from "../molecules/SidebarFooter.svelte";
8
+ import SidebarNavSection from "../molecules/SidebarNavSection.svelte";
9
+ import { Command, Search } from "@lucide/svelte";
10
+ import type { Snippet } from "svelte";
5
11
  import type { Component } from "svelte";
12
+ import type { ButtonVariant, SizeVariant } from "../types/variants.js";
6
13
 
7
14
  export interface SidebarNavigationItem {
8
15
  id: string;
@@ -12,14 +19,25 @@
12
19
  badgeText?: string | number;
13
20
  badgeCount?: number;
14
21
  group?: "primary" | "secondary";
22
+ /** Groups items under a section heading in the rail. */
23
+ section?: string;
24
+ }
25
+
26
+ interface NavSectionGroup {
27
+ sectionLabel: string | null;
28
+ items: SidebarNavigationItem[];
15
29
  }
16
30
 
17
31
  interface Props {
18
32
  mode?: "expanded" | "collapsed";
33
+ layout?: "rail" | "card";
19
34
  items?: SidebarNavigationItem[];
20
35
  currentPath?: string;
21
36
  ariaLabel?: string;
22
37
  className?: string;
38
+ logoSrc?: string;
39
+ logoAlt?: string;
40
+ brandName?: string;
23
41
  showProfile?: boolean;
24
42
  profileName?: string;
25
43
  profileEmail?: string;
@@ -28,6 +46,10 @@
28
46
  searchMode?: "input" | "button";
29
47
  searchPlaceholder?: string;
30
48
  searchValue?: string;
49
+ /** Trigger icon when search is a button or rail is collapsed; input mode keeps `Search` on the field. */
50
+ searchTriggerIcon?: Component<{ size?: number; class?: string }>;
51
+ searchTriggerVariant?: ButtonVariant;
52
+ searchTriggerSize?: SizeVariant;
31
53
  showLogout?: boolean;
32
54
  logoutLabel?: string;
33
55
  showThemeToggle?: boolean;
@@ -41,20 +63,25 @@
41
63
  onLogout?: () => void;
42
64
  onThemeToggle?: (nextIsLightMode: boolean) => void;
43
65
  onEmptyStateAction?: () => void;
44
- /**
45
- * Highlights a primary (e.g. category) row when the current route is a child
46
- * of that section, while `currentPath` points at the leaf (e.g. a component).
47
- */
66
+ onProfileClick?: (event?: MouseEvent) => void;
67
+ profilePanelOpen?: boolean;
68
+ profilePanelControlsId?: string;
69
+ profilePanel?: Snippet;
70
+ /** Parent nav href to highlight when `currentPath` is a deeper leaf (e.g. category row). */
48
71
  activePrimaryHref?: string;
49
72
  }
50
73
 
51
74
  let {
52
75
  mode = "expanded",
76
+ layout = "rail",
53
77
  items = [],
54
78
  currentPath = "",
55
79
  activePrimaryHref = "",
56
80
  ariaLabel = "Sidebar navigation",
57
81
  className = "",
82
+ logoSrc = "",
83
+ logoAlt = "",
84
+ brandName = "",
58
85
  showProfile = true,
59
86
  profileName = "Zabi",
60
87
  profileEmail = "hello@zabi.dev",
@@ -63,6 +90,9 @@
63
90
  searchMode = "input",
64
91
  searchPlaceholder = "Search...",
65
92
  searchValue = $bindable(""),
93
+ searchTriggerIcon = Command,
94
+ searchTriggerVariant = "outline",
95
+ searchTriggerSize = "sm",
66
96
  showLogout = true,
67
97
  logoutLabel = "Logout",
68
98
  showThemeToggle = true,
@@ -76,10 +106,19 @@
76
106
  onLogout,
77
107
  onThemeToggle,
78
108
  onEmptyStateAction,
109
+ onProfileClick,
110
+ profilePanelOpen = false,
111
+ profilePanelControlsId = "",
112
+ profilePanel,
79
113
  ...restProps
80
114
  }: Props = $props();
81
115
 
82
116
  const isCollapsed = $derived(mode === "collapsed");
117
+ const isCard = $derived(layout === "card");
118
+ const showBrandRow = $derived(
119
+ Boolean(logoSrc.trim() || brandName.trim()),
120
+ );
121
+
83
122
  const normalizedSearchTerm = $derived(searchValue.trim().toLowerCase());
84
123
  const primaryItems = $derived(
85
124
  items.filter((item) => item.group !== "secondary"),
@@ -87,11 +126,11 @@
87
126
  const secondaryItems = $derived(
88
127
  items.filter((item) => item.group === "secondary"),
89
128
  );
129
+
90
130
  function itemMatchesSearch(item: SidebarNavigationItem): boolean {
91
131
  return item.label.toLowerCase().includes(normalizedSearchTerm);
92
132
  }
93
133
 
94
- /** Keep the current (and section) row visible even when it does not match the filter. */
95
134
  function itemPinnedBySelection(item: SidebarNavigationItem): boolean {
96
135
  if (currentPath === item.href) {
97
136
  return true;
@@ -119,36 +158,52 @@
119
158
  filteredPrimaryItems.length + filteredSecondaryItems.length > 0,
120
159
  );
121
160
 
161
+ function partitionBySection(
162
+ list: SidebarNavigationItem[],
163
+ ): NavSectionGroup[] {
164
+ const groups: NavSectionGroup[] = [];
165
+ for (const item of list) {
166
+ const label = item.section?.trim() || null;
167
+ const last = groups[groups.length - 1];
168
+ if (last && last.sectionLabel === label) {
169
+ last.items = [...last.items, item];
170
+ } else {
171
+ groups.push({ sectionLabel: label, items: [item] });
172
+ }
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ const primarySectionGroups = $derived(
178
+ partitionBySection(filteredPrimaryItems),
179
+ );
180
+
181
+ const insetX = $derived(isCollapsed ? "px-2.5" : "px-4");
182
+
122
183
  const containerClasses = $derived.by(() => {
123
184
  const widthClass = isCollapsed ? "w-[104px]" : "w-[266px]";
124
- const surfaceClasses = "bg-background border-border text-headline";
125
- const baseClasses =
126
- "flex h-full min-h-0 max-h-full flex-col overflow-hidden border-r py-6";
185
+ const railSurface = "border-r border-border bg-background text-headline";
186
+ const cardSurface =
187
+ "border-r border-border bg-background text-headline shadow-sm";
188
+ const surfaceClasses = isCard ? cardSurface : railSurface;
189
+ const verticalPad = isCard ? "py-4" : "py-5";
190
+ const baseClasses = `flex h-full min-h-0 max-h-full flex-col overflow-visible ${verticalPad}`;
127
191
 
128
192
  return `${baseClasses} ${widthClass} ${surfaceClasses} ${className}`.trim();
129
193
  });
130
194
 
131
- const avatarClasses = $derived(
132
- "size-14 rounded-2xl bg-action-primary text-action-primary flex items-center justify-center font-semibold text-base shrink-0",
195
+ const headerStackClasses = $derived(
196
+ `flex w-full shrink-0 flex-col gap-5 ${insetX}`,
133
197
  );
134
198
 
135
- /** Search trigger (collapsed icon strip or button mode): matches nav item interaction tokens. */
136
- const searchControlStates =
137
- "text-nav-menu-item outline-none transition-colors duration-150 hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-base-200 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-nav-menu-focus";
138
-
139
- const searchButtonClasses = $derived(
140
- `flex min-h-11 w-full cursor-pointer items-center justify-center rounded-2xl border border-input-border bg-input px-0 py-2.5 ${searchControlStates}`,
141
- );
142
- const searchTriggerClasses = $derived.by(() => {
143
- return isCollapsed
144
- ? searchButtonClasses
145
- : `flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-2xl border border-input-border bg-input px-4 py-2.5 text-left ${searchControlStates}`;
146
- });
147
199
  const iconContainerClasses = $derived(
148
200
  "flex size-6 shrink-0 items-center justify-center leading-none text-current",
149
201
  );
202
+ /** With `searchMode: "input"`, the field stays visible even if `onSearchClick` is set (e.g. Storybook). */
150
203
  const shouldRenderSearchButton = $derived(
151
- isCollapsed || searchMode === "button" || Boolean(onSearchClick),
204
+ isCollapsed ||
205
+ searchMode === "button" ||
206
+ (Boolean(onSearchClick) && searchMode !== "input"),
152
207
  );
153
208
 
154
209
  function getTextToneClass(isMuted = false): string {
@@ -160,35 +215,18 @@
160
215
  currentPath === item.href ||
161
216
  (Boolean(activePrimaryHref) && item.href === activePrimaryHref);
162
217
  const layoutClasses = isCollapsed
163
- ? "flex min-h-11 items-center justify-center px-0 py-2.5"
164
- : "flex min-h-11 items-center gap-3 px-4 py-2.5";
218
+ ? "flex min-h-10 items-center justify-center px-0 py-2"
219
+ : "flex min-h-10 items-center gap-2.5 px-2.5 py-2";
165
220
  const structural =
166
- "w-full cursor-pointer rounded-lg no-underline transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-nav-menu-focus";
221
+ "focus-ring focus-ring--nav w-full cursor-pointer rounded-lg no-underline transition-colors duration-150 outline-none";
167
222
 
168
223
  if (isActive) {
169
- /* Current page: keep active surface on hover (do not apply inactive hover bg). */
170
224
  return `${structural} ${layoutClasses} bg-nav-menu-active text-inherit hover:bg-nav-menu-active hover:text-inherit active:opacity-90`;
171
225
  }
172
226
 
173
227
  return `${structural} ${layoutClasses} text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-base-200`;
174
228
  }
175
229
 
176
- function getControlButtonClasses(): string {
177
- const baseClasses =
178
- "w-full cursor-pointer rounded-lg text-nav-menu-item transition-colors duration-150 outline-none hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-base-200 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-nav-menu-focus";
179
- const layoutClasses = isCollapsed
180
- ? "flex min-h-11 items-center justify-center px-0 py-2.5"
181
- : "flex min-h-11 items-center gap-3 px-4 py-2.5";
182
-
183
- return `${baseClasses} ${layoutClasses}`;
184
- }
185
-
186
- const themeToggleContainerClasses = $derived(
187
- isCollapsed
188
- ? "flex w-full justify-center py-1"
189
- : "flex w-full justify-start py-1",
190
- );
191
-
192
230
  function handleNavigate(item: SidebarNavigationItem, event: MouseEvent) {
193
231
  if (onNavigate) {
194
232
  onNavigate(item, event);
@@ -201,19 +239,6 @@
201
239
  }
202
240
  }
203
241
 
204
- function handleLogout() {
205
- if (onLogout) {
206
- onLogout();
207
- }
208
- }
209
-
210
- function handleThemeToggle() {
211
- isLightMode = !isLightMode;
212
- if (onThemeToggle) {
213
- onThemeToggle(isLightMode);
214
- }
215
- }
216
-
217
242
  function handleEmptyStateAction() {
218
243
  if (onEmptyStateAction) {
219
244
  onEmptyStateAction();
@@ -244,150 +269,186 @@
244
269
  </script>
245
270
 
246
271
  <nav class={containerClasses} aria-label={ariaLabel} {...restProps}>
247
- <div class="flex w-full shrink-0 flex-col gap-6 px-2">
248
- {#if showProfile}
249
- <div class="flex w-full items-center gap-3">
250
- <div class={avatarClasses} aria-hidden="true">
251
- {profileInitials}
252
- </div>
253
- {#if !isCollapsed}
254
- <div class="min-w-0">
255
- <p
256
- class="truncate text-base font-bold {getTextToneClass()}"
272
+ <div
273
+ class="flex min-h-0 w-full min-w-0 flex-1 flex-col"
274
+ >
275
+ <div class={`${headerStackClasses} pb-3`.trim()}>
276
+ {#if showBrandRow}
277
+ <SidebarBrandHeader
278
+ collapsed={isCollapsed}
279
+ {brandName}
280
+ logoSrc={logoSrc.trim()}
281
+ {logoAlt}
282
+ />
283
+ {/if}
284
+
285
+ {#if showSearch}
286
+ {#if shouldRenderSearchButton}
287
+ {@const TriggerIcon = searchTriggerIcon}
288
+ {#if isCollapsed}
289
+ <span
290
+ class="inline-flex"
291
+ data-sidebar-flyout-anchor="search"
292
+ >
293
+ <IconButton
294
+ variant={searchTriggerVariant}
295
+ size={searchTriggerSize}
296
+ label={searchPlaceholder}
297
+ onclick={handleSearchClick}
298
+ >
299
+ <TriggerIcon size={18} />
300
+ </IconButton>
301
+ </span>
302
+ {:else}
303
+ <div
304
+ class="w-full min-w-0"
305
+ data-sidebar-flyout-anchor="search"
257
306
  >
258
- {profileName}
259
- </p>
260
- <p class="truncate text-sm {getTextToneClass(true)}">
261
- {profileEmail}
262
- </p>
307
+ <Button
308
+ variant={searchTriggerVariant}
309
+ size={searchTriggerSize}
310
+ isFullWidth
311
+ onclick={handleSearchClick}
312
+ >
313
+ <span class="flex w-full items-center justify-start gap-2.5">
314
+ <span class={iconContainerClasses} aria-hidden="true">
315
+ <TriggerIcon size={17} />
316
+ </span>
317
+ <span class="truncate text-sm text-inherit">
318
+ {searchValue || searchPlaceholder}
319
+ </span>
320
+ </span>
321
+ </Button>
322
+ </div>
323
+ {/if}
324
+ {:else}
325
+ <div class="relative w-full">
326
+ <span
327
+ class="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2"
328
+ aria-hidden="true"
329
+ >
330
+ <Search size={17} class={getTextToneClass(true)} />
331
+ </span>
332
+ <Input
333
+ type="search"
334
+ bind:value={searchValue}
335
+ placeholder={searchPlaceholder}
336
+ aria-label={searchPlaceholder}
337
+ class="focus-ring focus-ring--nav 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"
338
+ />
263
339
  </div>
264
340
  {/if}
265
- </div>
266
- {/if}
267
-
268
- {#if showSearch}
269
- {#if shouldRenderSearchButton}
270
- <button
271
- type="button"
272
- class={searchTriggerClasses}
273
- onclick={handleSearchClick}
274
- aria-label={searchPlaceholder}
275
- >
276
- <span
277
- class="truncate text-base text-inherit transition-colors"
278
- >
279
- {searchValue || searchPlaceholder}
280
- </span>
281
- </button>
282
- {:else}
283
- <div class="relative w-full">
284
- <span
285
- class="pointer-events-none absolute left-4 top-1/2 z-10 -translate-y-1/2"
286
- aria-hidden="true"
287
- >
288
- <Search size={20} class={getTextToneClass(true)} />
289
- </span>
290
- <Input
291
- type="search"
292
- bind:value={searchValue}
293
- placeholder={searchPlaceholder}
294
- aria-label={searchPlaceholder}
295
- class="w-full min-w-0 rounded-2xl pl-12"
296
- />
297
- </div>
298
341
  {/if}
299
- {/if}
300
- </div>
342
+ </div>
301
343
 
302
- <div
303
- class="flex min-h-0 flex-1 w-full flex-col gap-6 overflow-y-auto overflow-x-hidden overscroll-y-contain py-1"
304
- role="region"
305
- aria-label="Navigation links"
306
- >
344
+ <div
345
+ class={`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-1.5 ${insetX}`}
346
+ role="region"
347
+ aria-label="Navigation links"
348
+ >
307
349
  {#if hasFilteredItems}
308
- <ul
309
- class="flex w-full flex-col gap-2 px-2"
310
- aria-label="Primary navigation"
350
+ <div
351
+ class="flex w-full min-w-0 flex-col divide-y divide-border"
311
352
  >
312
- {#each filteredPrimaryItems as item (item.id)}
313
- {@const Icon = item.icon}
314
- <li>
315
- <a
316
- href={item.href}
317
- class={getNavItemClasses(item)}
318
- onclick={(event) => handleNavigate(item, event)}
319
- aria-current={getAriaCurrent(item)}
320
- aria-label={isCollapsed ? item.label : undefined}
353
+ {#each primarySectionGroups as group, gi (`${gi}-${group.sectionLabel ?? "x"}`)}
354
+ <div class="py-4 first:pt-1 last:pb-1">
355
+ <SidebarNavSection
356
+ title={group.sectionLabel ?? ""}
357
+ sectionKey={`p-${gi}`}
358
+ listAriaLabel={group.sectionLabel
359
+ ? `${group.sectionLabel} navigation`
360
+ : "Primary navigation"}
361
+ collapsed={isCollapsed}
321
362
  >
322
- {#if Icon}
323
- <span
324
- class={iconContainerClasses}
325
- aria-hidden="true"
326
- >
327
- <Icon size={20} />
328
- </span>
329
- {/if}
330
- {#if !isCollapsed}
331
- <span
332
- class="text-base leading-snug text-inherit"
333
- >{item.label}</span
334
- >
335
- {/if}
336
- {#if !isCollapsed && getItemBadgeText(item) !== null}
337
- <span class="ml-auto">
338
- <Badge
339
- variant="default"
340
- size="sm"
341
- text={getItemBadgeText(item) ?? ""}
342
- />
343
- </span>
344
- {/if}
345
- </a>
346
- </li>
363
+ {#each group.items as item (item.id)}
364
+ {@const Icon = item.icon}
365
+ <li>
366
+ <a
367
+ href={item.href}
368
+ class={getNavItemClasses(item)}
369
+ onclick={(event) => handleNavigate(item, event)}
370
+ aria-current={getAriaCurrent(item)}
371
+ aria-label={isCollapsed ? item.label : undefined}
372
+ >
373
+ {#if Icon}
374
+ <span
375
+ class={iconContainerClasses}
376
+ aria-hidden="true"
377
+ >
378
+ <Icon size={18} />
379
+ </span>
380
+ {/if}
381
+ {#if !isCollapsed}
382
+ <span
383
+ class="text-sm font-medium leading-snug text-inherit"
384
+ >{item.label}</span
385
+ >
386
+ {/if}
387
+ {#if !isCollapsed && getItemBadgeText(item) !== null}
388
+ <span class="ml-auto">
389
+ <Badge
390
+ variant="default"
391
+ size="sm"
392
+ text={getItemBadgeText(item) ?? ""}
393
+ />
394
+ </span>
395
+ {/if}
396
+ </a>
397
+ </li>
398
+ {/each}
399
+ </SidebarNavSection>
400
+ </div>
347
401
  {/each}
348
- </ul>
402
+ </div>
349
403
 
350
404
  {#if filteredSecondaryItems.length > 0}
351
- <ul
352
- class="flex w-full flex-col gap-3 px-2"
353
- aria-label="Secondary navigation"
405
+ <div
406
+ class="mt-3 border-t border-border pt-4"
407
+ role="presentation"
354
408
  >
355
- {#each filteredSecondaryItems as item (item.id)}
356
- {@const Icon = item.icon}
357
- <li>
358
- <a
359
- href={item.href}
360
- class={getNavItemClasses(item)}
361
- onclick={(event) => handleNavigate(item, event)}
362
- aria-current={getAriaCurrent(item)}
363
- aria-label={isCollapsed
364
- ? item.label
365
- : undefined}
366
- >
367
- {#if Icon}
368
- <span
369
- class={iconContainerClasses}
370
- aria-hidden="true"
371
- >
372
- <Icon size={20} />
373
- </span>
374
- {/if}
375
- {#if !isCollapsed}
376
- <span
377
- class="text-base leading-snug text-inherit"
378
- >{item.label}</span
379
- >
380
- {/if}
381
- </a>
382
- </li>
383
- {/each}
384
- </ul>
409
+ <SidebarNavSection
410
+ sectionKey="secondary"
411
+ listAriaLabel="Secondary navigation"
412
+ collapsed={isCollapsed}
413
+ >
414
+ {#each filteredSecondaryItems as item (item.id)}
415
+ {@const Icon = item.icon}
416
+ <li>
417
+ <a
418
+ href={item.href}
419
+ class={getNavItemClasses(item)}
420
+ onclick={(event) =>
421
+ handleNavigate(item, event)}
422
+ aria-current={getAriaCurrent(item)}
423
+ aria-label={isCollapsed
424
+ ? item.label
425
+ : undefined}
426
+ >
427
+ {#if Icon}
428
+ <span
429
+ class={iconContainerClasses}
430
+ aria-hidden="true"
431
+ >
432
+ <Icon size={18} />
433
+ </span>
434
+ {/if}
435
+ {#if !isCollapsed}
436
+ <span
437
+ class="text-sm font-medium leading-snug text-inherit"
438
+ >{item.label}</span
439
+ >
440
+ {/if}
441
+ </a>
442
+ </li>
443
+ {/each}
444
+ </SidebarNavSection>
445
+ </div>
385
446
  {/if}
386
447
  {:else}
387
448
  <div
388
- class="rounded-2xl border border-border border-dashed bg-base-50 px-4 py-5"
449
+ class="rounded-xl border border-border border-dashed bg-transparent px-3.5 py-4 ring-1 ring-border/60"
389
450
  >
390
- <h3 class="text-base font-semibold {getTextToneClass()}">
451
+ <h3 class="text-sm font-semibold {getTextToneClass()}">
391
452
  {normalizedSearchTerm && searchMode === "input"
392
453
  ? "No matching navigation items"
393
454
  : emptyStateTitle}
@@ -400,7 +461,7 @@
400
461
  {#if !(normalizedSearchTerm && searchMode === "input")}
401
462
  <button
402
463
  type="button"
403
- class="mt-4 inline-flex min-h-11 cursor-pointer items-center rounded-lg bg-action-primary px-3 py-2 text-sm text-action-primary outline-none transition-colors hover:bg-action-primary-hover focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-nav-menu-focus"
464
+ class="focus-ring focus-ring--nav 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 outline-none transition-colors hover:bg-action-primary-hover"
404
465
  onclick={handleEmptyStateAction}
405
466
  >
406
467
  {emptyStateActionLabel}
@@ -408,57 +469,26 @@
408
469
  {/if}
409
470
  </div>
410
471
  {/if}
472
+ </div>
411
473
  </div>
412
474
 
413
- {#if showLogout || showThemeToggle}
414
- <div
415
- class="flex w-full shrink-0 flex-col gap-2 border-t border-border pt-4 px-2"
416
- >
417
- {#if showLogout}
418
- <button
419
- type="button"
420
- class={getControlButtonClasses()}
421
- onclick={handleLogout}
422
- >
423
- <span class={iconContainerClasses} aria-hidden="true">
424
- <LogOut size={20} />
425
- </span>
426
- {#if !isCollapsed}
427
- <span class="text-base text-inherit">{logoutLabel}</span
428
- >
429
- {/if}
430
- </button>
431
- {/if}
432
-
433
- {#if showThemeToggle}
434
- <div class={themeToggleContainerClasses}>
435
- <button
436
- type="button"
437
- class="inline-flex h-8 w-14 cursor-pointer items-center rounded-2xl p-1 outline-none transition-colors duration-150 hover:opacity-95 active:opacity-90 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-nav-menu-focus {isLightMode
438
- ? 'bg-action-primary'
439
- : 'bg-base-200'}"
440
- onclick={handleThemeToggle}
441
- role="switch"
442
- aria-checked={isLightMode}
443
- title={lightModeLabel}
444
- aria-label={isLightMode
445
- ? "Switch to dark mode"
446
- : "Switch to light mode"}
447
- >
448
- <span
449
- class="flex h-6 w-6 items-center justify-center rounded-xl leading-none shadow-sm transition-transform duration-150 active:scale-95 {isLightMode
450
- ? 'translate-x-6 bg-base-900 text-action-primary'
451
- : 'translate-x-0 bg-base-900 text-base-50'}"
452
- >
453
- {#if isLightMode}
454
- <Sun size={14} />
455
- {:else}
456
- <Moon size={14} />
457
- {/if}
458
- </span>
459
- </button>
460
- </div>
461
- {/if}
462
- </div>
463
- {/if}
475
+ <SidebarFooter
476
+ collapsed={isCollapsed}
477
+ {showProfile}
478
+ {profileName}
479
+ {profileEmail}
480
+ {profileInitials}
481
+ {showLogout}
482
+ {logoutLabel}
483
+ {showThemeToggle}
484
+ {lightModeLabel}
485
+ bind:isLightMode
486
+ {onLogout}
487
+ {onThemeToggle}
488
+ {onProfileClick}
489
+ {profilePanelOpen}
490
+ {profilePanelControlsId}
491
+ {profilePanel}
492
+ className={insetX}
493
+ />
464
494
  </nav>