zabi-components 5.0.21 → 6.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 (89) hide show
  1. package/README.md +42 -44
  2. package/THEME.md +473 -0
  3. package/THEMING.md +248 -0
  4. package/dist/atoms/Card.svelte +40 -67
  5. package/dist/atoms/Card.svelte.d.ts +3 -3
  6. package/dist/atoms/CardContent.svelte +1 -1
  7. package/dist/atoms/CardHeader.svelte +10 -1
  8. package/dist/atoms/CardHeader.svelte.d.ts +3 -0
  9. package/dist/atoms/Checkbox.svelte +1 -7
  10. package/dist/atoms/CodeBlock.svelte +7 -7
  11. package/dist/atoms/ColorPicker.svelte +81 -73
  12. package/dist/atoms/Input.svelte +16 -12
  13. package/dist/atoms/Input.svelte.d.ts +2 -0
  14. package/dist/atoms/List.svelte +47 -0
  15. package/dist/atoms/List.svelte.d.ts +19 -0
  16. package/dist/atoms/ListItem.svelte +148 -0
  17. package/dist/atoms/ListItem.svelte.d.ts +35 -0
  18. package/dist/atoms/Progress.svelte +4 -6
  19. package/dist/atoms/Select.svelte +65 -20
  20. package/dist/atoms/Select.svelte.d.ts +13 -1
  21. package/dist/atoms/Textarea.svelte +13 -11
  22. package/dist/atoms/Textarea.svelte.d.ts +1 -0
  23. package/dist/atoms/Toast.svelte +1 -1
  24. package/dist/atoms/Toggle.svelte +1 -7
  25. package/dist/atoms/Tooltip.svelte +8 -15
  26. package/dist/atoms/index.d.ts +2 -0
  27. package/dist/atoms/index.js +2 -0
  28. package/dist/components/atoms/index.d.ts +2 -0
  29. package/dist/components/atoms/index.d.ts.map +1 -1
  30. package/dist/components/index.d.ts +4 -1
  31. package/dist/components/index.d.ts.map +1 -1
  32. package/dist/components/molecules/index.d.ts +0 -1
  33. package/dist/components/molecules/index.d.ts.map +1 -1
  34. package/dist/components/molecules/navigation-menu-context.d.ts +7 -0
  35. package/dist/components/molecules/navigation-menu-context.d.ts.map +1 -0
  36. package/dist/components/organisms/index.d.ts +2 -1
  37. package/dist/components/organisms/index.d.ts.map +1 -1
  38. package/dist/index.d.ts +4 -1
  39. package/dist/index.js +5 -9
  40. package/dist/lib/showcase/components-catalog.d.ts +3 -0
  41. package/dist/lib/showcase/components-catalog.d.ts.map +1 -0
  42. package/dist/lib/showcase/components-showcase-constants.d.ts +63 -0
  43. package/dist/lib/showcase/components-showcase-constants.d.ts.map +1 -0
  44. package/dist/lib/showcase/docs-sidebar-helpers.d.ts +15 -0
  45. package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +1 -0
  46. package/dist/molecules/Alert.svelte +9 -4
  47. package/dist/molecules/ComponentDemo.svelte +53 -50
  48. package/dist/molecules/ContactForm.svelte +113 -76
  49. package/dist/molecules/ContactForm.svelte.d.ts +1 -4
  50. package/dist/molecules/Dropdown.svelte +4 -2
  51. package/dist/molecules/ImageUpload.svelte +27 -4
  52. package/dist/molecules/ImageUpload.svelte.d.ts +1 -0
  53. package/dist/molecules/Modal.svelte +46 -45
  54. package/dist/molecules/NavigationMenu.svelte +7 -10
  55. package/dist/molecules/NavigationMenuContent.svelte +9 -9
  56. package/dist/molecules/NavigationMenuItem.svelte +7 -6
  57. package/dist/molecules/NavigationMenuLink.svelte +8 -6
  58. package/dist/molecules/NavigationMenuList.svelte +0 -7
  59. package/dist/molecules/NavigationMenuTrigger.svelte +8 -11
  60. package/dist/molecules/Section.svelte +12 -12
  61. package/dist/molecules/SlideUp.svelte +32 -2
  62. package/dist/molecules/Tabs.svelte +74 -7
  63. package/dist/molecules/index.d.ts +0 -1
  64. package/dist/molecules/index.js +0 -1
  65. package/dist/molecules/navigation-menu-context.d.ts +6 -0
  66. package/dist/molecules/navigation-menu-context.js +1 -0
  67. package/dist/organisms/Navbar.svelte +170 -53
  68. package/dist/organisms/Navbar.svelte.d.ts +31 -4
  69. package/dist/organisms/SidebarNavigation.svelte +464 -0
  70. package/dist/organisms/SidebarNavigation.svelte.d.ts +49 -0
  71. package/dist/organisms/SidebarPanel.svelte +175 -0
  72. package/dist/organisms/SidebarPanel.svelte.d.ts +32 -0
  73. package/dist/organisms/index.d.ts +2 -1
  74. package/dist/organisms/index.js +2 -1
  75. package/dist/types/page.types.d.ts +32 -0
  76. package/dist/types/page.types.d.ts.map +1 -0
  77. package/dist/types/variants.ts +76 -0
  78. package/dist/zabi-components-colors.css +347 -324
  79. package/dist/zabi-components-theme-dark-only.css +159 -151
  80. package/dist/zabi-components-theme-dark.css +159 -151
  81. package/dist/zabi-components-theme-only.css +245 -230
  82. package/dist/zabi-components-theme.css +245 -230
  83. package/dist/zabi-components.css +722 -84
  84. package/docs/theme-imports.md +112 -0
  85. package/package.json +20 -9
  86. package/dist/molecules/Sidebar.svelte +0 -324
  87. package/dist/molecules/Sidebar.svelte.d.ts +0 -30
  88. package/dist/organisms/Navigation.svelte +0 -116
  89. package/dist/organisms/Navigation.svelte.d.ts +0 -20
@@ -0,0 +1,464 @@
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
+ * 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
+ */
48
+ activePrimaryHref?: string;
49
+ }
50
+
51
+ let {
52
+ mode = "expanded",
53
+ items = [],
54
+ currentPath = "",
55
+ activePrimaryHref = "",
56
+ ariaLabel = "Sidebar navigation",
57
+ className = "",
58
+ showProfile = true,
59
+ profileName = "Zabi",
60
+ profileEmail = "hello@zabi.dev",
61
+ profileInitials = "ZA",
62
+ showSearch = true,
63
+ searchMode = "input",
64
+ searchPlaceholder = "Search...",
65
+ searchValue = $bindable(""),
66
+ showLogout = true,
67
+ logoutLabel = "Logout",
68
+ showThemeToggle = true,
69
+ lightModeLabel = "Light mode",
70
+ isLightMode = $bindable(false),
71
+ emptyStateTitle = "Create your first navigation item",
72
+ emptyStateDescription = "Add your first sidebar item so users can start navigating your product.",
73
+ emptyStateActionLabel = "Add navigation item",
74
+ onNavigate,
75
+ onSearchClick,
76
+ onLogout,
77
+ onThemeToggle,
78
+ onEmptyStateAction,
79
+ ...restProps
80
+ }: Props = $props();
81
+
82
+ const isCollapsed = $derived(mode === "collapsed");
83
+ const normalizedSearchTerm = $derived(searchValue.trim().toLowerCase());
84
+ const primaryItems = $derived(
85
+ items.filter((item) => item.group !== "secondary"),
86
+ );
87
+ const secondaryItems = $derived(
88
+ items.filter((item) => item.group === "secondary"),
89
+ );
90
+ function itemMatchesSearch(item: SidebarNavigationItem): boolean {
91
+ return item.label.toLowerCase().includes(normalizedSearchTerm);
92
+ }
93
+
94
+ /** Keep the current (and section) row visible even when it does not match the filter. */
95
+ function itemPinnedBySelection(item: SidebarNavigationItem): boolean {
96
+ if (currentPath === item.href) {
97
+ return true;
98
+ }
99
+ return Boolean(activePrimaryHref) && item.href === activePrimaryHref;
100
+ }
101
+
102
+ const filteredPrimaryItems = $derived(
103
+ searchMode === "input" && normalizedSearchTerm
104
+ ? primaryItems.filter(
105
+ (item) =>
106
+ itemMatchesSearch(item) || itemPinnedBySelection(item),
107
+ )
108
+ : primaryItems,
109
+ );
110
+ const filteredSecondaryItems = $derived(
111
+ searchMode === "input" && normalizedSearchTerm
112
+ ? secondaryItems.filter(
113
+ (item) =>
114
+ itemMatchesSearch(item) || itemPinnedBySelection(item),
115
+ )
116
+ : secondaryItems,
117
+ );
118
+ const hasFilteredItems = $derived(
119
+ filteredPrimaryItems.length + filteredSecondaryItems.length > 0,
120
+ );
121
+
122
+ const containerClasses = $derived.by(() => {
123
+ 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";
127
+
128
+ return `${baseClasses} ${widthClass} ${surfaceClasses} ${className}`.trim();
129
+ });
130
+
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",
133
+ );
134
+
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
+ const iconContainerClasses = $derived(
148
+ "flex size-6 shrink-0 items-center justify-center leading-none text-current",
149
+ );
150
+ const shouldRenderSearchButton = $derived(
151
+ isCollapsed || searchMode === "button" || Boolean(onSearchClick),
152
+ );
153
+
154
+ function getTextToneClass(isMuted = false): string {
155
+ return isMuted ? "text-description" : "text-headline";
156
+ }
157
+
158
+ function getNavItemClasses(item: SidebarNavigationItem): string {
159
+ const isActive =
160
+ currentPath === item.href ||
161
+ (Boolean(activePrimaryHref) && item.href === activePrimaryHref);
162
+ 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";
165
+ 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";
167
+
168
+ if (isActive) {
169
+ /* Current page: keep active surface on hover (do not apply inactive hover bg). */
170
+ return `${structural} ${layoutClasses} bg-nav-menu-active text-inherit hover:bg-nav-menu-active hover:text-inherit active:opacity-90`;
171
+ }
172
+
173
+ return `${structural} ${layoutClasses} text-nav-menu-item hover:bg-nav-menu-hover hover:text-nav-menu-item-hover active:bg-base-200`;
174
+ }
175
+
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
+ function handleNavigate(item: SidebarNavigationItem, event: MouseEvent) {
193
+ if (onNavigate) {
194
+ onNavigate(item, event);
195
+ }
196
+ }
197
+
198
+ function handleSearchClick() {
199
+ if (onSearchClick) {
200
+ onSearchClick();
201
+ }
202
+ }
203
+
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
+ function handleEmptyStateAction() {
218
+ if (onEmptyStateAction) {
219
+ onEmptyStateAction();
220
+ }
221
+ }
222
+
223
+ function getItemBadgeText(item: SidebarNavigationItem): string | null {
224
+ if (item.badgeText !== undefined) {
225
+ return String(item.badgeText);
226
+ }
227
+ if (item.badgeCount !== undefined) {
228
+ return String(item.badgeCount);
229
+ }
230
+ return null;
231
+ }
232
+
233
+ function getAriaCurrent(
234
+ item: SidebarNavigationItem,
235
+ ): "page" | "location" | undefined {
236
+ if (currentPath === item.href) {
237
+ return "page";
238
+ }
239
+ if (activePrimaryHref && item.href === activePrimaryHref) {
240
+ return "location";
241
+ }
242
+ return undefined;
243
+ }
244
+ </script>
245
+
246
+ <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()}"
257
+ >
258
+ {profileName}
259
+ </p>
260
+ <p class="truncate text-sm {getTextToneClass(true)}">
261
+ {profileEmail}
262
+ </p>
263
+ </div>
264
+ {/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
+ {/if}
299
+ {/if}
300
+ </div>
301
+
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
+ >
307
+ {#if hasFilteredItems}
308
+ <ul
309
+ class="flex w-full flex-col gap-2 px-2"
310
+ aria-label="Primary navigation"
311
+ >
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}
321
+ >
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>
347
+ {/each}
348
+ </ul>
349
+
350
+ {#if filteredSecondaryItems.length > 0}
351
+ <ul
352
+ class="flex w-full flex-col gap-3 px-2"
353
+ aria-label="Secondary navigation"
354
+ >
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>
385
+ {/if}
386
+ {:else}
387
+ <div
388
+ class="rounded-2xl border border-border border-dashed bg-base-50 px-4 py-5"
389
+ >
390
+ <h3 class="text-base font-semibold {getTextToneClass()}">
391
+ {normalizedSearchTerm && searchMode === "input"
392
+ ? "No matching navigation items"
393
+ : emptyStateTitle}
394
+ </h3>
395
+ <p class="mt-1 text-sm {getTextToneClass(true)}">
396
+ {normalizedSearchTerm && searchMode === "input"
397
+ ? `No results found for "${searchValue}". Try another keyword.`
398
+ : emptyStateDescription}
399
+ </p>
400
+ {#if !(normalizedSearchTerm && searchMode === "input")}
401
+ <button
402
+ 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"
404
+ onclick={handleEmptyStateAction}
405
+ >
406
+ {emptyStateActionLabel}
407
+ </button>
408
+ {/if}
409
+ </div>
410
+ {/if}
411
+ </div>
412
+
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}
464
+ </nav>
@@ -0,0 +1,49 @@
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
+ * Highlights a primary (e.g. category) row when the current route is a child
43
+ * of that section, while `currentPath` points at the leaf (e.g. a component).
44
+ */
45
+ activePrimaryHref?: string;
46
+ }
47
+ declare const SidebarNavigation: Component<Props, {}, "searchValue" | "isLightMode">;
48
+ type SidebarNavigation = ReturnType<typeof SidebarNavigation>;
49
+ export default SidebarNavigation;
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ import Input from "../atoms/Input.svelte";
3
+ import Badge from "../atoms/Badge.svelte";
4
+ import { Search, X } from "@lucide/svelte";
5
+ import type { Component } from "svelte";
6
+
7
+ export interface SidebarPanelItem {
8
+ id: string;
9
+ label: string;
10
+ icon?: Component<{ size?: number; class?: string }>;
11
+ description?: string;
12
+ badgeText?: string;
13
+ }
14
+
15
+ interface Props {
16
+ className?: string;
17
+ widthClass?: string;
18
+ ariaLabel?: string;
19
+ title?: string;
20
+ subtitle?: string;
21
+ showSearch?: boolean;
22
+ searchPlaceholder?: string;
23
+ searchValue?: string;
24
+ items?: SidebarPanelItem[];
25
+ selectedItemId?: string;
26
+ emptyStateTitle?: string;
27
+ emptyStateDescription?: string;
28
+ selectLabel?: string;
29
+ closeLabel?: string;
30
+ onSelect?: (item: SidebarPanelItem) => void;
31
+ onClose?: () => void;
32
+ }
33
+
34
+ let {
35
+ className = "",
36
+ widthClass = "w-80",
37
+ ariaLabel = "Picker panel",
38
+ title = "Items",
39
+ subtitle = "Choose an item to continue",
40
+ showSearch = true,
41
+ searchPlaceholder = "Search...",
42
+ searchValue = $bindable(""),
43
+ items = [],
44
+ selectedItemId = $bindable(""),
45
+ emptyStateTitle = "No matches",
46
+ emptyStateDescription = "Try a different keyword.",
47
+ selectLabel = "Select item",
48
+ closeLabel = "Close panel",
49
+ onSelect,
50
+ onClose,
51
+ ...restProps
52
+ }: Props = $props();
53
+
54
+ const normalizedSearchTerm = $derived(searchValue.trim().toLowerCase());
55
+ const filteredItems = $derived(
56
+ normalizedSearchTerm
57
+ ? items.filter((item) =>
58
+ item.label.toLowerCase().includes(normalizedSearchTerm),
59
+ )
60
+ : items,
61
+ );
62
+
63
+ const containerClasses = $derived.by(() => {
64
+ const resolvedWidthClass = widthClass.trim() || "w-80";
65
+ return `${resolvedWidthClass} shrink-0 rounded-2xl border border-border bg-background p-4 ${className}`.trim();
66
+ });
67
+
68
+ function getItemClasses(itemId: string): string {
69
+ const isActive = selectedItemId === itemId;
70
+ const baseClasses =
71
+ "w-full cursor-pointer rounded-xl px-3 py-3 text-left transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-0";
72
+ if (isActive) {
73
+ return `${baseClasses} bg-action-primary-subtle text-headline`;
74
+ }
75
+ return `${baseClasses} hover:bg-base-50 text-description`;
76
+ }
77
+
78
+ function handleSelect(item: SidebarPanelItem): void {
79
+ selectedItemId = item.id;
80
+ if (onSelect) {
81
+ onSelect(item);
82
+ }
83
+ }
84
+
85
+ function handleClose(): void {
86
+ if (onClose) {
87
+ onClose();
88
+ }
89
+ }
90
+ </script>
91
+
92
+ <aside class={containerClasses} aria-label={ariaLabel} {...restProps}>
93
+ <div class="mb-4 flex items-start justify-between gap-3">
94
+ <div class="min-w-0">
95
+ <h3 class="truncate text-base font-semibold text-headline">{title}</h3>
96
+ <p class="mt-1 text-sm text-description">{subtitle}</p>
97
+ </div>
98
+ <button
99
+ type="button"
100
+ class="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-description hover:bg-base-50 hover:text-headline focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-0"
101
+ aria-label={closeLabel}
102
+ onclick={handleClose}
103
+ >
104
+ <X size={16} />
105
+ </button>
106
+ </div>
107
+
108
+ {#if showSearch}
109
+ <div class="relative mb-3">
110
+ <span
111
+ class="pointer-events-none absolute left-4 top-1/2 z-10 -translate-y-1/2 text-description"
112
+ aria-hidden="true"
113
+ >
114
+ <Search size={18} />
115
+ </span>
116
+ <Input
117
+ type="search"
118
+ bind:value={searchValue}
119
+ placeholder={searchPlaceholder}
120
+ aria-label={searchPlaceholder}
121
+ class="w-full min-w-0 rounded-xl pl-11"
122
+ />
123
+ </div>
124
+ {/if}
125
+
126
+ {#if filteredItems.length > 0}
127
+ <ul class="space-y-2" role="listbox" aria-label={selectLabel}>
128
+ {#each filteredItems as item (item.id)}
129
+ <li role="presentation">
130
+ <button
131
+ type="button"
132
+ role="option"
133
+ class={getItemClasses(item.id)}
134
+ onclick={() => handleSelect(item)}
135
+ aria-selected={selectedItemId === item.id}
136
+ >
137
+ <span class="flex items-center justify-between gap-3">
138
+ <span class="flex min-w-0 items-start gap-2">
139
+ {#if item.icon}
140
+ {@const Icon = item.icon}
141
+ <span
142
+ class="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-current"
143
+ aria-hidden="true"
144
+ >
145
+ <Icon size={16} />
146
+ </span>
147
+ {/if}
148
+ <span class="min-w-0">
149
+ <span class="block truncate text-sm font-medium text-current">
150
+ {item.label}
151
+ </span>
152
+ {#if item.description}
153
+ <span
154
+ class="mt-0.5 block truncate text-xs text-description"
155
+ >
156
+ {item.description}
157
+ </span>
158
+ {/if}
159
+ </span>
160
+ </span>
161
+ {#if item.badgeText}
162
+ <Badge text={item.badgeText} size="sm" />
163
+ {/if}
164
+ </span>
165
+ </button>
166
+ </li>
167
+ {/each}
168
+ </ul>
169
+ {:else}
170
+ <div class="rounded-xl border border-dashed border-border bg-base-50 p-4">
171
+ <h4 class="text-sm font-semibold text-headline">{emptyStateTitle}</h4>
172
+ <p class="mt-1 text-sm text-description">{emptyStateDescription}</p>
173
+ </div>
174
+ {/if}
175
+ </aside>