zabi-components 5.0.21 → 5.0.22

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 (64) hide show
  1. package/README.md +1 -0
  2. package/dist/atoms/CardContent.svelte +1 -1
  3. package/dist/atoms/CardHeader.svelte +1 -1
  4. package/dist/atoms/Checkbox.svelte +1 -7
  5. package/dist/atoms/CodeBlock.svelte +6 -6
  6. package/dist/atoms/ColorPicker.svelte +80 -72
  7. package/dist/atoms/Input.svelte +14 -10
  8. package/dist/atoms/Input.svelte.d.ts +2 -0
  9. package/dist/atoms/List.svelte +47 -0
  10. package/dist/atoms/List.svelte.d.ts +19 -0
  11. package/dist/atoms/ListItem.svelte +148 -0
  12. package/dist/atoms/ListItem.svelte.d.ts +35 -0
  13. package/dist/atoms/Progress.svelte +3 -5
  14. package/dist/atoms/Select.svelte +59 -16
  15. package/dist/atoms/Select.svelte.d.ts +13 -1
  16. package/dist/atoms/Textarea.svelte +11 -9
  17. package/dist/atoms/Textarea.svelte.d.ts +1 -0
  18. package/dist/atoms/Toggle.svelte +1 -7
  19. package/dist/atoms/Tooltip.svelte +8 -15
  20. package/dist/atoms/index.d.ts +2 -0
  21. package/dist/atoms/index.js +2 -0
  22. package/dist/components/atoms/index.d.ts +2 -0
  23. package/dist/components/atoms/index.d.ts.map +1 -1
  24. package/dist/components/index.d.ts +4 -0
  25. package/dist/components/index.d.ts.map +1 -1
  26. package/dist/components/molecules/index.d.ts +0 -1
  27. package/dist/components/molecules/index.d.ts.map +1 -1
  28. package/dist/components/molecules/navigation-menu-context.d.ts +7 -0
  29. package/dist/components/molecules/navigation-menu-context.d.ts.map +1 -0
  30. package/dist/components/organisms/index.d.ts +2 -0
  31. package/dist/components/organisms/index.d.ts.map +1 -1
  32. package/dist/index.d.ts +4 -0
  33. package/dist/index.js +5 -8
  34. package/dist/molecules/Alert.svelte +8 -3
  35. package/dist/molecules/ContactForm.svelte +63 -31
  36. package/dist/molecules/ContactForm.svelte.d.ts +1 -4
  37. package/dist/molecules/Dropdown.svelte +2 -1
  38. package/dist/molecules/ImageUpload.svelte +26 -3
  39. package/dist/molecules/ImageUpload.svelte.d.ts +1 -0
  40. package/dist/molecules/Modal.svelte +44 -43
  41. package/dist/molecules/NavigationMenu.svelte +7 -10
  42. package/dist/molecules/NavigationMenuContent.svelte +8 -8
  43. package/dist/molecules/NavigationMenuItem.svelte +7 -6
  44. package/dist/molecules/NavigationMenuLink.svelte +7 -5
  45. package/dist/molecules/NavigationMenuList.svelte +0 -7
  46. package/dist/molecules/NavigationMenuTrigger.svelte +7 -7
  47. package/dist/molecules/SlideUp.svelte +30 -0
  48. package/dist/molecules/Tabs.svelte +71 -4
  49. package/dist/molecules/index.d.ts +0 -1
  50. package/dist/molecules/index.js +0 -1
  51. package/dist/molecules/navigation-menu-context.d.ts +6 -0
  52. package/dist/molecules/navigation-menu-context.js +1 -0
  53. package/dist/organisms/Navigation.svelte +7 -4
  54. package/dist/organisms/Navigation.svelte.d.ts +1 -0
  55. package/dist/organisms/SidebarNavigation.svelte +420 -0
  56. package/dist/organisms/SidebarNavigation.svelte.d.ts +44 -0
  57. package/dist/organisms/SidebarProjectPanel.svelte +174 -0
  58. package/dist/organisms/SidebarProjectPanel.svelte.d.ts +32 -0
  59. package/dist/organisms/index.d.ts +2 -0
  60. package/dist/organisms/index.js +2 -0
  61. package/dist/zabi-components.css +302 -0
  62. package/package.json +5 -4
  63. package/dist/molecules/Sidebar.svelte +0 -324
  64. package/dist/molecules/Sidebar.svelte.d.ts +0 -30
@@ -1,4 +1,11 @@
1
1
  <script lang="ts">
2
+ function createId(prefix: string): string {
3
+ if (typeof window !== "undefined") {
4
+ return `${prefix}-${Math.random().toString(36).slice(2, 11)}`;
5
+ }
6
+ return `${prefix}-ssr-${Date.now()}`;
7
+ }
8
+
2
9
  interface Props {
3
10
  tabs?: Array<{
4
11
  id: string;
@@ -19,17 +26,69 @@
19
26
  ...restProps
20
27
  }: Props & { children?: any } = $props();
21
28
 
29
+ const tabsBaseId = createId("tabs");
30
+
22
31
  function selectTab(tabId: string) {
23
32
  activeTab = tabId;
24
33
  }
25
34
 
35
+ function getEnabledTabs() {
36
+ return tabs.filter((tab) => !tab.disabled);
37
+ }
38
+
39
+ function getTabIndex(tabId: string) {
40
+ return tabs.findIndex((tab) => tab.id === tabId);
41
+ }
42
+
43
+ function getTabId(tabId: string) {
44
+ return `${tabsBaseId}-tab-${tabId}`;
45
+ }
46
+
47
+ function getPanelId(tabId: string) {
48
+ return `${tabsBaseId}-panel-${tabId}`;
49
+ }
50
+
51
+ $effect(() => {
52
+ if (tabs.length === 0) {
53
+ return;
54
+ }
55
+
56
+ const activeExists = tabs.some((tab) => tab.id === activeTab && !tab.disabled);
57
+ if (!activeExists) {
58
+ const firstEnabledTab = tabs.find((tab) => !tab.disabled);
59
+ if (firstEnabledTab) {
60
+ activeTab = firstEnabledTab.id;
61
+ }
62
+ }
63
+ });
64
+
26
65
  function handleKeydown(event: KeyboardEvent) {
66
+ const enabledTabs = getEnabledTabs();
67
+ if (enabledTabs.length === 0) {
68
+ return;
69
+ }
70
+
71
+ const currentIndex = enabledTabs.findIndex((tab) => tab.id === activeTab);
72
+ const fallbackIndex = currentIndex === -1 ? 0 : currentIndex;
73
+
27
74
  if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
28
- const currentIndex = tabs.findIndex((tab) => tab.id === activeTab);
75
+ event.preventDefault();
29
76
  const direction = event.key === "ArrowLeft" ? -1 : 1;
30
77
  const nextIndex =
31
- (currentIndex + direction + tabs.length) % tabs.length;
32
- selectTab(tabs[nextIndex].id);
78
+ (fallbackIndex + direction + enabledTabs.length) %
79
+ enabledTabs.length;
80
+ selectTab(enabledTabs[nextIndex].id);
81
+ } else if (event.key === "Home") {
82
+ event.preventDefault();
83
+ selectTab(enabledTabs[0].id);
84
+ } else if (event.key === "End") {
85
+ event.preventDefault();
86
+ selectTab(enabledTabs[enabledTabs.length - 1].id);
87
+ } else if (event.key === "Enter" || event.key === " ") {
88
+ event.preventDefault();
89
+ if (currentIndex === -1) {
90
+ selectTab(enabledTabs[0].id);
91
+ }
33
92
  }
34
93
  }
35
94
  </script>
@@ -45,6 +104,7 @@
45
104
  <button
46
105
  type="button"
47
106
  role="tab"
107
+ id={getTabId(tab.id)}
48
108
  class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab ===
49
109
  tab.id
50
110
  ? variant === 'pills'
@@ -54,13 +114,20 @@
54
114
  onclick={() => selectTab(tab.id)}
55
115
  disabled={tab.disabled}
56
116
  aria-selected={activeTab === tab.id}
117
+ aria-controls={getPanelId(tab.id)}
118
+ tabindex={activeTab === tab.id ? 0 : -1}
57
119
  >
58
120
  {tab.label}
59
121
  </button>
60
122
  {/each}
61
123
  </div>
62
124
 
63
- <div class="mt-4">
125
+ <div
126
+ class="mt-4"
127
+ role="tabpanel"
128
+ id={getPanelId(activeTab)}
129
+ aria-labelledby={getTabId(activeTab)}
130
+ >
64
131
  {@render children?.({ activeTab })}
65
132
  </div>
66
133
  </div>
@@ -12,6 +12,5 @@ export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.svelte
12
12
  export { default as NavigationMenuContent } from './NavigationMenuContent.svelte';
13
13
  export { default as NavigationMenuLink } from './NavigationMenuLink.svelte';
14
14
  export { default as Section } from './Section.svelte';
15
- export { default as Sidebar } from './Sidebar.svelte';
16
15
  export { default as SlideUp } from './SlideUp.svelte';
17
16
  export { default as Tabs } from './Tabs.svelte';
@@ -12,6 +12,5 @@ export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.svelte
12
12
  export { default as NavigationMenuContent } from './NavigationMenuContent.svelte';
13
13
  export { default as NavigationMenuLink } from './NavigationMenuLink.svelte';
14
14
  export { default as Section } from './Section.svelte';
15
- export { default as Sidebar } from './Sidebar.svelte';
16
15
  export { default as SlideUp } from './SlideUp.svelte';
17
16
  export { default as Tabs } from './Tabs.svelte';
@@ -0,0 +1,6 @@
1
+ export interface NavigationMenuContextValue {
2
+ activeItem: string | null;
3
+ setActiveItem: (item: string | null) => void;
4
+ isMobile: boolean;
5
+ }
6
+ export declare const NAVIGATION_MENU_CONTEXT_KEY: unique symbol;
@@ -0,0 +1 @@
1
+ export const NAVIGATION_MENU_CONTEXT_KEY = Symbol("navigation-menu");
@@ -12,6 +12,7 @@
12
12
  variant?: "header" | "sidebar";
13
13
  items?: NavigationItem[];
14
14
  currentPath?: string;
15
+ preventNavigation?: boolean;
15
16
  onclick?: (event: Event) => void;
16
17
  className?: string;
17
18
  }
@@ -20,13 +21,16 @@
20
21
  variant = "header",
21
22
  items = [],
22
23
  currentPath = "",
24
+ preventNavigation = false,
23
25
  onclick,
24
26
  className = "",
25
27
  ...restProps
26
28
  }: Props & { children?: any } = $props();
27
29
 
28
- function handleClick(item: NavigationItem, event: MouseEvent) {
29
- event.preventDefault();
30
+ function handleClick(event: MouseEvent) {
31
+ if (preventNavigation) {
32
+ event.preventDefault();
33
+ }
30
34
  if (onclick) {
31
35
  onclick(event as any);
32
36
  }
@@ -88,8 +92,7 @@
88
92
  <a
89
93
  href={item.href}
90
94
  class={iconContainerClasses}
91
- onclick={(e) => handleClick(item, e)}
92
- role="button"
95
+ onclick={handleClick}
93
96
  aria-current={isActive ? "page" : undefined}
94
97
  >
95
98
  <div class={getStateLayerClasses()}>
@@ -9,6 +9,7 @@ interface Props {
9
9
  variant?: "header" | "sidebar";
10
10
  items?: NavigationItem[];
11
11
  currentPath?: string;
12
+ preventNavigation?: boolean;
12
13
  onclick?: (event: Event) => void;
13
14
  className?: string;
14
15
  }
@@ -0,0 +1,420 @@
1
+ <script lang="ts">
2
+ import Badge from "../atoms/Badge.svelte";
3
+ import Input from "../atoms/Input.svelte";
4
+ import { Search, LogOut, Sun, Moon } from "@lucide/svelte";
5
+ import type { Component } from "svelte";
6
+
7
+ export interface SidebarNavigationItem {
8
+ id: string;
9
+ label: string;
10
+ href: string;
11
+ icon?: Component<{ size?: number; class?: string }>;
12
+ badgeText?: string | number;
13
+ badgeCount?: number;
14
+ group?: "primary" | "secondary";
15
+ }
16
+
17
+ interface Props {
18
+ mode?: "expanded" | "collapsed";
19
+ items?: SidebarNavigationItem[];
20
+ currentPath?: string;
21
+ ariaLabel?: string;
22
+ className?: string;
23
+ showProfile?: boolean;
24
+ profileName?: string;
25
+ profileEmail?: string;
26
+ profileInitials?: string;
27
+ showSearch?: boolean;
28
+ searchMode?: "input" | "button";
29
+ searchPlaceholder?: string;
30
+ searchValue?: string;
31
+ showLogout?: boolean;
32
+ logoutLabel?: string;
33
+ showThemeToggle?: boolean;
34
+ lightModeLabel?: string;
35
+ isLightMode?: boolean;
36
+ emptyStateTitle?: string;
37
+ emptyStateDescription?: string;
38
+ emptyStateActionLabel?: string;
39
+ onNavigate?: (item: SidebarNavigationItem, event: MouseEvent) => void;
40
+ onSearchClick?: () => void;
41
+ onLogout?: () => void;
42
+ onThemeToggle?: (nextIsLightMode: boolean) => void;
43
+ onEmptyStateAction?: () => void;
44
+ }
45
+
46
+ let {
47
+ mode = "expanded",
48
+ items = [],
49
+ currentPath = "",
50
+ ariaLabel = "Sidebar navigation",
51
+ className = "",
52
+ showProfile = true,
53
+ profileName = "Zabi",
54
+ profileEmail = "hello@zabi.dev",
55
+ profileInitials = "ZA",
56
+ showSearch = true,
57
+ searchMode = "input",
58
+ searchPlaceholder = "Search...",
59
+ searchValue = $bindable(""),
60
+ showLogout = true,
61
+ logoutLabel = "Logout",
62
+ showThemeToggle = true,
63
+ lightModeLabel = "Light mode",
64
+ isLightMode = $bindable(false),
65
+ emptyStateTitle = "Create your first navigation item",
66
+ emptyStateDescription = "Add your first sidebar item so users can start navigating your product.",
67
+ emptyStateActionLabel = "Add navigation item",
68
+ onNavigate,
69
+ onSearchClick,
70
+ onLogout,
71
+ onThemeToggle,
72
+ onEmptyStateAction,
73
+ ...restProps
74
+ }: Props = $props();
75
+
76
+ const isCollapsed = $derived(mode === "collapsed");
77
+ const normalizedSearchTerm = $derived(searchValue.trim().toLowerCase());
78
+ const primaryItems = $derived(
79
+ items.filter((item) => item.group !== "secondary"),
80
+ );
81
+ const secondaryItems = $derived(
82
+ items.filter((item) => item.group === "secondary"),
83
+ );
84
+ const filteredPrimaryItems = $derived(
85
+ searchMode === "input" && normalizedSearchTerm
86
+ ? primaryItems.filter((item) =>
87
+ item.label.toLowerCase().includes(normalizedSearchTerm),
88
+ )
89
+ : primaryItems,
90
+ );
91
+ const filteredSecondaryItems = $derived(
92
+ searchMode === "input" && normalizedSearchTerm
93
+ ? secondaryItems.filter((item) =>
94
+ item.label.toLowerCase().includes(normalizedSearchTerm),
95
+ )
96
+ : secondaryItems,
97
+ );
98
+ const hasFilteredItems = $derived(
99
+ filteredPrimaryItems.length + filteredSecondaryItems.length > 0,
100
+ );
101
+
102
+ const containerClasses = $derived.by(() => {
103
+ 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";
107
+
108
+ return `${baseClasses} ${widthClass} ${surfaceClasses} ${className}`.trim();
109
+ });
110
+
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",
113
+ );
114
+
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
+ const iconContainerClasses = $derived(
124
+ "flex size-6 shrink-0 items-center justify-center leading-none",
125
+ );
126
+ const shouldRenderSearchButton = $derived(
127
+ isCollapsed || searchMode === "button" || Boolean(onSearchClick),
128
+ );
129
+
130
+ function getTextToneClass(isMuted = false): string {
131
+ return isMuted ? "text-description" : "text-headline";
132
+ }
133
+
134
+ 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";
138
+ 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";
141
+
142
+ if (isActive) {
143
+ return `${baseClasses} ${layoutClasses} bg-action-primary-subtle text-headline`;
144
+ }
145
+
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}`;
157
+ }
158
+
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
+ function handleNavigate(item: SidebarNavigationItem, event: MouseEvent) {
166
+ if (onNavigate) {
167
+ onNavigate(item, event);
168
+ }
169
+ }
170
+
171
+ function handleSearchClick() {
172
+ if (onSearchClick) {
173
+ onSearchClick();
174
+ }
175
+ }
176
+
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
+ function handleEmptyStateAction() {
191
+ if (onEmptyStateAction) {
192
+ onEmptyStateAction();
193
+ }
194
+ }
195
+
196
+ function getItemBadgeText(item: SidebarNavigationItem): string | null {
197
+ if (item.badgeText !== undefined) {
198
+ return String(item.badgeText);
199
+ }
200
+ if (item.badgeCount !== undefined) {
201
+ return String(item.badgeCount);
202
+ }
203
+ return null;
204
+ }
205
+ </script>
206
+
207
+ <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}
228
+
229
+ <div class="flex w-full flex-col gap-6">
230
+ {#if showSearch}
231
+ {#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}
240
+ </span>
241
+ </button>
242
+ {:else}
243
+ <div class="relative w-full">
244
+ <span
245
+ class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 z-10"
246
+ aria-hidden="true"
247
+ >
248
+ <Search size={20} class={getTextToneClass(true)} />
249
+ </span>
250
+ <Input
251
+ type="search"
252
+ bind:value={searchValue}
253
+ placeholder={searchPlaceholder}
254
+ aria-label={searchPlaceholder}
255
+ class="w-full min-w-0 rounded-2xl pl-12"
256
+ />
257
+ </div>
258
+ {/if}
259
+ {/if}
260
+
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
291
+ >
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>
306
+
307
+ {#if filteredSecondaryItems.length > 0}
308
+ <ul
309
+ class="flex w-full flex-col gap-3"
310
+ aria-label="Secondary navigation"
311
+ >
312
+ {#each filteredSecondaryItems as item (item.id)}
313
+ {@const Icon = item.icon}
314
+ <li>
315
+ <a
316
+ href={item.href}
317
+ class={getNavItemClasses(item)}
318
+ onclick={(event) =>
319
+ handleNavigate(item, event)}
320
+ aria-current={currentPath === item.href
321
+ ? "page"
322
+ : undefined}
323
+ aria-label={isCollapsed
324
+ ? item.label
325
+ : undefined}
326
+ >
327
+ {#if Icon}
328
+ <span
329
+ class={iconContainerClasses}
330
+ aria-hidden="true"
331
+ >
332
+ <Icon size={20} />
333
+ </span>
334
+ {/if}
335
+ {#if !isCollapsed}
336
+ <span class="text-base leading-snug"
337
+ >{item.label}</span
338
+ >
339
+ {/if}
340
+ </a>
341
+ </li>
342
+ {/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}
370
+ </div>
371
+ {/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}
381
+ >
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'}"
409
+ >
410
+ {#if isLightMode}
411
+ <Sun size={14} />
412
+ {:else}
413
+ <Moon size={14} />
414
+ {/if}
415
+ </span>
416
+ </button>
417
+ </div>
418
+ {/if}
419
+ </div>
420
+ </nav>
@@ -0,0 +1,44 @@
1
+ import type { Component } from "svelte";
2
+ export interface SidebarNavigationItem {
3
+ id: string;
4
+ label: string;
5
+ href: string;
6
+ icon?: Component<{
7
+ size?: number;
8
+ class?: string;
9
+ }>;
10
+ badgeText?: string | number;
11
+ badgeCount?: number;
12
+ group?: "primary" | "secondary";
13
+ }
14
+ interface Props {
15
+ mode?: "expanded" | "collapsed";
16
+ items?: SidebarNavigationItem[];
17
+ currentPath?: string;
18
+ ariaLabel?: string;
19
+ className?: string;
20
+ showProfile?: boolean;
21
+ profileName?: string;
22
+ profileEmail?: string;
23
+ profileInitials?: string;
24
+ showSearch?: boolean;
25
+ searchMode?: "input" | "button";
26
+ searchPlaceholder?: string;
27
+ searchValue?: string;
28
+ showLogout?: boolean;
29
+ logoutLabel?: string;
30
+ showThemeToggle?: boolean;
31
+ lightModeLabel?: string;
32
+ isLightMode?: boolean;
33
+ emptyStateTitle?: string;
34
+ emptyStateDescription?: string;
35
+ emptyStateActionLabel?: string;
36
+ onNavigate?: (item: SidebarNavigationItem, event: MouseEvent) => void;
37
+ onSearchClick?: () => void;
38
+ onLogout?: () => void;
39
+ onThemeToggle?: (nextIsLightMode: boolean) => void;
40
+ onEmptyStateAction?: () => void;
41
+ }
42
+ declare const SidebarNavigation: Component<Props, {}, "searchValue" | "isLightMode">;
43
+ type SidebarNavigation = ReturnType<typeof SidebarNavigation>;
44
+ export default SidebarNavigation;