@miozu/jera 0.6.3 → 0.7.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/CLAUDE.md CHANGED
@@ -370,6 +370,107 @@ Props: `tabs` (array), `active`, `variant` (default/underline/pills), `size` (sm
370
370
  Accordion props: `expanded` (array of ids), `multiple`
371
371
  AccordionItem props: `id`, `title`, `disabled`
372
372
 
373
+ ### LeftBar (Sidebar Navigation)
374
+
375
+ A lightweight, collapsible sidebar for admin-style navigation.
376
+
377
+ ```svelte
378
+ <script>
379
+ import { LeftBar, LeftBarSection, LeftBarItem, LeftBarGroup, LeftBarToggle, createActiveChecker } from '@miozu/jera';
380
+ import { page } from '$app/stores';
381
+ import { Home, Settings, Users, Building2 } from 'lucide-svelte';
382
+
383
+ let collapsed = $state(false);
384
+ const isActive = createActiveChecker(() => $page.url.pathname);
385
+ </script>
386
+
387
+ <LeftBar bind:collapsed persistKey="my-sidebar">
388
+ {#snippet header()}
389
+ <div class="logo">MyApp</div>
390
+ {/snippet}
391
+
392
+ {#snippet navigation()}
393
+ <LeftBarSection>
394
+ <LeftBarItem href="/" icon={Home} label="Dashboard" active={isActive('/', 'exact')} />
395
+ <LeftBarItem href="/users" icon={Users} label="Users" badge={5} active={isActive('/users')} />
396
+ </LeftBarSection>
397
+
398
+ <LeftBarSection title="Management">
399
+ <LeftBarItem
400
+ label="Organization"
401
+ icon={Building2}
402
+ expandable
403
+ subroutes={[
404
+ { label: 'Members', href: '/org/members' },
405
+ { label: 'Settings', href: '/org/settings' }
406
+ ]}
407
+ />
408
+ </LeftBarSection>
409
+
410
+ <!-- Generic group for accounts, projects, workspaces, etc. -->
411
+ <LeftBarGroup
412
+ title="Connected Accounts"
413
+ items={accounts}
414
+ bind:expanded={accountsExpanded}
415
+ onItemClick={(account) => goto(`/account/${account.id}`)}
416
+ onAddClick={() => showConnectModal = true}
417
+ addLabel="Connect account"
418
+ />
419
+ {/snippet}
420
+
421
+ {#snippet footer()}
422
+ <LeftBarToggle />
423
+ {/snippet}
424
+ </LeftBar>
425
+ ```
426
+
427
+ **LeftBar** props: `collapsed` (bindable), `persistKey`, `header` (snippet), `navigation` (snippet), `footer` (snippet)
428
+
429
+ **LeftBarSection** props: `title`, `class`
430
+
431
+ **LeftBarItem** props:
432
+ - `href` - Link destination (renders as `<a>`)
433
+ - `label` - Display text
434
+ - `icon` - Lucide icon component
435
+ - `active` - Boolean for active state
436
+ - `badge` - Number/string for badge count
437
+ - `expandable` - Enable expand/collapse
438
+ - `expanded` - Bindable expansion state
439
+ - `subroutes` - Array of `{ label, href }` for expandable items
440
+ - `preload` - Boolean for SvelteKit preload-data (default: true)
441
+ - `leading` - Snippet for content before icon
442
+ - `trailing` - Snippet for content after label (custom badges, etc.)
443
+ - `isActiveRoute` - Function to check subroute active state
444
+
445
+ **LeftBarGroup** props (generic collection for accounts, projects, etc.):
446
+ - `title` - Section header
447
+ - `items` - Array of items to display
448
+ - `expanded` - Bindable expansion state
449
+ - `expandedItems` - Object tracking which items are expanded
450
+ - `expandable` - Enable item-level expansion
451
+ - `showAdd` - Show add button
452
+ - `addLabel` - Add button text
453
+ - `showCount` - Show item count badge
454
+ - `onItemClick` - Item click handler
455
+ - `onAddClick` - Add button handler
456
+ - `getItemId`, `getItemName`, `getItemAvatar`, `getItemPlatform` - Item accessors
457
+ - `getSubroutes` - Function returning subroutes for an item
458
+ - `isItemActive`, `isSubrouteActive` - Active state checkers
459
+ - `item` - Custom item rendering snippet
460
+
461
+ **createActiveChecker** utility:
462
+ ```javascript
463
+ import { createActiveChecker } from '@miozu/jera';
464
+ import { page } from '$app/stores';
465
+
466
+ // Create checker with current pathname
467
+ const isActive = createActiveChecker(() => $page.url.pathname);
468
+
469
+ // Use in components
470
+ <LeftBarItem active={isActive('/dashboard')} /> // prefix match
471
+ <LeftBarItem active={isActive('/dashboard', 'exact')} /> // exact match
472
+ ```
473
+
373
474
  ### CodeBlock
374
475
  ```svelte
375
476
  <script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miozu/jera",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Zero-dependency, AI-first component library for Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,510 @@
1
+ <!--
2
+ @component LeftBarGroup
3
+
4
+ A collapsible group within LeftBar for displaying collections of entities.
5
+ Generic component that can represent accounts, projects, workspaces, channels, etc.
6
+
7
+ @example Basic usage with items
8
+ <LeftBarGroup
9
+ title="Connected Accounts"
10
+ items={accounts}
11
+ bind:expanded={accountsExpanded}
12
+ onItemClick={(item) => navigateTo(item)}
13
+ onAddClick={() => showConnectModal = true}
14
+ addLabel="Connect account"
15
+ />
16
+
17
+ @example With custom item rendering
18
+ <LeftBarGroup title="Projects" bind:expanded>
19
+ {#snippet item(project)}
20
+ <div class="project-item">
21
+ <span class="project-color" style="background: {project.color}"></span>
22
+ <span>{project.name}</span>
23
+ </div>
24
+ {/snippet}
25
+ </LeftBarGroup>
26
+
27
+ @example With expandable items and subroutes
28
+ <LeftBarGroup
29
+ title="Accounts"
30
+ items={accounts}
31
+ expandable
32
+ bind:expandedItems={expandedAccounts}
33
+ getSubroutes={(account) => [
34
+ { label: 'Overview', href: `/account/${account.id}` },
35
+ { label: 'Settings', href: `/account/${account.id}/settings` }
36
+ ]}
37
+ />
38
+ -->
39
+ <script>
40
+ import { getContext } from 'svelte';
41
+ import { slide, fade } from 'svelte/transition';
42
+ import { cubicOut } from 'svelte/easing';
43
+
44
+ let {
45
+ title = '',
46
+ items = [],
47
+ expanded = $bindable(false),
48
+ expandedItems = $bindable({}),
49
+ expandable = false,
50
+ searchable = false,
51
+ searchQuery = '',
52
+ addLabel = 'Add item',
53
+ showAdd = true,
54
+ showCount = true,
55
+ onItemClick = null,
56
+ onSubrouteClick = null,
57
+ onAddClick = null,
58
+ getItemId = (item) => item.id,
59
+ getItemName = (item) => item.name || item.label || '',
60
+ getItemAvatar = (item) => item.avatar_url || item.avatar || null,
61
+ getItemInitial = (item) => getItemName(item)?.charAt(0)?.toUpperCase() || '?',
62
+ getItemPlatform = (item) => item.platform || item.type || null,
63
+ getSubroutes = () => [],
64
+ isItemActive = () => false,
65
+ isSubrouteActive = () => false,
66
+ class: className = '',
67
+ item: itemSnippet,
68
+ children
69
+ } = $props();
70
+
71
+ const leftbar = getContext('leftbar');
72
+ const isCollapsed = $derived(leftbar?.collapsed ?? false);
73
+
74
+ // Filter items based on search
75
+ const filteredItems = $derived(
76
+ items.filter(item =>
77
+ !searchQuery ||
78
+ getItemName(item)?.toLowerCase().includes(searchQuery.toLowerCase())
79
+ )
80
+ );
81
+
82
+ function toggleExpanded() {
83
+ expanded = !expanded;
84
+ }
85
+
86
+ function handleItemClick(item) {
87
+ if (expandable) {
88
+ const id = getItemId(item);
89
+ expandedItems = { ...expandedItems, [id]: !expandedItems[id] };
90
+ }
91
+ onItemClick?.(item);
92
+ }
93
+
94
+ function handleSubrouteClick(subroute, item) {
95
+ onSubrouteClick?.(subroute, item);
96
+ leftbar?.closeMobileOverlay?.();
97
+ }
98
+
99
+ function handleAddClick() {
100
+ onAddClick?.();
101
+ }
102
+
103
+ // Platform colors
104
+ const platformColors = {
105
+ instagram: 'linear-gradient(to bottom right, rgb(168, 85, 247), rgb(236, 72, 153))',
106
+ shopify: 'linear-gradient(to bottom right, rgb(34, 197, 94), rgb(16, 185, 129))',
107
+ tiktok: 'linear-gradient(to bottom right, rgb(0, 0, 0), rgb(37, 244, 238))',
108
+ facebook: 'linear-gradient(to bottom right, rgb(59, 89, 152), rgb(24, 119, 242))',
109
+ twitter: 'linear-gradient(to bottom right, rgb(29, 161, 242), rgb(29, 161, 242))',
110
+ default: 'var(--color-base03)'
111
+ };
112
+
113
+ function getAvatarStyle(item) {
114
+ const platform = getItemPlatform(item);
115
+ return platformColors[platform] || platformColors.default;
116
+ }
117
+ </script>
118
+
119
+ <div class="left-bar-group {className}">
120
+ <!-- Section Header -->
121
+ <button
122
+ class="group-header"
123
+ onclick={toggleExpanded}
124
+ aria-expanded={expanded}
125
+ >
126
+ {#if !isCollapsed}
127
+ <span class="group-title" transition:fade={{ duration: 150 }}>{title}</span>
128
+ {#if showCount && items.length > 0}
129
+ <span class="group-count" transition:fade={{ duration: 150 }}>{items.length}</span>
130
+ {/if}
131
+ {/if}
132
+ <svg
133
+ class="group-chevron"
134
+ class:rotate-180={expanded}
135
+ xmlns="http://www.w3.org/2000/svg"
136
+ width="14"
137
+ height="14"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ stroke-width="2"
142
+ stroke-linecap="round"
143
+ stroke-linejoin="round"
144
+ >
145
+ <polyline points="6 9 12 15 18 9"></polyline>
146
+ </svg>
147
+ </button>
148
+
149
+ <!-- Items List -->
150
+ {#if expanded}
151
+ <div class="group-content" transition:slide={{ duration: 200, easing: cubicOut }}>
152
+ <!-- Custom children content -->
153
+ {#if children}
154
+ {@render children()}
155
+ {:else}
156
+ <!-- Default item rendering -->
157
+ {#each filteredItems as item}
158
+ {@const itemId = getItemId(item)}
159
+ {@const itemName = getItemName(item)}
160
+ {@const itemAvatar = getItemAvatar(item)}
161
+ {@const platform = getItemPlatform(item)}
162
+ {@const subroutes = getSubroutes(item)}
163
+ {@const isExpanded = expandedItems[itemId]}
164
+
165
+ <div class="group-item-wrapper">
166
+ <!-- Custom item snippet or default -->
167
+ {#if itemSnippet}
168
+ {@render itemSnippet(item, isExpanded)}
169
+ {:else}
170
+ <button
171
+ class="group-item"
172
+ class:active={isItemActive(item)}
173
+ class:expanded={isExpanded}
174
+ onclick={() => handleItemClick(item)}
175
+ title={isCollapsed ? itemName : null}
176
+ >
177
+ <!-- Avatar -->
178
+ <div class="item-avatar-container">
179
+ {#if itemAvatar}
180
+ <div class="item-avatar-wrapper">
181
+ <img src={itemAvatar} alt={itemName} class="item-avatar-img" />
182
+ </div>
183
+ {:else}
184
+ <div class="item-avatar" style="background: {getAvatarStyle(item)}">
185
+ {getItemInitial(item)}
186
+ </div>
187
+ {/if}
188
+ {#if platform}
189
+ <div class="platform-badge {platform}">
190
+ {#if platform === 'instagram'}
191
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="5"></rect><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg>
192
+ {:else if platform === 'shopify'}
193
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"></path><line x1="3" y1="6" x2="21" y2="6"></line><path d="M16 10a4 4 0 0 1-8 0"></path></svg>
194
+ {/if}
195
+ </div>
196
+ {/if}
197
+ </div>
198
+
199
+ {#if !isCollapsed}
200
+ <span class="item-name" transition:fade={{ duration: 150 }}>{itemName}</span>
201
+ {#if expandable && subroutes.length > 0}
202
+ <svg
203
+ class="item-chevron"
204
+ class:rotate-180={isExpanded}
205
+ xmlns="http://www.w3.org/2000/svg"
206
+ width="14"
207
+ height="14"
208
+ viewBox="0 0 24 24"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ stroke-width="2"
212
+ >
213
+ <polyline points="6 9 12 15 18 9"></polyline>
214
+ </svg>
215
+ {/if}
216
+ {/if}
217
+ </button>
218
+
219
+ <!-- Subroutes -->
220
+ {#if isExpanded && !isCollapsed && subroutes.length > 0}
221
+ <div class="item-subroutes" transition:slide={{ duration: 200, easing: cubicOut }}>
222
+ {#each subroutes as subroute}
223
+ <a
224
+ href={subroute.href}
225
+ class="subroute-item"
226
+ class:active={isSubrouteActive(subroute)}
227
+ onclick={() => handleSubrouteClick(subroute, item)}
228
+ >
229
+ {subroute.label}
230
+ </a>
231
+ {/each}
232
+ </div>
233
+ {/if}
234
+ {/if}
235
+ </div>
236
+ {/each}
237
+ {/if}
238
+
239
+ <!-- Add Button -->
240
+ {#if showAdd && onAddClick}
241
+ <button
242
+ class="add-item-btn"
243
+ class:collapsed={isCollapsed}
244
+ onclick={handleAddClick}
245
+ title={addLabel}
246
+ >
247
+ <svg
248
+ xmlns="http://www.w3.org/2000/svg"
249
+ width="16"
250
+ height="16"
251
+ viewBox="0 0 24 24"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ stroke-width="2"
255
+ >
256
+ <line x1="12" y1="5" x2="12" y2="19"></line>
257
+ <line x1="5" y1="12" x2="19" y2="12"></line>
258
+ </svg>
259
+ {#if !isCollapsed}
260
+ <span transition:fade={{ duration: 150 }}>{addLabel}</span>
261
+ {/if}
262
+ </button>
263
+ {/if}
264
+ </div>
265
+ {/if}
266
+ </div>
267
+
268
+ <style>
269
+ .left-bar-group {
270
+ padding: 0.125rem 0;
271
+ }
272
+
273
+ /* Group Header */
274
+ .group-header {
275
+ width: 100%;
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 0.5rem;
279
+ padding: 0.5rem 0.75rem;
280
+ font-size: 0.75rem;
281
+ font-weight: 600;
282
+ color: var(--color-base04);
283
+ text-transform: uppercase;
284
+ letter-spacing: 0.05em;
285
+ cursor: pointer;
286
+ transition: all 200ms ease;
287
+ border: none;
288
+ background: transparent;
289
+ font-family: inherit;
290
+ justify-content: flex-start;
291
+ }
292
+
293
+ .group-header:hover {
294
+ color: var(--color-base05);
295
+ }
296
+
297
+ .group-title {
298
+ flex: 1;
299
+ text-align: left;
300
+ }
301
+
302
+ .group-count {
303
+ padding: 0.125rem 0.375rem;
304
+ font-size: 0.625rem;
305
+ background-color: var(--color-base02);
306
+ color: var(--color-base05);
307
+ border-radius: 9999px;
308
+ flex-shrink: 0;
309
+ }
310
+
311
+ .group-chevron {
312
+ color: var(--color-base04);
313
+ transition: all 200ms ease;
314
+ flex-shrink: 0;
315
+ }
316
+
317
+ .group-chevron.rotate-180 {
318
+ transform: rotate(180deg);
319
+ }
320
+
321
+ /* Group Content */
322
+ .group-content {
323
+ display: flex;
324
+ flex-direction: column;
325
+ gap: 0.125rem;
326
+ padding: 0 0.5rem;
327
+ }
328
+
329
+ .group-item-wrapper {
330
+ display: flex;
331
+ flex-direction: column;
332
+ }
333
+
334
+ /* Item Button */
335
+ .group-item {
336
+ width: 100%;
337
+ padding: 0.5rem 0.75rem;
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 0.75rem;
341
+ font-size: 0.875rem;
342
+ line-height: 1.25rem;
343
+ color: var(--color-base06);
344
+ cursor: pointer;
345
+ border-radius: 0.375rem;
346
+ transition: all 200ms ease;
347
+ overflow: hidden;
348
+ border: none;
349
+ background: transparent;
350
+ font-family: inherit;
351
+ text-align: left;
352
+ }
353
+
354
+ .group-item:hover {
355
+ color: var(--color-base0D);
356
+ background-color: color-mix(in srgb, var(--color-base0D) 5%, transparent);
357
+ }
358
+
359
+ .group-item.active,
360
+ .group-item.expanded {
361
+ color: var(--color-base0D);
362
+ background-color: color-mix(in srgb, var(--color-base0D) 10%, transparent);
363
+ }
364
+
365
+ /* Avatar */
366
+ .item-avatar-container {
367
+ position: relative;
368
+ flex-shrink: 0;
369
+ width: 2rem;
370
+ height: 2rem;
371
+ }
372
+
373
+ .item-avatar-wrapper {
374
+ width: 2rem;
375
+ height: 2rem;
376
+ border-radius: 0.375rem;
377
+ overflow: hidden;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ }
382
+
383
+ .item-avatar-img {
384
+ width: 100%;
385
+ height: 100%;
386
+ object-fit: cover;
387
+ }
388
+
389
+ .item-avatar {
390
+ width: 2rem;
391
+ height: 2rem;
392
+ border-radius: 0.375rem;
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ font-size: 0.75rem;
397
+ font-weight: 600;
398
+ color: white;
399
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
400
+ }
401
+
402
+ .platform-badge {
403
+ position: absolute;
404
+ bottom: -2px;
405
+ right: -2px;
406
+ width: 1rem;
407
+ height: 1rem;
408
+ border-radius: 9999px;
409
+ display: flex;
410
+ align-items: center;
411
+ justify-content: center;
412
+ color: white;
413
+ border: 1px solid var(--color-base00);
414
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
415
+ z-index: 10;
416
+ }
417
+
418
+ .platform-badge.instagram {
419
+ background: linear-gradient(135deg, #833ab4, #fd1d1d, #fcb045);
420
+ }
421
+
422
+ .platform-badge.shopify {
423
+ background-color: var(--color-base0B);
424
+ }
425
+
426
+ .item-name {
427
+ flex: 1;
428
+ white-space: nowrap;
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
431
+ font-weight: 500;
432
+ }
433
+
434
+ .item-chevron {
435
+ color: var(--color-base05);
436
+ transition: all 200ms ease;
437
+ flex-shrink: 0;
438
+ }
439
+
440
+ .item-chevron.rotate-180 {
441
+ transform: rotate(180deg);
442
+ }
443
+
444
+ /* Subroutes */
445
+ .item-subroutes {
446
+ margin-left: 2.5rem;
447
+ padding-left: 0.5rem;
448
+ border-left: 2px solid color-mix(in srgb, var(--color-base03) 30%, transparent);
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 0.125rem;
452
+ }
453
+
454
+ .subroute-item {
455
+ padding: 0.375rem 0.75rem;
456
+ display: block;
457
+ font-size: 0.875rem;
458
+ line-height: 1.25rem;
459
+ color: var(--color-base05);
460
+ cursor: pointer;
461
+ border-radius: 0.375rem;
462
+ transition: all 200ms ease;
463
+ text-decoration: none;
464
+ }
465
+
466
+ .subroute-item:hover {
467
+ color: var(--color-base0D);
468
+ background-color: color-mix(in srgb, var(--color-base0D) 5%, transparent);
469
+ }
470
+
471
+ .subroute-item.active {
472
+ color: var(--color-base0D);
473
+ background-color: color-mix(in srgb, var(--color-base0D) 10%, transparent);
474
+ font-weight: 500;
475
+ }
476
+
477
+ /* Add Button */
478
+ .add-item-btn {
479
+ width: 100%;
480
+ padding: 0.5rem 0.75rem;
481
+ margin-top: 0.25rem;
482
+ display: flex;
483
+ align-items: center;
484
+ gap: 0.5rem;
485
+ justify-content: center;
486
+ font-size: 0.875rem;
487
+ line-height: 1.25rem;
488
+ color: var(--color-base0D);
489
+ cursor: pointer;
490
+ border-radius: 0.375rem;
491
+ transition: all 200ms ease;
492
+ border: 1px solid color-mix(in srgb, var(--color-base0D) 30%, transparent);
493
+ background: transparent;
494
+ font-family: inherit;
495
+ }
496
+
497
+ .add-item-btn:hover {
498
+ color: var(--color-base0D);
499
+ background-color: color-mix(in srgb, var(--color-base0D) 10%, transparent);
500
+ border-color: var(--color-base0D);
501
+ }
502
+
503
+ .add-item-btn.collapsed {
504
+ width: 2rem;
505
+ height: 2rem;
506
+ padding: 0;
507
+ margin: 0 auto;
508
+ justify-content: center;
509
+ }
510
+ </style>
@@ -32,12 +32,19 @@
32
32
  expandable = false,
33
33
  expanded = $bindable(false),
34
34
  subroutes = [],
35
+ badge = null,
36
+ preload = true,
35
37
  onclick = null,
36
38
  isActiveRoute = () => false,
37
39
  class: className = '',
40
+ leading,
41
+ trailing,
38
42
  children
39
43
  } = $props();
40
44
 
45
+ // Preload attribute for SvelteKit
46
+ const preloadAttr = preload ? 'hover' : undefined;
47
+
41
48
  const leftbar = getContext('leftbar');
42
49
  const isCollapsed = $derived(leftbar?.collapsed ?? false);
43
50
 
@@ -68,12 +75,18 @@
68
75
  class:active
69
76
  class:collapsed={isCollapsed}
70
77
  title={isCollapsed ? label : null}
78
+ data-sveltekit-preload-data={preloadAttr}
71
79
  >
80
+ {@render leading?.()}
72
81
  {#if Icon}
73
82
  <Icon size={18} class="nav-icon" />
74
83
  {/if}
75
84
  {#if !isCollapsed}
76
85
  <span class="nav-label" transition:fade={{ duration: 150 }}>{label}</span>
86
+ {#if badge != null}
87
+ <span class="nav-badge" transition:fade={{ duration: 150 }}>{badge}</span>
88
+ {/if}
89
+ {@render trailing?.()}
77
90
  {/if}
78
91
  {@render children?.()}
79
92
  </a>
@@ -133,11 +146,16 @@
133
146
  onclick={handleClick}
134
147
  title={isCollapsed ? label : null}
135
148
  >
149
+ {@render leading?.()}
136
150
  {#if Icon}
137
151
  <Icon size={18} class="nav-icon" />
138
152
  {/if}
139
153
  {#if !isCollapsed}
140
154
  <span class="nav-label" transition:fade={{ duration: 150 }}>{label}</span>
155
+ {#if badge != null}
156
+ <span class="nav-badge" transition:fade={{ duration: 150 }}>{badge}</span>
157
+ {/if}
158
+ {@render trailing?.()}
141
159
  {/if}
142
160
  {@render children?.()}
143
161
  </button>
@@ -155,6 +173,8 @@
155
173
  display: flex;
156
174
  align-items: center;
157
175
  gap: 0.5rem;
176
+ /* font-size and line-height MUST come after font-family to not be overwritten */
177
+ font-family: inherit;
158
178
  font-size: 0.875rem;
159
179
  line-height: 1.25rem;
160
180
  color: var(--color-base06);
@@ -167,7 +187,6 @@
167
187
  overflow: hidden;
168
188
  background: transparent;
169
189
  border: none;
170
- font: inherit;
171
190
  text-align: left;
172
191
  }
173
192
 
@@ -216,6 +235,16 @@
216
235
  overflow: hidden;
217
236
  }
218
237
 
238
+ .nav-badge {
239
+ padding: 0.125rem 0.375rem;
240
+ font-size: 0.625rem;
241
+ font-weight: 600;
242
+ background-color: color-mix(in srgb, var(--color-base0D) 10%, transparent);
243
+ color: var(--color-base0D);
244
+ border-radius: 9999px;
245
+ flex-shrink: 0;
246
+ }
247
+
219
248
  .expand-icon {
220
249
  color: var(--color-base05);
221
250
  flex-shrink: 0;
@@ -4,7 +4,15 @@
4
4
  * Reusable navigation components including sidebars, tabs, and accordions.
5
5
  */
6
6
 
7
- // Sidebar Components
7
+ // LeftBar Components (simpler, lightweight)
8
+ export { default as LeftBar } from './LeftBar.svelte';
9
+ export { default as LeftBarItem } from './LeftBarItem.svelte';
10
+ export { default as LeftBarSection } from './LeftBarSection.svelte';
11
+ export { default as LeftBarGroup } from './LeftBarGroup.svelte';
12
+ export { default as LeftBarPopover } from './LeftBarPopover.svelte';
13
+ export { default as LeftBarToggle } from './LeftBarToggle.svelte';
14
+
15
+ // Sidebar Components (feature-rich)
8
16
  export { default as Sidebar } from './Sidebar.svelte';
9
17
  export { default as SidebarSection } from './SidebarSection.svelte';
10
18
  export { default as SidebarItem } from './SidebarItem.svelte';
package/src/index.js CHANGED
@@ -120,6 +120,7 @@ export { default as WorkspaceMenu } from './components/navigation/WorkspaceMenu.
120
120
  export { default as LeftBar } from './components/navigation/LeftBar.svelte';
121
121
  export { default as LeftBarSection } from './components/navigation/LeftBarSection.svelte';
122
122
  export { default as LeftBarItem } from './components/navigation/LeftBarItem.svelte';
123
+ export { default as LeftBarGroup } from './components/navigation/LeftBarGroup.svelte';
123
124
  export { default as LeftBarToggle } from './components/navigation/LeftBarToggle.svelte';
124
125
  export { default as LeftBarPopover } from './components/navigation/LeftBarPopover.svelte';
125
126
  export { default as DropdownContainer } from './components/navigation/DropdownContainer.svelte';
@@ -164,6 +165,7 @@ export {
164
165
  export {
165
166
  NavigationState,
166
167
  createNavigationState,
168
+ createActiveChecker,
167
169
  NAVIGATION_CONTEXT_KEY
168
170
  } from './utils/navigation.svelte.js';
169
171
 
@@ -420,4 +420,36 @@ export function createNavigationState(options = {}) {
420
420
  /**
421
421
  * Navigation context key for Svelte context
422
422
  */
423
- export const NAVIGATION_CONTEXT_KEY = 'navigation-state';
423
+ export const NAVIGATION_CONTEXT_KEY = 'navigation-state';
424
+
425
+ /**
426
+ * Create an active route checker function for use with LeftBar/Sidebar components.
427
+ * Works with SvelteKit's page store or any path string.
428
+ *
429
+ * @example With SvelteKit page store
430
+ * import { page } from '$app/stores';
431
+ * const isActive = createActiveChecker(() => $page.url.pathname);
432
+ *
433
+ * <LeftBarItem active={isActive('/dashboard')} />
434
+ * <LeftBarItem active={isActive('/settings', 'exact')} />
435
+ *
436
+ * @example With reactive path
437
+ * let currentPath = $state('/dashboard');
438
+ * const isActive = createActiveChecker(() => currentPath);
439
+ *
440
+ * @param {() => string} getPathname - Function that returns current pathname
441
+ * @returns {(path: string, matchType?: 'prefix' | 'exact') => boolean}
442
+ */
443
+ export function createActiveChecker(getPathname) {
444
+ return function isActive(path, matchType = 'prefix') {
445
+ const currentPath = getPathname();
446
+ // Normalize paths by removing trailing slashes
447
+ const normalizedCurrent = currentPath.replace(/\/$/, '') || '/';
448
+ const normalizedPath = path.replace(/\/$/, '') || '/';
449
+
450
+ if (matchType === 'exact') {
451
+ return normalizedCurrent === normalizedPath;
452
+ }
453
+ return normalizedCurrent.startsWith(normalizedPath);
454
+ };
455
+ }