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.
- package/README.md +42 -44
- package/THEME.md +473 -0
- package/THEMING.md +248 -0
- package/dist/atoms/Card.svelte +40 -67
- package/dist/atoms/Card.svelte.d.ts +3 -3
- package/dist/atoms/CardContent.svelte +1 -1
- package/dist/atoms/CardHeader.svelte +10 -1
- package/dist/atoms/CardHeader.svelte.d.ts +3 -0
- package/dist/atoms/Checkbox.svelte +1 -7
- package/dist/atoms/CodeBlock.svelte +7 -7
- package/dist/atoms/ColorPicker.svelte +81 -73
- package/dist/atoms/Input.svelte +16 -12
- package/dist/atoms/Input.svelte.d.ts +2 -0
- package/dist/atoms/List.svelte +47 -0
- package/dist/atoms/List.svelte.d.ts +19 -0
- package/dist/atoms/ListItem.svelte +148 -0
- package/dist/atoms/ListItem.svelte.d.ts +35 -0
- package/dist/atoms/Progress.svelte +4 -6
- package/dist/atoms/Select.svelte +65 -20
- package/dist/atoms/Select.svelte.d.ts +13 -1
- package/dist/atoms/Textarea.svelte +13 -11
- package/dist/atoms/Textarea.svelte.d.ts +1 -0
- package/dist/atoms/Toast.svelte +1 -1
- package/dist/atoms/Toggle.svelte +1 -7
- package/dist/atoms/Tooltip.svelte +8 -15
- package/dist/atoms/index.d.ts +2 -0
- package/dist/atoms/index.js +2 -0
- package/dist/components/atoms/index.d.ts +2 -0
- package/dist/components/atoms/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/molecules/index.d.ts +0 -1
- package/dist/components/molecules/index.d.ts.map +1 -1
- package/dist/components/molecules/navigation-menu-context.d.ts +7 -0
- package/dist/components/molecules/navigation-menu-context.d.ts.map +1 -0
- package/dist/components/organisms/index.d.ts +2 -1
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -9
- package/dist/lib/showcase/components-catalog.d.ts +3 -0
- package/dist/lib/showcase/components-catalog.d.ts.map +1 -0
- package/dist/lib/showcase/components-showcase-constants.d.ts +63 -0
- package/dist/lib/showcase/components-showcase-constants.d.ts.map +1 -0
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts +15 -0
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +1 -0
- package/dist/molecules/Alert.svelte +9 -4
- package/dist/molecules/ComponentDemo.svelte +53 -50
- package/dist/molecules/ContactForm.svelte +113 -76
- package/dist/molecules/ContactForm.svelte.d.ts +1 -4
- package/dist/molecules/Dropdown.svelte +4 -2
- package/dist/molecules/ImageUpload.svelte +27 -4
- package/dist/molecules/ImageUpload.svelte.d.ts +1 -0
- package/dist/molecules/Modal.svelte +46 -45
- package/dist/molecules/NavigationMenu.svelte +7 -10
- package/dist/molecules/NavigationMenuContent.svelte +9 -9
- package/dist/molecules/NavigationMenuItem.svelte +7 -6
- package/dist/molecules/NavigationMenuLink.svelte +8 -6
- package/dist/molecules/NavigationMenuList.svelte +0 -7
- package/dist/molecules/NavigationMenuTrigger.svelte +8 -11
- package/dist/molecules/Section.svelte +12 -12
- package/dist/molecules/SlideUp.svelte +32 -2
- package/dist/molecules/Tabs.svelte +74 -7
- package/dist/molecules/index.d.ts +0 -1
- package/dist/molecules/index.js +0 -1
- package/dist/molecules/navigation-menu-context.d.ts +6 -0
- package/dist/molecules/navigation-menu-context.js +1 -0
- package/dist/organisms/Navbar.svelte +170 -53
- package/dist/organisms/Navbar.svelte.d.ts +31 -4
- package/dist/organisms/SidebarNavigation.svelte +464 -0
- package/dist/organisms/SidebarNavigation.svelte.d.ts +49 -0
- package/dist/organisms/SidebarPanel.svelte +175 -0
- package/dist/organisms/SidebarPanel.svelte.d.ts +32 -0
- package/dist/organisms/index.d.ts +2 -1
- package/dist/organisms/index.js +2 -1
- package/dist/types/page.types.d.ts +32 -0
- package/dist/types/page.types.d.ts.map +1 -0
- package/dist/types/variants.ts +76 -0
- package/dist/zabi-components-colors.css +347 -324
- package/dist/zabi-components-theme-dark-only.css +159 -151
- package/dist/zabi-components-theme-dark.css +159 -151
- package/dist/zabi-components-theme-only.css +245 -230
- package/dist/zabi-components-theme.css +245 -230
- package/dist/zabi-components.css +722 -84
- package/docs/theme-imports.md +112 -0
- package/package.json +20 -9
- package/dist/molecules/Sidebar.svelte +0 -324
- package/dist/molecules/Sidebar.svelte.d.ts +0 -30
- package/dist/organisms/Navigation.svelte +0 -116
- 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>
|