@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.
- package/.claude/skills/svelte-ui/SKILL.md +6 -1
- package/dist/components/Popup.svelte +8 -2
- package/dist/components/Popup.svelte.d.ts +4 -0
- package/dist/components/SideBarMenu/SideBarMenu.svelte +303 -134
- package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +33 -5
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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) ·
|
|
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
|
-
/**
|
|
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
|
|
179
|
-
* `0` = top-level
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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"
|
|
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
|
|
417
|
-
chevronSvg: string; // chevron
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
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
|
|
519
|
+
if (stepLevel < stepPath.length) stepPath = stepPath.slice(0, stepLevel);
|
|
492
520
|
});
|
|
493
521
|
$effect(() => {
|
|
494
522
|
if (!open) {
|
|
495
523
|
stepLevel = 0;
|
|
496
|
-
|
|
524
|
+
stepPath = [];
|
|
497
525
|
}
|
|
498
526
|
});
|
|
499
527
|
|
|
500
|
-
function stepIntoChildren(item: SideBarMenuItem) {
|
|
501
|
-
|
|
502
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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(
|
|
864
|
+
{#snippet row(
|
|
865
|
+
item: SideBarMenuItem,
|
|
866
|
+
opts: { id?: ItemId; isPopoutChild?: boolean; reserveIcon?: boolean },
|
|
867
|
+
)}
|
|
719
868
|
{@const active = isItemActive(item)}
|
|
720
|
-
{@const ownActive =
|
|
869
|
+
{@const ownActive = isItemOwnActive(item)}
|
|
870
|
+
{@const reserveIcon = opts.reserveIcon ?? true}
|
|
721
871
|
{@const hasChildren = (item.children?.length ?? 0) > 0}
|
|
722
|
-
{@const isParent =
|
|
872
|
+
{@const isParent = hasChildren || !!item.popout}
|
|
723
873
|
{@const hasLink = !!item.link}
|
|
724
|
-
{@const isOpen = !!opts.id &&
|
|
725
|
-
{@const popoutId = opts.id ?
|
|
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
|
|
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
|
-
|
|
740
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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={
|
|
929
|
+
aria-expanded={isOpen}
|
|
779
930
|
aria-controls={isOpen ? popoutId : undefined}
|
|
780
|
-
use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed ||
|
|
781
|
-
onmouseenter={
|
|
782
|
-
onmouseleave={
|
|
783
|
-
onclick={() =>
|
|
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
|
-
<!--
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
124
|
-
* `0` = top-level
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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";
|