@spaethtech/svelte-ui 0.7.1-dev.41.d01009c → 0.7.1-dev.43.72e77ad

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.
@@ -4,7 +4,7 @@
4
4
  * bottom-anchored group (pushed to the strip's bottom edge), and hover-open
5
5
  * popouts for parent items.
6
6
  *
7
- * Driven by data — pass `topItems` / `bottomItems` arrays of {@link SideBarMenuItem}.
7
+ * Driven by data — pass `items` / `bottomItems` arrays of {@link SideBarMenuItem}.
8
8
  * Each item declares its own icon (Snippet), label, link, badge, and optional
9
9
  * children. Children render in a top-layer Popup on hover or click.
10
10
  *
@@ -33,6 +33,18 @@
33
33
  */
34
34
  export type SideBarMenuMode = "stepped" | "drawer" | "icon" | "expanded";
35
35
 
36
+ /** Imperative handle exposed via `bind:api`. */
37
+ export type SideBarMenuApi = {
38
+ /** Reset all transient state — close the drawer, clear the open popout, return step level to 0. */
39
+ reset: () => void;
40
+ /** Open the drawer (drawer/stepped modes). */
41
+ open: () => void;
42
+ /** Close the drawer. */
43
+ close: () => void;
44
+ /** Set the stepped-mode step level (`0` = top). */
45
+ setStepLevel: (n: number) => void;
46
+ };
47
+
36
48
  export type SideBarMenuBadgeVariant = "error" | "warning" | "info" | "success";
37
49
 
38
50
  export type SideBarMenuBadge = {
@@ -69,6 +81,15 @@
69
81
  badge?: SideBarMenuBadge;
70
82
  /** v1 renders only the first level; deeper levels emit a DEV warning. */
71
83
  children?: SideBarMenuItem[];
84
+ /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
85
+ * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
86
+ onclick?: (e: MouseEvent) => void;
87
+ /** Custom popout content, rendered INSTEAD of `children` in the popout (separators, hotkeys,
88
+ * an account/identity block…). The row still needs an `icon`/`label`; this is just its popout. */
89
+ popout?: import("svelte").Snippet;
90
+ /** Opt this anchor out of client-side routing — stamps `data-sveltekit-reload` (SvelteKit) so
91
+ * the link forces a full-browser navigation (sign-out endpoints, downloads, external redirects). */
92
+ reload?: boolean;
72
93
  };
73
94
  </script>
74
95
 
@@ -89,7 +110,7 @@
89
110
  type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
90
111
 
91
112
  let {
92
- topItems = [],
113
+ items = [],
93
114
  bottomItems = [],
94
115
  currentPath,
95
116
  size = "md",
@@ -100,9 +121,12 @@
100
121
  open = $bindable(false),
101
122
  stepLevel = $bindable(0),
102
123
  hamburger = true,
124
+ iconHoverExpand = false,
125
+ hoverExpandCloseMs = 200,
126
+ api = $bindable(),
103
127
  class: additionalClass = "",
104
128
  }: {
105
- topItems?: SideBarMenuItem[];
129
+ items?: SideBarMenuItem[];
106
130
  bottomItems?: SideBarMenuItem[];
107
131
  currentPath: string;
108
132
  size?: Size;
@@ -149,6 +173,15 @@
149
173
  * and binds to `open`.
150
174
  */
151
175
  hamburger?: boolean;
176
+ /** In `icon` mode, expand the rail to `expanded` ON HOVER — as a floating overlay over a spacer
177
+ * that reserves the collapsed width, so page content never reflows. Default `false`. */
178
+ iconHoverExpand?: boolean;
179
+ /** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
180
+ hoverExpandCloseMs?: number;
181
+ /** Imperative handle (bindable). Call `api.reset()` from your router's navigation lifecycle
182
+ * (SvelteKit `afterNavigate`, etc.) so a programmatic `goto()`/back-button closes the drawer +
183
+ * any open popout and returns a stepped drawer to the top. */
184
+ api?: SideBarMenuApi;
152
185
  class?: string;
153
186
  } = $props();
154
187
 
@@ -198,27 +231,55 @@
198
231
 
199
232
  const isStepped = $derived(currentMode === "stepped");
200
233
  const isDrawer = $derived(currentMode === "drawer" || currentMode === "stepped");
201
- const labelsVisible = $derived(currentMode !== "icon");
234
+
235
+ // ── Icon-mode hover-expand ──────────────────────────────────────
236
+ // When enabled + in `icon` mode, hovering the rail floats it out to the `expanded` render OVER a
237
+ // spacer that reserves the collapsed width — page content never reflows. Exit is delayed.
238
+ let hoverExpanded = $state(false);
239
+ let hoverExpandTimer: ReturnType<typeof setTimeout> | null = null;
240
+ const hoverExpandActive = $derived(iconHoverExpand && currentMode === "icon");
241
+ const effectivelyExpanded = $derived(hoverExpandActive && hoverExpanded);
242
+ function openHoverExpand() {
243
+ if (hoverExpandTimer) {
244
+ clearTimeout(hoverExpandTimer);
245
+ hoverExpandTimer = null;
246
+ }
247
+ if (hoverExpandActive) hoverExpanded = true;
248
+ }
249
+ function scheduleHoverCollapse() {
250
+ if (hoverExpandTimer) clearTimeout(hoverExpandTimer);
251
+ hoverExpandTimer = setTimeout(() => {
252
+ hoverExpanded = false;
253
+ openId = null;
254
+ hoverExpandTimer = null;
255
+ }, hoverExpandCloseMs);
256
+ }
257
+ $effect(() => {
258
+ if (!hoverExpandActive) hoverExpanded = false;
259
+ });
260
+
261
+ // Labels show in every mode except a collapsed (not hover-expanded) icon rail.
262
+ const labelsVisible = $derived(currentMode !== "icon" || effectivelyExpanded);
202
263
 
203
264
  // ── DEV depth warning ───────────────────────────────────────────
204
265
  if (DEV) {
205
- const walk = (items: SideBarMenuItem[], depth: number) => {
206
- for (const it of items) {
266
+ const walk = (list: SideBarMenuItem[], depth: number, path: string[]) => {
267
+ for (const it of list) {
268
+ const here = [...path, it.label];
207
269
  if (it.children?.length) {
208
270
  if (depth >= 1) {
209
271
  console.warn(
210
- 'SideBarMenu: nesting deeper than 1 level is not yet rendered; flattening grandchildren of "' +
211
- it.label +
212
- '"',
272
+ `SideBarMenu: nesting deeper than 1 level is not yet rendered; flattening ` +
273
+ `grandchildren of "${here.join(" › ")}"`,
213
274
  );
214
275
  return;
215
276
  }
216
- walk(it.children, depth + 1);
277
+ walk(it.children, depth + 1, here);
217
278
  }
218
279
  }
219
280
  };
220
- walk(topItems, 0);
221
- walk(bottomItems, 0);
281
+ walk(items, 0, []);
282
+ walk(bottomItems, 0, []);
222
283
  }
223
284
 
224
285
  // ── Size axis ───────────────────────────────────────────────────
@@ -327,6 +388,15 @@
327
388
  stepLevel = 1;
328
389
  }
329
390
 
391
+ // Imperative handle for consumers (bound via `bind:api`). Assigned once — the closures read the
392
+ // reactive `open`/`stepLevel`/`openId` live, so a single assignment stays correct.
393
+ api = {
394
+ reset: closeAfterNav,
395
+ open: () => (open = true),
396
+ close: () => (open = false),
397
+ setStepLevel: (n: number) => (stepLevel = n),
398
+ };
399
+
330
400
  // Label / chevron visibility are now driven by the active mode
331
401
  // instead of by responsive class strings — the matchMedia effect
332
402
  // above keeps `currentMode` (and therefore `labelsVisible`) in sync
@@ -503,27 +573,35 @@
503
573
  `chevronVisibility` (not `labelClassResolved`) so the span
504
574
  stays `inline-flex` and the SVG centers vertically. -->
505
575
  <span
506
- class="shrink-0 items-center justify-center opacity-60 {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
576
+ class="shrink-0 items-center justify-center opacity-60 transition-transform duration-150 {isOpen
577
+ ? 'rotate-180'
578
+ : ''} {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
507
579
  >
508
580
  <IconChevronRight />
509
581
  </span>
510
582
  {/if}
511
583
  {/snippet}
512
584
 
513
- <!-- Single row template — used by topItems / bottomItems and the popout. -->
585
+ <!-- Single row template — used by items / bottomItems and the popout. -->
514
586
  {#snippet row(item: SideBarMenuItem, opts: { id?: ItemId; isPopoutChild?: boolean })}
515
587
  {@const active = isItemActive(item)}
588
+ {@const ownActive = !!item.link && currentPath.startsWith(item.link)}
516
589
  {@const hasChildren = (item.children?.length ?? 0) > 0}
517
- {@const isParent = hasChildren && !opts.isPopoutChild}
590
+ {@const isParent = (hasChildren || !!item.popout) && !opts.isPopoutChild}
518
591
  {@const hasLink = !!item.link}
519
592
  {@const isOpen = !!opts.id && openId === opts.id}
593
+ {@const popoutId = opts.id ? "sbm-popout-" + opts.id.replace(":", "-") : undefined}
520
594
  {@const rowClass = `group flex items-center w-full ${sz.row} lg:pr-3 transition-colors cursor-pointer ${active ? "[background-color:var(--ui-color-primary)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"}`}
521
595
  {#if hasLink}
522
596
  <a
523
597
  href={item.link}
524
598
  use:refIntoMap={opts.id}
525
599
  class={rowClass}
526
- aria-current={active ? "page" : undefined}
600
+ data-sveltekit-reload={item.reload ? "" : undefined}
601
+ aria-current={ownActive ? "page" : undefined}
602
+ aria-haspopup={isParent ? "menu" : undefined}
603
+ aria-expanded={isParent && opts.id ? isOpen : undefined}
604
+ aria-controls={isParent && isOpen ? popoutId : undefined}
527
605
  use:tooltip={{
528
606
  text: item.label,
529
607
  side: "right",
@@ -562,6 +640,7 @@
562
640
  class="{rowClass} text-left"
563
641
  aria-haspopup="menu"
564
642
  aria-expanded={openId === opts.id}
643
+ aria-controls={isOpen ? popoutId : undefined}
565
644
  use:tooltip={{ text: item.label, side: "right", disabled: openId === opts.id }}
566
645
  onmouseenter={!isStepped ? () => openOnHover(opts.id!) : undefined}
567
646
  onmouseleave={!isStepped ? scheduleClose : undefined}
@@ -569,6 +648,21 @@
569
648
  >
570
649
  {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
571
650
  </button>
651
+ {:else if item.onclick}
652
+ <!-- Action row: no link, no children/popout — a `<button>` that runs `onclick`
653
+ (e.g. an AI-panel toggle), then resets the drawer/popout like a leaf nav. -->
654
+ <button
655
+ type="button"
656
+ use:refIntoMap={opts.id}
657
+ class="{rowClass} text-left"
658
+ use:tooltip={{ text: item.label, side: "right" }}
659
+ onclick={(e) => {
660
+ item.onclick!(e);
661
+ closeAfterNav();
662
+ }}
663
+ >
664
+ {@render rowBody(item, false, !!opts.isPopoutChild, false)}
665
+ </button>
572
666
  {:else}
573
667
  <!-- Malformed item: no link, no children. Render visibly so the
574
668
  consumer notices, but with disabled-style cursor + opacity. -->
@@ -582,22 +676,27 @@
582
676
  mirrors main strip styling. `borderClass` is computed per popout
583
677
  by `geometryFor` so the visible edges get borders and the edges
584
678
  that coincide with the sidebar's top/bottom don't. -->
585
- {#snippet popout(item: SideBarMenuItem, borderClass: string)}
679
+ {#snippet popout(item: SideBarMenuItem, borderClass: string, popoutId: string)}
586
680
  <!-- svelte-ignore a11y_interactive_supports_focus -->
587
681
  <div
588
682
  role="menu"
589
683
  tabindex="-1"
684
+ id={popoutId}
590
685
  class="w-[var(--sbm-expanded-w)] overflow-hidden shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] {borderClass}"
591
686
  onmouseenter={cancelClose}
592
687
  onmouseleave={scheduleClose}
593
688
  >
594
- <ul class="flex flex-col">
595
- {#each item.children ?? [] as child (child.label)}
596
- <li class="list-none" role="none">
597
- {@render row(child, { isPopoutChild: true })}
598
- </li>
599
- {/each}
600
- </ul>
689
+ {#if item.popout}
690
+ {@render item.popout()}
691
+ {:else}
692
+ <ul class="flex flex-col">
693
+ {#each item.children ?? [] as child (child.label)}
694
+ <li class="list-none" role="none">
695
+ {@render row(child, { isPopoutChild: true })}
696
+ </li>
697
+ {/each}
698
+ </ul>
699
+ {/if}
601
700
  </div>
602
701
  {/snippet}
603
702
 
@@ -627,24 +726,34 @@
627
726
  ></div>
628
727
  {/if}
629
728
 
630
- <aside
631
- bind:this={asideRef}
729
+ <!-- Root wrapper. In icon hover-expand it becomes a `relative` spacer reserving the collapsed rail
730
+ width, so the expanded float below is positioned against the SIDEBAR's own box (container-relative,
731
+ never the viewport). In every other mode it's `display: contents` (no box) so layout is unchanged. -->
732
+ <div
733
+ class={hoverExpandActive ? "relative shrink-0 [width:var(--sbm-collapsed-w)]" : "contents"}
632
734
  style="--sbm-collapsed-w: {collapsedWidth}px; --sbm-expanded-w: {expandedWidth}px;"
633
- class="
735
+ >
736
+ <aside
737
+ bind:this={asideRef}
738
+ onmouseenter={hoverExpandActive ? openHoverExpand : undefined}
739
+ onmouseleave={hoverExpandActive ? scheduleHoverCollapse : undefined}
740
+ class="
634
741
  flex flex-col z-[60] overflow-hidden
635
742
  [background-color:var(--ui-color-background)]
636
743
  [color:var(--ui-color-text)]
637
744
  [border-right:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]
638
745
  {currentMode === 'stepped'
639
- ? `absolute inset-y-0 left-0 w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
640
- : currentMode === 'drawer'
641
- ? `absolute inset-y-0 left-0 w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
642
- : currentMode === 'icon'
643
- ? 'relative w-[var(--sbm-collapsed-w)]'
644
- : 'relative w-[var(--sbm-expanded-w)]'}
746
+ ? `absolute inset-y-0 left-0 w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
747
+ : currentMode === 'drawer'
748
+ ? `absolute inset-y-0 left-0 w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
749
+ : currentMode === 'icon'
750
+ ? hoverExpandActive
751
+ ? `absolute inset-y-0 left-0 z-[65] transition-[width] duration-200 ${effectivelyExpanded ? 'w-[var(--sbm-expanded-w)] shadow-lg' : 'w-[var(--sbm-collapsed-w)]'}`
752
+ : 'relative w-[var(--sbm-collapsed-w)]'
753
+ : 'relative w-[var(--sbm-expanded-w)]'}
645
754
  {additionalClass}
646
755
  "
647
- >
756
+ >
648
757
  {#if isStepped && stepLevel === 1 && currentParent}
649
758
  <!-- Stepped sub-view (<2xs only): show just the children of the
650
759
  parent the user tapped. The consumer's hamburger button is
@@ -659,15 +768,15 @@
659
768
  {/each}
660
769
  </ul>
661
770
  {:else}
662
- {#if topItems.length > 0}
771
+ {#if items.length > 0}
663
772
  <ul class="flex flex-col">
664
- {#each topItems as item, idx (item.label)}
773
+ {#each items as item, idx (item.label)}
665
774
  {@const id = `top:${idx}` as ItemId}
666
775
  <li class="list-none">
667
776
  {@render row(item, { id })}
668
777
  </li>
669
- {#if (item.children?.length ?? 0) > 0 && !isStepped}
670
- {@const geom = geometryFor(id, "top", idx, topItems.length, item.children!.length)}
778
+ {#if ((item.children?.length ?? 0) > 0 || !!item.popout) && !isStepped}
779
+ {@const geom = geometryFor(id, "top", idx, items.length, item.children?.length ?? 1)}
671
780
  <Popup
672
781
  anchor={geom.anchor}
673
782
  open={openId === id}
@@ -678,14 +787,14 @@
678
787
  if (openId === id) openId = null;
679
788
  }}
680
789
  >
681
- {@render popout(item, geom.borderClass)}
790
+ {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
682
791
  </Popup>
683
792
  {/if}
684
793
  {/each}
685
794
  </ul>
686
795
  {/if}
687
796
 
688
- {#if topItems.length > 0 && bottomItems.length > 0}
797
+ {#if items.length > 0 && bottomItems.length > 0}
689
798
  <div class="flex-1"></div>
690
799
  {/if}
691
800
 
@@ -696,13 +805,13 @@
696
805
  <li class="list-none">
697
806
  {@render row(item, { id })}
698
807
  </li>
699
- {#if (item.children?.length ?? 0) > 0 && !isStepped}
808
+ {#if ((item.children?.length ?? 0) > 0 || !!item.popout) && !isStepped}
700
809
  {@const geom = geometryFor(
701
810
  id,
702
811
  "bottom",
703
812
  idx,
704
813
  bottomItems.length,
705
- item.children!.length,
814
+ item.children?.length ?? 1,
706
815
  )}
707
816
  <Popup
708
817
  anchor={geom.anchor}
@@ -714,11 +823,12 @@
714
823
  if (openId === id) openId = null;
715
824
  }}
716
825
  >
717
- {@render popout(item, geom.borderClass)}
826
+ {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
718
827
  </Popup>
719
828
  {/if}
720
829
  {/each}
721
830
  </ul>
722
831
  {/if}
723
832
  {/if}
724
- </aside>
833
+ </aside>
834
+ </div>
@@ -18,6 +18,17 @@
18
18
  * Designed for wide desktop.
19
19
  */
20
20
  export type SideBarMenuMode = "stepped" | "drawer" | "icon" | "expanded";
21
+ /** Imperative handle exposed via `bind:api`. */
22
+ export type SideBarMenuApi = {
23
+ /** Reset all transient state — close the drawer, clear the open popout, return step level to 0. */
24
+ reset: () => void;
25
+ /** Open the drawer (drawer/stepped modes). */
26
+ open: () => void;
27
+ /** Close the drawer. */
28
+ close: () => void;
29
+ /** Set the stepped-mode step level (`0` = top). */
30
+ setStepLevel: (n: number) => void;
31
+ };
21
32
  export type SideBarMenuBadgeVariant = "error" | "warning" | "info" | "success";
22
33
  export type SideBarMenuBadge = {
23
34
  /** Color of the indicator. */
@@ -50,12 +61,21 @@ export type SideBarMenuItem = {
50
61
  badge?: SideBarMenuBadge;
51
62
  /** v1 renders only the first level; deeper levels emit a DEV warning. */
52
63
  children?: SideBarMenuItem[];
64
+ /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
65
+ * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
66
+ onclick?: (e: MouseEvent) => void;
67
+ /** Custom popout content, rendered INSTEAD of `children` in the popout (separators, hotkeys,
68
+ * an account/identity block…). The row still needs an `icon`/`label`; this is just its popout. */
69
+ popout?: import("svelte").Snippet;
70
+ /** Opt this anchor out of client-side routing — stamps `data-sveltekit-reload` (SvelteKit) so
71
+ * the link forces a full-browser navigation (sign-out endpoints, downloads, external redirects). */
72
+ reload?: boolean;
53
73
  };
54
74
  import type { Size } from "../../types/sizes.js";
55
75
  import { type Breakpoint } from "../../types/breakpoints.js";
56
76
  type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
57
77
  type $$ComponentProps = {
58
- topItems?: SideBarMenuItem[];
78
+ items?: SideBarMenuItem[];
59
79
  bottomItems?: SideBarMenuItem[];
60
80
  currentPath: string;
61
81
  size?: Size;
@@ -102,8 +122,17 @@ type $$ComponentProps = {
102
122
  * and binds to `open`.
103
123
  */
104
124
  hamburger?: boolean;
125
+ /** In `icon` mode, expand the rail to `expanded` ON HOVER — as a floating overlay over a spacer
126
+ * that reserves the collapsed width, so page content never reflows. Default `false`. */
127
+ iconHoverExpand?: boolean;
128
+ /** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
129
+ hoverExpandCloseMs?: number;
130
+ /** Imperative handle (bindable). Call `api.reset()` from your router's navigation lifecycle
131
+ * (SvelteKit `afterNavigate`, etc.) so a programmatic `goto()`/back-button closes the drawer +
132
+ * any open popout and returns a stepped drawer to the top. */
133
+ api?: SideBarMenuApi;
105
134
  class?: string;
106
135
  };
107
- declare const SideBarMenu: import("svelte").Component<$$ComponentProps, {}, "open" | "stepLevel">;
136
+ declare const SideBarMenu: import("svelte").Component<$$ComponentProps, {}, "open" | "stepLevel" | "api">;
108
137
  type SideBarMenu = ReturnType<typeof SideBarMenu>;
109
138
  export default SideBarMenu;
package/dist/index.d.ts CHANGED
@@ -41,7 +41,7 @@ export type { Columns, Gap } from "./components/field-group.js";
41
41
  export { default as Disclosure } from "./components/Disclosure.svelte";
42
42
  export { default as Accordion } from "./components/Accordion.svelte";
43
43
  export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
44
- export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, } from "./components/SideBarMenu/SideBarMenu.svelte";
44
+ export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, } from "./components/SideBarMenu/SideBarMenu.svelte";
45
45
  export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
46
46
  export type { TabDefinition } from "./components/TabStrip/TabStrip.svelte";
47
47
  export { default as DataTable } from "./components/DataTable.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.7.1-dev.41.d01009c",
3
+ "version": "0.7.1-dev.43.72e77ad",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"