@spaethtech/svelte-ui 0.9.0 → 0.10.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.
@@ -166,7 +166,12 @@ examples):
166
166
  · `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
167
167
  - **Navigation:** `TabStrip` (tabs; `size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
168
168
  fixed-height bar with bottom-aligned tabs, `activeSurface` to flow the active tab into the content
169
- below) · `SideBarMenu`
169
+ below) · **`SideBarMenu`** (data-driven vertical nav: `items`/`bottomItems` of `SideBarMenuItem`
170
+ {`icon`,`label`,`link`,`badge`,`children`,`onclick`,`popout`,`reload`,`trailing` snippet,`activeMatch`};
171
+ responsive `modes` map + `iconHoverExpand`; `variant` tints only hover/active; `size`; `side`;
172
+ `interactMode` `'hover'|'click'` popouts; `currentPath` active-match (per-item `activeMatch` for
173
+ query-keyed/exact rules); `bind:open`/`bind:api`/`bind:activeMode`; `publishWidthVar` → CSS width var;
174
+ popouts expose `data-sbm-popout`/`data-open`)
170
175
  - **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast`
171
176
  - **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
172
177
  `columns` + `gap`)
@@ -41,6 +41,7 @@
41
41
  variant,
42
42
  size = "md",
43
43
  borderless = false,
44
+ lightDismiss = true,
44
45
  onclose,
45
46
  children,
46
47
  }: {
@@ -66,6 +67,10 @@
66
67
  size?: Responsive<Size>;
67
68
  /** Drop the panel's visible border (border = its own background). */
68
69
  borderless?: boolean;
70
+ /** Built-in dismiss on outside pointerdown / Escape. Default `true`. Set `false` when the owner
71
+ * manages dismissal itself — e.g. a cascade of nested popouts, where each popout is "outside" its
72
+ * parent's top-layer element and would otherwise close its ancestors. */
73
+ lightDismiss?: boolean;
69
74
  onclose?: () => void;
70
75
  children: Snippet;
71
76
  } = $props();
@@ -101,9 +106,9 @@
101
106
  else if (!open && isOpen) el.hidePopover();
102
107
  });
103
108
 
104
- // Dismiss on Escape / outside pointerdown while open.
109
+ // Dismiss on Escape / outside pointerdown while open (unless the owner opts out via `lightDismiss`).
105
110
  $effect(() => {
106
- if (!open) return;
111
+ if (!open || !lightDismiss) return;
107
112
  const onKey = (e: KeyboardEvent) => {
108
113
  if (e.key === "Escape") {
109
114
  e.preventDefault();
@@ -127,6 +132,7 @@
127
132
  <div
128
133
  bind:this={el}
129
134
  popover="manual"
135
+ data-open={open}
130
136
  class="m-0 overflow-visible {surfaceClass}"
131
137
  style={surface ? `border-color: ${panelBorder};` : ""}
132
138
  use:anchored={{
@@ -26,6 +26,10 @@ type $$ComponentProps = {
26
26
  size?: Responsive<Size>;
27
27
  /** Drop the panel's visible border (border = its own background). */
28
28
  borderless?: boolean;
29
+ /** Built-in dismiss on outside pointerdown / Escape. Default `true`. Set `false` when the owner
30
+ * manages dismissal itself — e.g. a cascade of nested popouts, where each popout is "outside" its
31
+ * parent's top-layer element and would otherwise close its ancestors. */
32
+ lightDismiss?: boolean;
29
33
  onclose?: () => void;
30
34
  children: Snippet;
31
35
  };
@@ -68,6 +68,21 @@
68
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
69
  export type SideBarMenuItemIcon = any;
70
70
 
71
+ /**
72
+ * How an item decides it's "active" against `currentPath`. Absent → the default
73
+ * `currentPath` matches `link` on a segment boundary.
74
+ * - `string` / `string[]` — path prefix(es) (segment-boundary match, like `link`).
75
+ * - object — `path` prefix(es) PLUS optional `query` params that must ALL match (compared against
76
+ * `currentPath`'s query string, so pass `pathname + search` as `currentPath`), and `exact` to
77
+ * require the path to equal rather than prefix-match. Other query params are ignored.
78
+ * Use it for cross-group items keyed by a query param (e.g. `?via=network`) so a module that appears
79
+ * in several groups only highlights in the right one.
80
+ */
81
+ export type SideBarMenuActiveRule =
82
+ | string
83
+ | string[]
84
+ | { path: string | string[]; query?: Record<string, string>; exact?: boolean };
85
+
71
86
  export type SideBarMenuItem = {
72
87
  /**
73
88
  * Icon component constructor — same idiom as `MenuItem.icon`. Pass an
@@ -79,7 +94,8 @@
79
94
  /** href; absent when the item is a parent that only hosts children. */
80
95
  link?: string;
81
96
  badge?: SideBarMenuBadge;
82
- /** v1 renders only the first level; deeper levels emit a DEV warning. */
97
+ /** Child items rendered as a fly-out popout. Recursive: a child that itself has children
98
+ * cascades into a further popout (or steps deeper in `stepped` mode). */
83
99
  children?: SideBarMenuItem[];
84
100
  /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
85
101
  * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
@@ -90,11 +106,17 @@
90
106
  /** Opt this anchor out of client-side routing — stamps `data-sveltekit-reload` (SvelteKit) so
91
107
  * the link forces a full-browser navigation (sign-out endpoints, downloads, external redirects). */
92
108
  reload?: boolean;
109
+ /** Trailing-slot content rendered on the row's far end (after the label, before the parent chevron
110
+ * if any) — a state indicator, a dock/mute/notify glyph, a count, etc. Shown wherever the label is.
111
+ * Sized/positioned like the chevron; mirrors to the other edge under `side="right"`. */
112
+ trailing?: import("svelte").Snippet;
113
+ /** Override how this item computes its active state (see {@link SideBarMenuActiveRule}). Absent →
114
+ * the default `currentPath` segment-boundary match against `link`. */
115
+ activeMatch?: SideBarMenuActiveRule;
93
116
  };
94
117
  </script>
95
118
 
96
119
  <script lang="ts">
97
- import { DEV } from "esm-env";
98
120
  import Popup from "../Popup.svelte";
99
121
  import { tooltip } from "../../positioning/tooltip.js";
100
122
  import IconAlert from "~icons/mdi/alert";
@@ -129,6 +151,7 @@
129
151
  side = "left",
130
152
  api = $bindable(),
131
153
  activeMode = $bindable(),
154
+ publishWidthVar,
132
155
  class: additionalClass = "",
133
156
  }: {
134
157
  items?: SideBarMenuItem[];
@@ -175,10 +198,10 @@
175
198
  */
176
199
  open?: boolean;
177
200
  /**
178
- * Step-navigation level inside the `stepped`-mode drawer.
179
- * `0` = top-level. `1` = inside a parent item's children list.
180
- * Bind from the consumer so a header button can read it (e.g.
181
- * to flip X ← icon) and write it (set to `0` to navigate back).
201
+ * Step-navigation DEPTH inside the `stepped`-mode drawer (breadcrumb length).
202
+ * `0` = top-level, `1` = inside a parent's children, `2` = a grandparent's, … Bind from the
203
+ * consumer so a header button can read it (e.g. flip X → ← when `> 0`) and write it to go BACK
204
+ * one level (`stepLevel - 1`), or `0` to return to the top.
182
205
  */
183
206
  stepLevel?: number;
184
207
  /**
@@ -212,9 +235,20 @@
212
235
  /** Read-only (bindable) — the currently active {@link SideBarMenuMode}. Bind it to decide whether
213
236
  * your own header trigger should render (e.g. only in `drawer`/`stepped`). */
214
237
  activeMode?: SideBarMenuMode;
238
+ /** Publish the strip's live pixel width to a CSS custom property on `:root` (kept current via a
239
+ * `ResizeObserver`), so sibling layout can offset by it without its own observer. `true` →
240
+ * `--sbm-current-width`; a string → that custom-property name (use a distinct name per instance
241
+ * when you run more than one). Unset → nothing published. */
242
+ publishWidthVar?: boolean | string;
215
243
  class?: string;
216
244
  } = $props();
217
245
 
246
+ // Per-instance, hydration-stable id prefix so popout DOM ids are unique when more than one
247
+ // SideBarMenu renders on a page (otherwise `sbm-popout-top-1` collides, breaking `aria-controls`
248
+ // and `getElementById`). `$props.id()` is consistent across SSR + client.
249
+ const uid = $props.id();
250
+ const popoutDomId = (id: ItemId) => `${uid}-sbm-popout-${id.replace(/[:>]/g, "-")}`;
251
+
218
252
  // Default mode map — replicates the pre-modes behavior so old
219
253
  // consumers continue working unchanged. `collapsedAt` plugs into the
220
254
  // `expanded` slot so the legacy prop still tunes where labels appear.
@@ -246,7 +280,13 @@
246
280
  // the server `window` is undefined, so SSR emits `expanded`; the client corrects during hydration
247
281
  // (not via a delayed effect). The effect below keeps it in sync on subsequent viewport changes.
248
282
  let currentMode = $state<SideBarMenuMode>(
249
- typeof window !== "undefined" ? resolveMode(effectiveModes, window.innerWidth) : "expanded",
283
+ typeof window !== "undefined"
284
+ ? resolveMode(effectiveModes, window.innerWidth)
285
+ : // SSR has no viewport. Resolve at the WIDEST width: a forced/single-mode map (e.g.
286
+ // `{ base: "icon" }`) then resolves EXACTLY, so a forced icon rail renders collapsed on the
287
+ // server and doesn't flash from `expanded` → `icon` on hydration. A responsive map still
288
+ // resolves to its top tier (typically `expanded`) — SSR can't know the real viewport.
289
+ resolveMode(effectiveModes, Number.MAX_SAFE_INTEGER),
250
290
  );
251
291
 
252
292
  $effect(() => {
@@ -375,27 +415,6 @@
375
415
  : "",
376
416
  );
377
417
 
378
- // ── DEV depth warning ───────────────────────────────────────────
379
- if (DEV) {
380
- const walk = (list: SideBarMenuItem[], depth: number, path: string[]) => {
381
- for (const it of list) {
382
- const here = [...path, it.label];
383
- if (it.children?.length) {
384
- if (depth >= 1) {
385
- console.warn(
386
- `SideBarMenu: nesting deeper than 1 level is not yet rendered; flattening ` +
387
- `grandchildren of "${here.join(" › ")}"`,
388
- );
389
- return;
390
- }
391
- walk(it.children, depth + 1, here);
392
- }
393
- }
394
- };
395
- walk(items, 0, []);
396
- walk(bottomItems, 0, []);
397
- }
398
-
399
418
  // ── Size axis ───────────────────────────────────────────────────
400
419
  // All sub-element sizes derive from the icon size:
401
420
  // - Badge icon ≈ icon × 0.5 (warning/error/info/success glyph)
@@ -413,8 +432,8 @@
413
432
  pill: string; // min-w + h literal for the count pill
414
433
  pillText: string; // text-[Npx] for the count digits
415
434
  pillOffset: string; // absolute top-[N] right-[N] for the count pill
416
- chevronBox: string; // wrapper around the parent-row chevron
417
- chevronSvg: string; // chevron SVG sizing
435
+ chevronBox: string; // chevron/trailing wrapper sized to the GLYPH (no dead space) so the 1rem
436
+ chevronSvg: string; // gap lands on the visible chevron, not an oversized box
418
437
  };
419
438
  const sizeMap: Record<Size, SizeStyles> = {
420
439
  sm: {
@@ -430,7 +449,7 @@
430
449
  pill: "min-w-[12px] h-[12px]",
431
450
  pillText: "text-[8px]",
432
451
  pillOffset: "top-[4px] right-[4px]",
433
- chevronBox: "w-5 h-5",
452
+ chevronBox: "w-3.5 h-3.5",
434
453
  chevronSvg: "[&_svg]:w-3.5 [&_svg]:h-3.5",
435
454
  },
436
455
  md: {
@@ -443,7 +462,7 @@
443
462
  pill: "min-w-[14px] h-[14px]",
444
463
  pillText: "text-[9px]",
445
464
  pillOffset: "top-[5px] right-[5px]",
446
- chevronBox: "w-6 h-6",
465
+ chevronBox: "w-4 h-4",
447
466
  chevronSvg: "[&_svg]:w-4 [&_svg]:h-4",
448
467
  },
449
468
  lg: {
@@ -456,12 +475,20 @@
456
475
  pill: "min-w-[16px] h-[16px]",
457
476
  pillText: "text-[10px]",
458
477
  pillOffset: "top-[6px] right-[6px]",
459
- chevronBox: "w-7 h-7",
478
+ chevronBox: "w-5 h-5",
460
479
  chevronSvg: "[&_svg]:w-5 [&_svg]:h-5",
461
480
  },
462
481
  };
463
482
  const sz = $derived(sizeMap[size]);
464
483
 
484
+ // A group reserves the leading icon column only when at least one of its items has an icon (or a
485
+ // badge, which is anchored to the icon box). A group with none → labels aren't indented. Items in a
486
+ // reserving group that lack an icon still get an empty box so everything aligns. The main rail counts
487
+ // `items` + `bottomItems` as ONE group (so top/bottom align); each popout / stepped sub-view is its
488
+ // own group.
489
+ const anyIcon = (list: SideBarMenuItem[]) => list.some((i) => i.icon || i.badge);
490
+ const railReserve = $derived(anyIcon(items) || anyIcon(bottomItems));
491
+
465
492
  // ── Mobile drawer state ─────────────────────────────────────────
466
493
  // Below `xs` the strip collapses to `width: 0` and either the built-in
467
494
  // hamburger (default) or a consumer-provided trigger toggles a slide-in
@@ -482,24 +509,26 @@
482
509
  openId = null;
483
510
  }
484
511
 
485
- // The parent whose children the drawer is currently displaying (when
486
- // `stepLevel === 1`). Reset to null whenever stepLevel returns to 0
487
- // or the drawer closes — the next step starts fresh from the top.
488
- let currentParent = $state<SideBarMenuItem | null>(null);
512
+ // Breadcrumb of parents the stepped drawer has descended into. `stepLevel` mirrors its length:
513
+ // 0 = top, 1 = inside a parent, 2 = a grandparent, … The drawer shows the LAST entry's children.
514
+ let stepPath = $state<SideBarMenuItem[]>([]);
489
515
 
516
+ // Keep the (bindable) `stepLevel` and the internal path in sync: when the consumer decreases
517
+ // `stepLevel` (a "back" affordance, or 0) truncate the path to match.
490
518
  $effect(() => {
491
- if (stepLevel === 0) currentParent = null;
519
+ if (stepLevel < stepPath.length) stepPath = stepPath.slice(0, stepLevel);
492
520
  });
493
521
  $effect(() => {
494
522
  if (!open) {
495
523
  stepLevel = 0;
496
- currentParent = null;
524
+ stepPath = [];
497
525
  }
498
526
  });
499
527
 
500
- function stepIntoChildren(item: SideBarMenuItem) {
501
- currentParent = item;
502
- stepLevel = 1;
528
+ function stepIntoChildren(item: SideBarMenuItem, _id?: ItemId) {
529
+ const next = [...stepPath, item];
530
+ stepPath = next;
531
+ stepLevel = next.length;
503
532
  }
504
533
 
505
534
  // Imperative handle for consumers (bound via `bind:api`). Assigned once — the closures read the
@@ -542,19 +571,65 @@
542
571
  if (link === "/") return false;
543
572
  return path.startsWith(link.endsWith("/") ? link : link + "/");
544
573
  }
574
+
575
+ // `currentPath` split into path + query, so `activeMatch` rules can test query params. Only the
576
+ // path portion is used for prefix matching; the params are consulted for `query` rules.
577
+ const parsedCurrent = $derived.by(() => {
578
+ const q = currentPath.indexOf("?");
579
+ return q === -1
580
+ ? { path: currentPath, params: new URLSearchParams() }
581
+ : { path: currentPath.slice(0, q), params: new URLSearchParams(currentPath.slice(q + 1)) };
582
+ });
583
+
584
+ function matchesActive(rule: SideBarMenuActiveRule): boolean {
585
+ const { path: curPath, params } = parsedCurrent;
586
+ const obj = typeof rule === "object" && !Array.isArray(rule);
587
+ const paths = typeof rule === "string" ? [rule] : Array.isArray(rule) ? rule : [rule.path].flat();
588
+ const exact = obj ? !!rule.exact : false;
589
+ if (!paths.some((p) => (exact ? p === curPath : pathMatches(p, curPath)))) return false;
590
+ if (obj && rule.query) {
591
+ for (const [k, v] of Object.entries(rule.query)) if (params.get(k) !== v) return false;
592
+ }
593
+ return true;
594
+ }
595
+
596
+ // Active when `activeMatch` matches (if provided), else the default segment-boundary match on `link`.
597
+ // A parent is also active when any descendant matches. `activeMatch` fully replaces the link default
598
+ // for that item (opt-in — the default behaviour is unchanged for items without it).
545
599
  function isItemActive(item: SideBarMenuItem): boolean {
546
- if (item.link && pathMatches(item.link, currentPath)) return true;
600
+ if (item.activeMatch !== undefined) {
601
+ if (matchesActive(item.activeMatch)) return true;
602
+ } else if (item.link && pathMatches(item.link, currentPath)) {
603
+ return true;
604
+ }
547
605
  if (item.children?.length) return item.children.some(isItemActive);
548
606
  return false;
549
607
  }
608
+ // A row's OWN active state (drives `aria-current`) — its own rule/link only, never descendants.
609
+ function isItemOwnActive(item: SideBarMenuItem): boolean {
610
+ return item.activeMatch !== undefined
611
+ ? matchesActive(item.activeMatch)
612
+ : !!item.link && pathMatches(item.link, currentPath);
613
+ }
550
614
 
551
615
  // ── Hover/click open state ──────────────────────────────────────
552
- type ItemId = `top:${number}` | `bottom:${number}`;
616
+ // An id is a group-prefixed INDEX PATH: `top:0`, `top:0>2`, `bottom:1>0>3` (">" separates tiers).
617
+ // `openId` holds the DEEPEST open node; a popout is open when its node is on that path (itself or an
618
+ // ancestor), so a whole cascade of fly-outs can be open at once.
619
+ type ItemId = string;
553
620
 
554
621
  let openId = $state<ItemId | null>(null);
555
622
  let closeTimer: ReturnType<typeof setTimeout> | null = null;
556
623
  const CLOSE_GRACE_MS = 150;
557
624
 
625
+ // The parent path of an id (drop the last tier); `null` at the top level (→ close everything).
626
+ const parentOf = (id: ItemId): ItemId | null => {
627
+ const i = id.lastIndexOf(">");
628
+ return i === -1 ? null : id.slice(0, i);
629
+ };
630
+ // Is this node's popout open? True when it IS the deepest open node or an ANCESTOR of it.
631
+ const isOnPath = (id: ItemId): boolean => openId === id || (openId?.startsWith(id + ">") ?? false);
632
+
558
633
  function cancelClose() {
559
634
  if (closeTimer !== null) {
560
635
  clearTimeout(closeTimer);
@@ -568,13 +643,17 @@
568
643
  closeTimer = null;
569
644
  }, CLOSE_GRACE_MS);
570
645
  }
571
- function openOnHover(id: ItemId) {
646
+ // Hovering a row sets the open path: a PARENT opens its own popout (ancestors stay open, siblings +
647
+ // their sub-trees close); a LEAF drops to its parent path (keeps the branch it's in, closes deeper).
648
+ function openOnHover(id: ItemId, isParent: boolean) {
572
649
  cancelClose();
573
- if (openId !== id) openId = id;
650
+ const target = isParent ? id : parentOf(id);
651
+ if (openId !== target) openId = target;
574
652
  }
653
+ // Click toggles this node's popout: open it, or if it's already the deepest, collapse to its parent.
575
654
  function toggleOnClick(id: ItemId) {
576
655
  cancelClose();
577
- openId = openId === id ? null : id;
656
+ openId = openId === id ? parentOf(id) : id;
578
657
  }
579
658
 
580
659
  // ── Refs ────────────────────────────────────────────────────────
@@ -582,6 +661,50 @@
582
661
  // One ref per parent trigger. Keyed by ItemId so Popup can anchor.
583
662
  const triggerRefs = $state<Record<string, HTMLElement | undefined>>({});
584
663
 
664
+ // ── Cascade dismiss ─────────────────────────────────────────────
665
+ // The popouts run with `lightDismiss={false}` (a nested popout is "outside" its parent's top-layer
666
+ // element, so per-popout dismiss would close ancestors). Manage it here for the whole chain: an
667
+ // outside pointerdown closes everything; Escape steps up one level.
668
+ $effect(() => {
669
+ const onDown = (e: PointerEvent) => {
670
+ if (openId === null) return;
671
+ const t = e.target as Node | null;
672
+ if (asideRef?.contains(t)) return;
673
+ if (t instanceof Element && t.closest("[data-sbm-popout]")) return;
674
+ openId = null;
675
+ };
676
+ const onKey = (e: KeyboardEvent) => {
677
+ if (e.key !== "Escape" || openId === null) return;
678
+ e.preventDefault();
679
+ openId = null; // close the whole cascade (one-level-close fights a still-hovering pointer)
680
+ };
681
+ document.addEventListener("pointerdown", onDown, true);
682
+ document.addEventListener("keydown", onKey, true);
683
+ return () => {
684
+ document.removeEventListener("pointerdown", onDown, true);
685
+ document.removeEventListener("keydown", onKey, true);
686
+ };
687
+ });
688
+
689
+ // ── Publish width (opt-in) ──────────────────────────────────────
690
+ // When `publishWidthVar` is set, mirror the strip's live width to a CSS var on `:root` so sibling
691
+ // layout can react to it (icon rail ⇄ expanded, hover-expand float, drawer slide) without its own
692
+ // ResizeObserver. Cleaned up (and the var removed) when disabled or the component unmounts.
693
+ $effect(() => {
694
+ if (!publishWidthVar || !asideRef) return;
695
+ const name = typeof publishWidthVar === "string" ? publishWidthVar : "--sbm-current-width";
696
+ const root = document.documentElement;
697
+ const node = asideRef;
698
+ const write = () => root.style.setProperty(name, `${node.offsetWidth}px`);
699
+ write();
700
+ const ro = new ResizeObserver(write);
701
+ ro.observe(node);
702
+ return () => {
703
+ ro.disconnect();
704
+ root.style.removeProperty(name);
705
+ };
706
+ });
707
+
585
708
  // Callback-ref action — `bind:this` can't take a {get, set} pair and can't
586
709
  // take a ternary expression, so we use a tiny action to wire each row's
587
710
  // DOM node into the triggerRefs map by id. No-op when id is undefined
@@ -682,21 +805,44 @@
682
805
  `forceLabel` flag overrides the strip's responsive label-visibility
683
806
  rule — popout children and stepped sub-view children always show
684
807
  their text labels regardless of the outer strip's collapsed state. -->
685
- {#snippet rowBody(item: SideBarMenuItem, isParent: boolean, forceLabel: boolean, isOpen: boolean)}
808
+ {#snippet rowBody(
809
+ item: SideBarMenuItem,
810
+ isParent: boolean,
811
+ forceLabel: boolean,
812
+ isOpen: boolean,
813
+ reserveIcon: boolean,
814
+ )}
686
815
  {@const labelClassResolved = forceLabel ? "inline" : labelClass}
687
816
  {@const chevronClass = forceLabel ? "inline-flex" : chevronVisibility}
688
- <span class="relative inline-flex shrink-0 items-center justify-center {sz.iconBox} {sz.iconSvg}">
689
- {#if item.icon}{@const Icon = item.icon}<Icon />{/if}
690
- {#if item.badge}{@render badge(item.badge)}{/if}
691
- </span>
817
+ <!-- 1rem gap before the trailing/chevron cluster (label ↔ chevron breathing room); mirrors under
818
+ `side="right"`. -->
819
+ {@const endGap = dockRight ? "mr-4" : "ml-4"}
820
+ {#if reserveIcon}
821
+ <span class="relative inline-flex shrink-0 items-center justify-center {sz.iconBox} {sz.iconSvg}">
822
+ {#if item.icon}{@const Icon = item.icon}<Icon />{/if}
823
+ {#if item.badge}{@render badge(item.badge)}{/if}
824
+ </span>
825
+ {/if}
692
826
  <!-- `flex-1 min-w-0 truncate` makes the label take the remaining
693
827
  row width AND lets it shrink below its content size, which is
694
828
  what `truncate` (overflow-hidden + text-ellipsis) needs to
695
829
  actually clip overflow. Without `min-w-0` the label refuses
696
830
  to shrink past its natural width and the row overflows. -->
697
- <span class="flex-1 min-w-0 truncate {dockRight ? 'text-right' : ''} {labelClassResolved} {sz.text}"
698
- >{item.label}</span
831
+ <!-- When the group reserves an icon column the box supplies the leading inset; when it doesn't,
832
+ give the label a 1rem leading inset so it isn't flush to the edge. -->
833
+ <span
834
+ class="flex-1 min-w-0 truncate {reserveIcon ? '' : dockRight ? 'pr-4' : 'pl-4'} {dockRight
835
+ ? 'text-right'
836
+ : ''} {labelClassResolved} {sz.text}">{item.label}</span
699
837
  >
838
+ {#if item.trailing}
839
+ <!-- Trailing slot — a state indicator / glyph on the far end. Visible with the label (like the
840
+ chevron); sits before the chevron on a parent row. `chevronClass` keeps it `inline-flex`. -->
841
+ <span
842
+ class="shrink-0 items-center justify-center {endGap} {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
843
+ >{@render item.trailing()}</span
844
+ >
845
+ {/if}
700
846
  {#if isParent}
701
847
  <!-- Chevron — visible only when the label is visible (no point
702
848
  dangling a `>` next to an icon-only icon). Rotates 180° to
@@ -705,7 +851,7 @@
705
851
  `chevronVisibility` (not `labelClassResolved`) so the span
706
852
  stays `inline-flex` and the SVG centers vertically. -->
707
853
  <span
708
- class="shrink-0 items-center justify-center opacity-60 transition-transform duration-150 {isOpen
854
+ class="shrink-0 items-center justify-center opacity-60 transition-transform duration-150 {endGap} {isOpen
709
855
  ? 'rotate-180'
710
856
  : ''} {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
711
857
  >
@@ -715,16 +861,22 @@
715
861
  {/snippet}
716
862
 
717
863
  <!-- Single row template — used by items / bottomItems and the popout. -->
718
- {#snippet row(item: SideBarMenuItem, opts: { id?: ItemId; isPopoutChild?: boolean })}
864
+ {#snippet row(
865
+ item: SideBarMenuItem,
866
+ opts: { id?: ItemId; isPopoutChild?: boolean; reserveIcon?: boolean },
867
+ )}
719
868
  {@const active = isItemActive(item)}
720
- {@const ownActive = !!item.link && pathMatches(item.link, currentPath)}
869
+ {@const ownActive = isItemOwnActive(item)}
870
+ {@const reserveIcon = opts.reserveIcon ?? true}
721
871
  {@const hasChildren = (item.children?.length ?? 0) > 0}
722
- {@const isParent = (hasChildren || !!item.popout) && !opts.isPopoutChild}
872
+ {@const isParent = hasChildren || !!item.popout}
723
873
  {@const hasLink = !!item.link}
724
- {@const isOpen = !!opts.id && openId === opts.id}
725
- {@const popoutId = opts.id ? "sbm-popout-" + opts.id.replace(":", "-") : undefined}
874
+ {@const isOpen = !!opts.id && isOnPath(opts.id)}
875
+ {@const popoutId = opts.id ? popoutDomId(opts.id) : undefined}
726
876
  {@const tipSuppressed = tooltipSuppressed || !!opts.isPopoutChild}
727
- {@const rowClass = `group flex items-center w-full ${sz.row} ${dockRight ? "flex-row-reverse" : ""} ${labelsVisible || opts.isPopoutChild ? (dockRight ? "pl-3" : "pr-3") : ""} transition-colors cursor-pointer ${active ? "[background-color:var(--sbm-accent)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--sbm-accent)_18%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--sbm-accent)_12%,transparent)]"}`}
877
+ {@const enter = opts.id && popoutOnHover ? () => openOnHover(opts.id!, isParent) : undefined}
878
+ {@const leave = opts.id && popoutOnHover ? scheduleClose : undefined}
879
+ {@const rowClass = `group flex items-center w-full ${sz.row} ${dockRight ? "flex-row-reverse" : ""} ${labelsVisible || opts.isPopoutChild ? (dockRight ? "pl-4" : "pr-4") : ""} transition-colors cursor-pointer ${active ? "[background-color:var(--sbm-accent)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--sbm-accent)_18%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--sbm-accent)_12%,transparent)]"}`}
728
880
  {#if hasLink}
729
881
  <a
730
882
  href={item.link}
@@ -735,13 +887,9 @@
735
887
  aria-haspopup={isParent ? "menu" : undefined}
736
888
  aria-expanded={isParent && opts.id ? isOpen : undefined}
737
889
  aria-controls={isParent && isOpen ? popoutId : undefined}
738
- use:tooltip={{
739
- text: item.label,
740
- side: popoutSide,
741
- disabled: tipSuppressed || (isParent && opts.id ? openId === opts.id : false),
742
- }}
743
- onmouseenter={isParent && opts.id && popoutOnHover ? () => openOnHover(opts.id!) : undefined}
744
- onmouseleave={isParent && opts.id && popoutOnHover ? scheduleClose : undefined}
890
+ use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || isOpen }}
891
+ onmouseenter={enter}
892
+ onmouseleave={leave}
745
893
  onclick={(e) => {
746
894
  // Power-user passthrough: Shift/Cmd/Ctrl click opens in
747
895
  // a new tab — don't mutate UI state in that case.
@@ -752,13 +900,16 @@
752
900
  // Cancel default navigation so the click can't
753
901
  // follow the parent's href out from under us.
754
902
  e.preventDefault();
755
- stepIntoChildren(item);
756
- } else {
757
- // When popouts are click-to-open (a plain icon rail), a click opens the popout
903
+ stepIntoChildren(item, opts.id);
904
+ } else if (!popoutOnHover) {
905
+ // Click-to-open (a plain icon rail / interactMode="click"): a click opens the popout
758
906
  // instead of following the parent's href.
759
- if (!popoutOnHover) e.preventDefault();
907
+ e.preventDefault();
760
908
  toggleOnClick(opts.id);
761
909
  }
910
+ // Hover mode: the popout is governed by hover, so a click just navigates. Toggling here
911
+ // would fight the tap's own `mouseenter` (which already opened it) and close it — the
912
+ // first-tap "opens then immediately closes" bug (B1).
762
913
  } else {
763
914
  // Leaf or popout-child navigation. Reset the drawer
764
915
  // so the next open starts at the top, the stepped
@@ -767,7 +918,7 @@
767
918
  }
768
919
  }}
769
920
  >
770
- {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
921
+ {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen, reserveIcon)}
771
922
  </a>
772
923
  {:else if isParent}
773
924
  <button
@@ -775,14 +926,21 @@
775
926
  use:refIntoMap={opts.id}
776
927
  class="{rowClass} text-left"
777
928
  aria-haspopup="menu"
778
- aria-expanded={openId === opts.id}
929
+ aria-expanded={isOpen}
779
930
  aria-controls={isOpen ? popoutId : undefined}
780
- use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || openId === opts.id }}
781
- onmouseenter={popoutOnHover ? () => openOnHover(opts.id!) : undefined}
782
- onmouseleave={popoutOnHover ? scheduleClose : undefined}
783
- onclick={() => (isStepped ? stepIntoChildren(item) : toggleOnClick(opts.id!))}
931
+ use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || isOpen }}
932
+ onmouseenter={enter}
933
+ onmouseleave={leave}
934
+ onclick={() => {
935
+ if (isStepped) stepIntoChildren(item, opts.id!);
936
+ // Hover mode: click (re)opens — never toggles closed — so a touch tap (which fires
937
+ // `mouseenter` then `click`) ends up OPEN instead of open-then-closed (B1). Click mode
938
+ // has no hover-open to fight, so it toggles as before.
939
+ else if (popoutOnHover) openOnHover(opts.id!, true);
940
+ else toggleOnClick(opts.id!);
941
+ }}
784
942
  >
785
- {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
943
+ {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen, reserveIcon)}
786
944
  </button>
787
945
  {:else if item.onclick}
788
946
  <!-- Action row: no link, no children/popout — a `<button>` that runs `onclick`
@@ -792,44 +950,93 @@
792
950
  use:refIntoMap={opts.id}
793
951
  class="{rowClass} text-left"
794
952
  use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed }}
953
+ onmouseenter={enter}
954
+ onmouseleave={leave}
795
955
  onclick={(e) => {
796
956
  item.onclick!(e);
797
957
  closeAfterNav();
798
958
  }}
799
959
  >
800
- {@render rowBody(item, false, !!opts.isPopoutChild, false)}
960
+ {@render rowBody(item, false, !!opts.isPopoutChild, false, reserveIcon)}
801
961
  </button>
802
962
  {:else}
803
963
  <!-- Malformed item: no link, no children. Render visibly so the
804
964
  consumer notices, but with disabled-style cursor + opacity. -->
805
965
  <span class="{rowClass} opacity-40 cursor-not-allowed" aria-disabled="true">
806
- {@render rowBody(item, false, !!opts.isPopoutChild, false)}
966
+ {@render rowBody(item, false, !!opts.isPopoutChild, false, reserveIcon)}
807
967
  </span>
808
968
  {/if}
809
969
  {/snippet}
810
970
 
811
- <!-- Popout panelfixed 200px (--sbm-expanded-w), edge-to-edge rows,
812
- mirrors main strip styling. `borderClass` is computed per popout
813
- by `geometryFor` so the visible edges get borders and the edges
814
- that coincide with the sidebar's top/bottom don't. -->
815
- {#snippet popout(item: SideBarMenuItem, borderClass: string, popoutId: string)}
971
+ <!-- A menu node the row plus, when it's a parent, its fly-out Popup (which recurses via
972
+ `popoutPanel`). This is what makes the menu multi-tier: a nested parent renders its own node
973
+ + Popup inside its ancestor's popout. -->
974
+ {#snippet menuNode(
975
+ item: SideBarMenuItem,
976
+ id: ItemId,
977
+ group: "top" | "bottom",
978
+ idx: number,
979
+ total: number,
980
+ reserveIcon: boolean,
981
+ depth: number,
982
+ )}
983
+ {@render row(item, { id, isPopoutChild: depth > 0, reserveIcon })}
984
+ {#if (((item.children?.length ?? 0) > 0) || !!item.popout) && !isStepped}
985
+ {@const top = depth === 0}
986
+ {@const geom = top
987
+ ? geometryFor(id, group, idx, total, item.children?.length ?? 1)
988
+ : {
989
+ anchor: triggerRefs[id],
990
+ align: "start" as const,
991
+ borderClass: `${dockRight ? "border-l" : "border-r"} border-t border-b`,
992
+ }}
993
+ <!-- Top-level parents (depth 0) anchor to the sidebar via the spans rule; a nested parent anchors
994
+ to its own trigger row and flies out further to the same side — a cascade. `lightDismiss={false}`
995
+ because a nested popout is "outside" its parent's top-layer element; the aside-level listener
996
+ below dismisses the whole chain instead. -->
997
+ <Popup
998
+ anchor={geom.anchor}
999
+ open={isOnPath(id)}
1000
+ side={popoutSide}
1001
+ align={geom.align}
1002
+ offset={0}
1003
+ margin={top ? 0 : 8}
1004
+ lightDismiss={false}
1005
+ onclose={() => {
1006
+ if (isOnPath(id)) openId = null;
1007
+ }}
1008
+ >
1009
+ {@render popoutPanel(item, id, group, geom.borderClass)}
1010
+ </Popup>
1011
+ {/if}
1012
+ {/snippet}
1013
+
1014
+ <!-- Popout panel — the floating menu. Renders a custom `item.popout` snippet, else the children as
1015
+ nested `menuNode`s (so a child that is itself a parent cascades further). `data-sbm-popout` +
1016
+ `data-open` are consumer hooks; overflow scrolls, capped to the viewport. -->
1017
+ {#snippet popoutPanel(item: SideBarMenuItem, id: ItemId, group: "top" | "bottom", borderClass: string)}
1018
+ {@const popoutId = popoutDomId(id)}
1019
+ {@const isOpen = isOnPath(id)}
816
1020
  <!-- svelte-ignore a11y_interactive_supports_focus -->
817
1021
  <div
818
1022
  role="menu"
819
1023
  tabindex="-1"
820
1024
  id={popoutId}
1025
+ data-sbm-popout=""
1026
+ data-open={isOpen}
821
1027
  style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} --sbm-accent: {accentVar};"
822
- class="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}"
1028
+ class="overflow-y-auto overflow-x-hidden max-h-[calc(100vh-0.5rem)] 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}"
823
1029
  onmouseenter={popoutOnHover ? cancelClose : undefined}
824
1030
  onmouseleave={popoutOnHover ? scheduleClose : undefined}
825
1031
  >
826
1032
  {#if item.popout}
827
1033
  {@render item.popout()}
828
1034
  {:else}
1035
+ {@const childReserve = anyIcon(item.children ?? [])}
829
1036
  <ul class="flex flex-col">
830
- {#each item.children ?? [] as child (child.label)}
1037
+ {#each item.children ?? [] as child, ci (child.label)}
831
1038
  <li class="list-none" role="none">
832
- {@render row(child, { isPopoutChild: true })}
1039
+ {@render menuNode(child, `${id}>${ci}`, group, ci, item.children!.length, childReserve, 1)}
833
1040
  </li>
834
1041
  {/each}
835
1042
  </ul>
@@ -894,16 +1101,18 @@
894
1101
  {additionalClass}
895
1102
  "
896
1103
  >
897
- {#if isStepped && stepLevel === 1 && currentParent}
1104
+ {#if isStepped && stepPath.length > 0}
898
1105
  <!-- Stepped sub-view (<2xs only): show just the children of the
899
1106
  parent the user tapped. The consumer's hamburger button is
900
1107
  expected to flip to a `←` icon and set `stepLevel = 0` to
901
1108
  return here — there is no in-drawer back row by design so
902
1109
  the back affordance stays in a single, consistent spot. -->
1110
+ {@const currentParent = stepPath[stepPath.length - 1]}
1111
+ {@const stepReserve = anyIcon(currentParent.children ?? [])}
903
1112
  <ul class="flex flex-col {scrollList}">
904
- {#each currentParent.children ?? [] as child (child.label)}
1113
+ {#each currentParent.children ?? [] as child, ci (child.label)}
905
1114
  <li class="list-none">
906
- {@render row(child, { isPopoutChild: true })}
1115
+ {@render row(child, { id: `step:${ci}`, isPopoutChild: true, reserveIcon: stepReserve })}
907
1116
  </li>
908
1117
  {/each}
909
1118
  </ul>
@@ -911,26 +1120,9 @@
911
1120
  {#if items.length > 0}
912
1121
  <ul class="flex flex-col {scrollList}">
913
1122
  {#each items as item, idx (item.label)}
914
- {@const id = `top:${idx}` as ItemId}
915
1123
  <li class="list-none">
916
- {@render row(item, { id })}
1124
+ {@render menuNode(item, `top:${idx}`, "top", idx, items.length, railReserve, 0)}
917
1125
  </li>
918
- {#if ((item.children?.length ?? 0) > 0 || !!item.popout) && !isStepped}
919
- {@const geom = geometryFor(id, "top", idx, items.length, item.children?.length ?? 1)}
920
- <Popup
921
- anchor={geom.anchor}
922
- open={openId === id}
923
- side={popoutSide}
924
- align={geom.align}
925
- offset={0}
926
- margin={0}
927
- onclose={() => {
928
- if (openId === id) openId = null;
929
- }}
930
- >
931
- {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
932
- </Popup>
933
- {/if}
934
1126
  {/each}
935
1127
  </ul>
936
1128
  {/if}
@@ -944,32 +1136,9 @@
944
1136
  {#if bottomItems.length > 0}
945
1137
  <ul class="flex flex-col shrink-0">
946
1138
  {#each bottomItems as item, idx (item.label)}
947
- {@const id = `bottom:${idx}` as ItemId}
948
1139
  <li class="list-none">
949
- {@render row(item, { id })}
1140
+ {@render menuNode(item, `bottom:${idx}`, "bottom", idx, bottomItems.length, railReserve, 0)}
950
1141
  </li>
951
- {#if ((item.children?.length ?? 0) > 0 || !!item.popout) && !isStepped}
952
- {@const geom = geometryFor(
953
- id,
954
- "bottom",
955
- idx,
956
- bottomItems.length,
957
- item.children?.length ?? 1,
958
- )}
959
- <Popup
960
- anchor={geom.anchor}
961
- open={openId === id}
962
- side={popoutSide}
963
- align={geom.align}
964
- offset={0}
965
- margin={0}
966
- onclose={() => {
967
- if (openId === id) openId = null;
968
- }}
969
- >
970
- {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
971
- </Popup>
972
- {/if}
973
1142
  {/each}
974
1143
  </ul>
975
1144
  {/if}
@@ -48,6 +48,21 @@ export type SideBarMenuBadge = {
48
48
  * can't always prove it.
49
49
  */
50
50
  export type SideBarMenuItemIcon = any;
51
+ /**
52
+ * How an item decides it's "active" against `currentPath`. Absent → the default
53
+ * `currentPath` matches `link` on a segment boundary.
54
+ * - `string` / `string[]` — path prefix(es) (segment-boundary match, like `link`).
55
+ * - object — `path` prefix(es) PLUS optional `query` params that must ALL match (compared against
56
+ * `currentPath`'s query string, so pass `pathname + search` as `currentPath`), and `exact` to
57
+ * require the path to equal rather than prefix-match. Other query params are ignored.
58
+ * Use it for cross-group items keyed by a query param (e.g. `?via=network`) so a module that appears
59
+ * in several groups only highlights in the right one.
60
+ */
61
+ export type SideBarMenuActiveRule = string | string[] | {
62
+ path: string | string[];
63
+ query?: Record<string, string>;
64
+ exact?: boolean;
65
+ };
51
66
  export type SideBarMenuItem = {
52
67
  /**
53
68
  * Icon component constructor — same idiom as `MenuItem.icon`. Pass an
@@ -59,7 +74,8 @@ export type SideBarMenuItem = {
59
74
  /** href; absent when the item is a parent that only hosts children. */
60
75
  link?: string;
61
76
  badge?: SideBarMenuBadge;
62
- /** v1 renders only the first level; deeper levels emit a DEV warning. */
77
+ /** Child items rendered as a fly-out popout. Recursive: a child that itself has children
78
+ * cascades into a further popout (or steps deeper in `stepped` mode). */
63
79
  children?: SideBarMenuItem[];
64
80
  /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
65
81
  * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
@@ -70,6 +86,13 @@ export type SideBarMenuItem = {
70
86
  /** Opt this anchor out of client-side routing — stamps `data-sveltekit-reload` (SvelteKit) so
71
87
  * the link forces a full-browser navigation (sign-out endpoints, downloads, external redirects). */
72
88
  reload?: boolean;
89
+ /** Trailing-slot content rendered on the row's far end (after the label, before the parent chevron
90
+ * if any) — a state indicator, a dock/mute/notify glyph, a count, etc. Shown wherever the label is.
91
+ * Sized/positioned like the chevron; mirrors to the other edge under `side="right"`. */
92
+ trailing?: import("svelte").Snippet;
93
+ /** Override how this item computes its active state (see {@link SideBarMenuActiveRule}). Absent →
94
+ * the default `currentPath` segment-boundary match against `link`. */
95
+ activeMatch?: SideBarMenuActiveRule;
73
96
  };
74
97
  import type { Size } from "../../types/sizes.js";
75
98
  import { type Variant } from "../../types/variants.js";
@@ -120,10 +143,10 @@ type $$ComponentProps = {
120
143
  */
121
144
  open?: boolean;
122
145
  /**
123
- * Step-navigation level inside the `stepped`-mode drawer.
124
- * `0` = top-level. `1` = inside a parent item's children list.
125
- * Bind from the consumer so a header button can read it (e.g.
126
- * to flip X ← icon) and write it (set to `0` to navigate back).
146
+ * Step-navigation DEPTH inside the `stepped`-mode drawer (breadcrumb length).
147
+ * `0` = top-level, `1` = inside a parent's children, `2` = a grandparent's, … Bind from the
148
+ * consumer so a header button can read it (e.g. flip X → ← when `> 0`) and write it to go BACK
149
+ * one level (`stepLevel - 1`), or `0` to return to the top.
127
150
  */
128
151
  stepLevel?: number;
129
152
  /**
@@ -157,6 +180,11 @@ type $$ComponentProps = {
157
180
  /** Read-only (bindable) — the currently active {@link SideBarMenuMode}. Bind it to decide whether
158
181
  * your own header trigger should render (e.g. only in `drawer`/`stepped`). */
159
182
  activeMode?: SideBarMenuMode;
183
+ /** Publish the strip's live pixel width to a CSS custom property on `:root` (kept current via a
184
+ * `ResizeObserver`), so sibling layout can offset by it without its own observer. `true` →
185
+ * `--sbm-current-width`; a string → that custom-property name (use a distinct name per instance
186
+ * when you run more than one). Unset → nothing published. */
187
+ publishWidthVar?: boolean | string;
160
188
  class?: string;
161
189
  };
162
190
  declare const SideBarMenu: import("svelte").Component<$$ComponentProps, {}, "open" | "stepLevel" | "api" | "activeMode">;
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, SideBarMenuApi, SideBarMenuMode, } from "./components/SideBarMenu/SideBarMenu.svelte";
44
+ export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } 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.9.0",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"