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