@spaethtech/svelte-ui 0.12.0 → 0.13.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.
Files changed (34) hide show
  1. package/.claude/skills/svelte-ui/SKILL.md +13 -4
  2. package/dist/components/Avatar/Avatar.svelte +118 -0
  3. package/dist/components/Avatar/Avatar.svelte.d.ts +20 -0
  4. package/dist/components/Avatar/AvatarGroup.svelte +83 -0
  5. package/dist/components/Avatar/AvatarGroup.svelte.d.ts +24 -0
  6. package/dist/components/Avatar/index.d.ts +4 -0
  7. package/dist/components/Avatar/index.js +2 -0
  8. package/dist/components/Breadcrumbs/Breadcrumbs.svelte +131 -0
  9. package/dist/components/Breadcrumbs/Breadcrumbs.svelte.d.ts +19 -0
  10. package/dist/components/Breadcrumbs/index.d.ts +2 -0
  11. package/dist/components/Breadcrumbs/index.js +1 -0
  12. package/dist/components/ButtonGroup/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/components/Chip/Chip.svelte +139 -0
  14. package/dist/components/Chip/Chip.svelte.d.ts +22 -0
  15. package/dist/components/Chip/index.d.ts +1 -0
  16. package/dist/components/Chip/index.js +1 -0
  17. package/dist/components/Divider/Divider.svelte +48 -0
  18. package/dist/components/Divider/Divider.svelte.d.ts +11 -0
  19. package/dist/components/Divider/index.d.ts +1 -0
  20. package/dist/components/Divider/index.js +1 -0
  21. package/dist/components/Pagination/Pagination.svelte +147 -0
  22. package/dist/components/Pagination/Pagination.svelte.d.ts +20 -0
  23. package/dist/components/Pagination/index.d.ts +1 -0
  24. package/dist/components/Pagination/index.js +1 -0
  25. package/dist/components/Popup.svelte +12 -1
  26. package/dist/components/Popup.svelte.d.ts +6 -1
  27. package/dist/components/SideBarMenu/SideBarMenu.svelte +160 -76
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.js +5 -0
  30. package/dist/positioning/anchored.d.ts +4 -0
  31. package/dist/positioning/anchored.js +19 -4
  32. package/docs/components.md +64 -0
  33. package/docs/usage.md +109 -0
  34. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ <!--
2
+ /**
3
+ * Divider — a thin separating rule. Horizontal (optionally with an inline label) or vertical (a 1px
4
+ * line that stretches to a flex row's height). Neutral `--ui-border-color`; no variant/size axis.
5
+ * See Divider.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ import type { Snippet } from "svelte";
10
+
11
+ let {
12
+ orientation = "horizontal",
13
+ label,
14
+ labelAlign = "center",
15
+ class: cls = "",
16
+ children,
17
+ }: {
18
+ orientation?: "horizontal" | "vertical";
19
+ label?: string;
20
+ labelAlign?: "start" | "center" | "end";
21
+ class?: string;
22
+ children?: Snippet;
23
+ } = $props();
24
+
25
+ const hasLabel = $derived(orientation === "horizontal" && (!!label || !!children));
26
+ </script>
27
+
28
+ {#if orientation === "vertical"}
29
+ <div
30
+ role="separator"
31
+ aria-orientation="vertical"
32
+ class="inline-block w-px self-stretch [background-color:var(--ui-border-color)] {cls}"
33
+ ></div>
34
+ {:else if hasLabel}
35
+ <div role="separator" aria-orientation="horizontal" class="flex items-center gap-3 {cls}">
36
+ {#if labelAlign !== "start"}
37
+ <div class="h-px flex-1 [background-color:var(--ui-border-color)]"></div>
38
+ {/if}
39
+ <span class="shrink-0 text-xs [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]">
40
+ {#if children}{@render children()}{:else}{label}{/if}
41
+ </span>
42
+ {#if labelAlign !== "end"}
43
+ <div class="h-px flex-1 [background-color:var(--ui-border-color)]"></div>
44
+ {/if}
45
+ </div>
46
+ {:else}
47
+ <hr class="w-full border-0 h-px [background-color:var(--ui-border-color)] {cls}" />
48
+ {/if}
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from "svelte";
2
+ type $$ComponentProps = {
3
+ orientation?: "horizontal" | "vertical";
4
+ label?: string;
5
+ labelAlign?: "start" | "center" | "end";
6
+ class?: string;
7
+ children?: Snippet;
8
+ };
9
+ declare const Divider: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type Divider = ReturnType<typeof Divider>;
11
+ export default Divider;
@@ -0,0 +1 @@
1
+ export { default as Divider } from "./Divider.svelte";
@@ -0,0 +1 @@
1
+ export { default as Divider } from "./Divider.svelte";
@@ -0,0 +1,147 @@
1
+ <!--
2
+ /**
3
+ * Pagination — a standalone page navigator (numbered buttons + ellipsis, prev/next, optional
4
+ * first/last, optional rows-per-page Select + total). The reusable extraction of DataTable's footer
5
+ * pager; built only from `Button` (ghost, active = filled variant) and `Select`. See Pagination.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ import Button from "../Button.svelte";
10
+ import Select from "../Select.svelte";
11
+ import type { Variant } from "../../types/variants.js";
12
+ import type { Size } from "../../types/sizes.js";
13
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
14
+ import IconFirst from "~icons/mdi/page-first";
15
+ import IconPrev from "~icons/mdi/chevron-left";
16
+ import IconNext from "~icons/mdi/chevron-right";
17
+ import IconLast from "~icons/mdi/page-last";
18
+
19
+ let {
20
+ page = $bindable(1),
21
+ total,
22
+ perPage = $bindable(10),
23
+ perPageOptions,
24
+ siblingCount = 1,
25
+ boundaryCount = 1,
26
+ showEdges = false,
27
+ showTotal = false,
28
+ variant = "primary",
29
+ size = "md",
30
+ disabled = false,
31
+ class: cls = "",
32
+ }: {
33
+ page?: number;
34
+ total: number;
35
+ perPage?: number;
36
+ perPageOptions?: number[];
37
+ siblingCount?: number;
38
+ boundaryCount?: number;
39
+ showEdges?: boolean;
40
+ showTotal?: boolean;
41
+ variant?: Variant;
42
+ size?: Responsive<Size>;
43
+ disabled?: boolean;
44
+ class?: string;
45
+ } = $props();
46
+
47
+ const pageCount = $derived(Math.max(1, Math.ceil((total || 0) / (perPage || 1))));
48
+ // Keep `page` in range as total/perPage change.
49
+ $effect(() => {
50
+ const clamped = Math.min(Math.max(page, 1), pageCount);
51
+ if (clamped !== page) page = clamped;
52
+ });
53
+
54
+ // Page-number list with ellipsis gaps: boundaryCount at each end + siblingCount around the current.
55
+ const items = $derived.by<(number | "ellipsis")[]>(() => {
56
+ const set = new Set<number>();
57
+ for (let i = 1; i <= boundaryCount; i++) {
58
+ set.add(i);
59
+ set.add(pageCount - i + 1);
60
+ }
61
+ for (let i = page - siblingCount; i <= page + siblingCount; i++) set.add(i);
62
+ const sorted = [...set].filter((n) => n >= 1 && n <= pageCount).sort((a, b) => a - b);
63
+ const out: (number | "ellipsis")[] = [];
64
+ let prev = 0;
65
+ for (const n of sorted) {
66
+ if (n - prev > 1) out.push("ellipsis");
67
+ out.push(n);
68
+ prev = n;
69
+ }
70
+ return out;
71
+ });
72
+
73
+ const rangeText = $derived(
74
+ total <= 0
75
+ ? "0 of 0"
76
+ : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, total)} of ${total}`,
77
+ );
78
+ const subtle = $derived(
79
+ `${responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" })} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]`,
80
+ );
81
+
82
+ const go = (to: number) => {
83
+ if (disabled) return;
84
+ page = Math.min(Math.max(to, 1), pageCount);
85
+ };
86
+ const setPerPage = (n: number) => {
87
+ perPage = n;
88
+ page = 1;
89
+ };
90
+ </script>
91
+
92
+ <nav aria-label="Pagination" class="flex flex-wrap items-center justify-between gap-3 {cls}">
93
+ {#if perPageOptions?.length}
94
+ <div class="flex items-center gap-2">
95
+ <Select
96
+ class="w-20"
97
+ {size}
98
+ {disabled}
99
+ value={String(perPage)}
100
+ options={perPageOptions.map((n) => ({ value: String(n), label: String(n) }))}
101
+ onSelection={(o) => setPerPage(Number(o.value))}
102
+ />
103
+ <span class={subtle}>per page</span>
104
+ </div>
105
+ {/if}
106
+
107
+ <div class="flex items-center gap-3">
108
+ {#if showTotal}<span class={subtle}>{rangeText}</span>{/if}
109
+ <div class="inline-flex items-center gap-0.5">
110
+ {#snippet navBtn(Icon: typeof IconFirst, to: number, off: boolean, aria: string)}
111
+ <Button
112
+ variant="ghost"
113
+ {size}
114
+ disabled={disabled || off}
115
+ aria-label={aria}
116
+ title={aria}
117
+ onclick={() => go(to)}
118
+ >
119
+ {#snippet icon()}<Icon />{/snippet}
120
+ </Button>
121
+ {/snippet}
122
+
123
+ {#if showEdges}{@render navBtn(IconFirst, 1, page <= 1, "First page")}{/if}
124
+ {@render navBtn(IconPrev, page - 1, page <= 1, "Previous page")}
125
+
126
+ {#each items as it, i (i)}
127
+ {#if it === "ellipsis"}
128
+ <span aria-hidden="true" class="px-1.5 {subtle}">…</span>
129
+ {:else}
130
+ <Button
131
+ variant={it === page ? variant : "ghost"}
132
+ {size}
133
+ {disabled}
134
+ aria-label={`Page ${it}`}
135
+ aria-current={it === page ? "page" : undefined}
136
+ onclick={() => go(it)}
137
+ >
138
+ {it}
139
+ </Button>
140
+ {/if}
141
+ {/each}
142
+
143
+ {@render navBtn(IconNext, page + 1, page >= pageCount, "Next page")}
144
+ {#if showEdges}{@render navBtn(IconLast, pageCount, page >= pageCount, "Last page")}{/if}
145
+ </div>
146
+ </div>
147
+ </nav>
@@ -0,0 +1,20 @@
1
+ import type { Variant } from "../../types/variants.js";
2
+ import type { Size } from "../../types/sizes.js";
3
+ import { type Responsive } from "../../types/responsive.js";
4
+ type $$ComponentProps = {
5
+ page?: number;
6
+ total: number;
7
+ perPage?: number;
8
+ perPageOptions?: number[];
9
+ siblingCount?: number;
10
+ boundaryCount?: number;
11
+ showEdges?: boolean;
12
+ showTotal?: boolean;
13
+ variant?: Variant;
14
+ size?: Responsive<Size>;
15
+ disabled?: boolean;
16
+ class?: string;
17
+ };
18
+ declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "page" | "perPage">;
19
+ type Pagination = ReturnType<typeof Pagination>;
20
+ export default Pagination;
@@ -0,0 +1 @@
1
+ export { default as Pagination } from "./Pagination.svelte";
@@ -0,0 +1 @@
1
+ export { default as Pagination } from "./Pagination.svelte";
@@ -36,6 +36,7 @@
36
36
  boundary = "viewport",
37
37
  offset = 4,
38
38
  margin = 8,
39
+ snap = 0,
39
40
  matchWidth = false,
40
41
  surface = false,
41
42
  variant,
@@ -43,6 +44,7 @@
43
44
  borderless = false,
44
45
  lightDismiss = true,
45
46
  onclose,
47
+ onplaced,
46
48
  children,
47
49
  }: {
48
50
  /** The trigger element to position against. */
@@ -56,6 +58,8 @@
56
58
  /** Minimum gap kept from the boundary edges, in px (default 8). Set `0` to let the box sit flush
57
59
  * against an edge — e.g. a sidebar popout that must align to the sidebar's (viewport) edge. */
58
60
  margin?: number;
61
+ /** Round the cross-axis shift to whole steps (px) — see `anchored`'s `snap`. Default 0. */
62
+ snap?: number;
59
63
  matchWidth?: boolean;
60
64
  /** Render a themed floating PANEL around the children (background, border, padding, shadow) instead
61
65
  * of the default bare positioner. `variant`/`size`/`borderless` configure that panel. Consumers
@@ -72,6 +76,9 @@
72
76
  * parent's top-layer element and would otherwise close its ancestors. */
73
77
  lightDismiss?: boolean;
74
78
  onclose?: () => void;
79
+ /** Fires after each (re)position with the resolved placement — for consumers that adapt to where
80
+ * the box actually landed (e.g. edge-flush borders). */
81
+ onplaced?: (p: Placement) => void;
75
82
  children: Snippet;
76
83
  } = $props();
77
84
 
@@ -143,8 +150,12 @@
143
150
  boundary,
144
151
  offset,
145
152
  margin,
153
+ snap,
146
154
  matchWidth,
147
- onplaced: (p) => (placed = p),
155
+ onplaced: (p) => {
156
+ placed = p;
157
+ onplaced?.(p);
158
+ },
148
159
  }}
149
160
  >
150
161
  {@render children()}
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from "svelte";
2
- import { type Side, type Align, type Boundary } from "../positioning/anchored.js";
2
+ import { type Side, type Align, type Boundary, type Placement } from "../positioning/anchored.js";
3
3
  import type { Size } from "../types/sizes.js";
4
4
  import { type Responsive } from "../types/responsive.js";
5
5
  import { type Variant } from "../types/variants.js";
@@ -15,6 +15,8 @@ type $$ComponentProps = {
15
15
  /** Minimum gap kept from the boundary edges, in px (default 8). Set `0` to let the box sit flush
16
16
  * against an edge — e.g. a sidebar popout that must align to the sidebar's (viewport) edge. */
17
17
  margin?: number;
18
+ /** Round the cross-axis shift to whole steps (px) — see `anchored`'s `snap`. Default 0. */
19
+ snap?: number;
18
20
  matchWidth?: boolean;
19
21
  /** Render a themed floating PANEL around the children (background, border, padding, shadow) instead
20
22
  * of the default bare positioner. `variant`/`size`/`borderless` configure that panel. Consumers
@@ -31,6 +33,9 @@ type $$ComponentProps = {
31
33
  * parent's top-layer element and would otherwise close its ancestors. */
32
34
  lightDismiss?: boolean;
33
35
  onclose?: () => void;
36
+ /** Fires after each (re)position with the resolved placement — for consumers that adapt to where
37
+ * the box actually landed (e.g. edge-flush borders). */
38
+ onplaced?: (p: Placement) => void;
34
39
  children: Snippet;
35
40
  };
36
41
  declare const Popup: import("svelte").Component<$$ComponentProps, {}, "open">;
@@ -126,6 +126,9 @@
126
126
  import IconMenu from "~icons/mdi/menu";
127
127
  import IconClose from "~icons/mdi/close";
128
128
  import IconChevronRight from "~icons/mdi/chevron-right";
129
+ import IconChevronUp from "~icons/mdi/chevron-up";
130
+ import IconChevronDown from "~icons/mdi/chevron-down";
131
+ import type { Placement } from "../../positioning/anchored.js";
129
132
  import type { Size } from "../../types/sizes.js";
130
133
  import { variantToken, type Variant } from "../../types/variants.js";
131
134
  import { BREAKPOINT_PX, type Breakpoint } from "../../types/breakpoints.js";
@@ -648,18 +651,62 @@
648
651
  function openOnHover(id: ItemId, isParent: boolean) {
649
652
  cancelClose();
650
653
  const target = isParent ? id : parentOf(id);
651
- if (openId !== target) openId = target;
654
+ if (openId !== target) {
655
+ if (target) popoutOffset[target] = 0; // fresh open → reset its scroll window
656
+ openId = target;
657
+ }
652
658
  }
653
659
  // Click toggles this node's popout: open it, or if it's already the deepest, collapse to its parent.
654
660
  function toggleOnClick(id: ItemId) {
655
661
  cancelClose();
656
- openId = openId === id ? parentOf(id) : id;
662
+ const next = openId === id ? parentOf(id) : id;
663
+ if (next && next !== openId) popoutOffset[next] = 0;
664
+ openId = next;
657
665
  }
658
666
 
659
667
  // ── Refs ────────────────────────────────────────────────────────
660
668
  let asideRef = $state<HTMLElement | undefined>();
669
+ // Live strip height — popouts window to this (their max-height + the shift boundary).
670
+ let asideH = $state(0);
661
671
  // One ref per parent trigger. Keyed by ItemId so Popup can anchor.
662
672
  const triggerRefs = $state<Record<string, HTMLElement | undefined>>({});
673
+ // Resolved placement per open popout (from Popup's `onplaced`) — drives the edge-flush border rule.
674
+ let placedById = $state<Record<string, Placement>>({});
675
+
676
+ // ── Windowed popout scroll ──────────────────────────────────────
677
+ // A popout taller than the strip shows only a window of its children with full-height ▲/▼ stepper
678
+ // rows (no native scrollbar). `popoutOffset[id]` is the window's start index. Steps are whole items:
679
+ // an arrow click pages (by the visible count), the wheel moves 3, ↑/↓ move 1.
680
+ let popoutOffset = $state<Record<ItemId, number>>({});
681
+ const rowPxMap: Record<Size, number> = { sm: 40, md: 48, lg: 56 };
682
+ const rowPx = $derived(rowPxMap[size]);
683
+ // Row slots that fit the strip height (≥1). Arrow rows each consume one slot.
684
+ const popoutCapacity = $derived(Math.max(1, Math.floor((asideH || 9999) / rowPx)));
685
+
686
+ function popoutWindow(id: ItemId, count: number) {
687
+ const cap = popoutCapacity;
688
+ if (count <= cap) return { up: false, down: false, start: 0, end: count, page: count };
689
+ const maxStart = Math.max(0, count - (cap - 1));
690
+ const start = Math.min(Math.max(popoutOffset[id] ?? 0, 0), maxStart);
691
+ const up = start > 0;
692
+ const upRows = up ? 1 : 0;
693
+ // Try without a down arrow; add one (costing a slot) only if items remain beyond the window.
694
+ let shown = cap - upRows;
695
+ let down = false;
696
+ if (start + shown < count) {
697
+ down = true;
698
+ shown = cap - upRows - 1;
699
+ }
700
+ shown = Math.min(shown, count - start);
701
+ return { up, down, start, end: start + shown, page: Math.max(1, shown) };
702
+ }
703
+ function stepPopout(id: ItemId, count: number, delta: number) {
704
+ const cap = popoutCapacity;
705
+ if (count <= cap) return;
706
+ const maxStart = Math.max(0, count - (cap - 1));
707
+ const cur = Math.min(Math.max(popoutOffset[id] ?? 0, 0), maxStart);
708
+ popoutOffset[id] = Math.min(Math.max(cur + delta, 0), maxStart);
709
+ }
663
710
 
664
711
  // ── Cascade dismiss ─────────────────────────────────────────────
665
712
  // The popouts run with `lightDismiss={false}` (a nested popout is "outside" its parent's top-layer
@@ -721,57 +768,44 @@
721
768
  };
722
769
  }
723
770
 
724
- // Spans rule — see SideBarMenu.spec.md → Popout Positioning.
725
- function spansEdge(group: "top" | "bottom", idx: number, total: number, count: number) {
726
- // positionFromEnd: how many rows from the anchored edge inclusive.
727
- const positionFromEnd = group === "bottom" ? total - idx : idx + 1;
728
- return count >= positionFromEnd;
729
- }
730
-
771
+ // Popout geometry — see SideBarMenu.spec.md → Popout Positioning.
772
+ // The popout anchors to its TRIGGER ROW: a top-group popout aligns its top edge to the row top and
773
+ // grows downward (`align: 'start'`); a bottom-group popout aligns its bottom edge to the row bottom
774
+ // and grows upward (`align: 'end'`). The `anchored` engine then shift-clamps it within the strip
775
+ // boundary, so a tall popout slides just far enough to keep its far edge inside the strip's top/
776
+ // bottom — it never detaches from the row. When it still overflows the strip it's height-capped and
777
+ // scrolls (per-row snap).
731
778
  function geometryFor(
732
779
  id: ItemId,
733
780
  group: "top" | "bottom",
734
- idx: number,
735
- total: number,
736
- count: number,
737
- ): {
738
- anchor: HTMLElement | undefined;
739
- align: "start" | "end";
740
- borderClass: string;
741
- } {
742
- const spans = spansEdge(group, idx, total, count);
743
- // Border policy:
744
- // - Right border is always needed (popout meets free content
745
- // area on the right).
746
- // - Top/bottom borders are needed only on edges that DON'T
747
- // coincide with the sidebar's edge. When `spans` is true,
748
- // the popout's edge on the anchored side is flush with the
749
- // sidebar's top/bottom that edge gets no border. The
750
- // opposite edge always extends into the sidebar's middle
751
- // vertical range, so it always gets a border.
752
- // - When `spans` is false the popout sits at the trigger row
753
- // level; both top and bottom edges are mid-sidebar and need
754
- // borders.
755
- // - Left border never the sidebar's own `border-right`
756
- // covers that seam.
757
- // NOTE: very tall popouts that would exceed the sidebar's
758
- // height get clamped to the viewport by the `anchored` engine
759
- // boundary. That case also needs in-popout scroll (TODO) and
760
- // the border rule may need to account for viewport-edge cases.
761
- let borderClass = dockRight ? "border-l" : "border-r";
762
- if (group === "bottom") {
763
- borderClass += spans ? " border-t" : " border-t border-b";
764
- } else {
765
- borderClass += spans ? " border-b" : " border-t border-b";
766
- }
767
- if (group === "bottom") {
768
- return spans
769
- ? { anchor: asideRef, align: "end", borderClass }
770
- : { anchor: triggerRefs[id], align: "start", borderClass };
771
- }
772
- return spans
773
- ? { anchor: asideRef, align: "start", borderClass }
774
- : { anchor: triggerRefs[id], align: "end", borderClass };
781
+ ): { anchor: HTMLElement | undefined; align: "start" | "end" } {
782
+ return { anchor: triggerRefs[id], align: group === "bottom" ? "end" : "start" };
783
+ }
784
+
785
+ // Edge-flush border rule (see SideBarMenu.spec.md → Popout Positioning). The seam edge (facing the
786
+ // strip) is never bordered. The NEAR edge (the one meeting the trigger row — top for top items,
787
+ // bottom for bottomItems) is bordered UNLESS it's flush with the strip's matching edge; the OPPOSITE
788
+ // edge is bordered unless it too is flush (only when the popout fills the whole strip). When the near
789
+ // border is present, a 1px `nudge` keeps the first/last row pixel-inline with the parent (a
790
+ // border-box top border would otherwise push content down 1px). Nested cascades float mid-content →
791
+ // always border both vertical edges + nudge.
792
+ function popoutBorder(id: ItemId, group: "top" | "bottom", top: boolean, rowsShown: number) {
793
+ const farH = dockRight ? "border-l" : "border-r";
794
+ const near = group === "bottom" ? "border-b" : "border-t";
795
+ const opp = group === "bottom" ? "border-t" : "border-b";
796
+ const nudge = group === "bottom" ? 1 : -1;
797
+ const placed = top ? placedById[id] : undefined;
798
+ const aside = top ? asideRef?.getBoundingClientRect() : undefined;
799
+ if (!placed || !aside) return { cls: `${farH} ${near} ${opp}`, nudge };
800
+ const h = rowsShown * rowPx;
801
+ const topFlush = placed.y <= aside.top + 1;
802
+ const bottomFlush = placed.y + h >= aside.bottom - 1;
803
+ const nearFlush = group === "bottom" ? bottomFlush : topFlush;
804
+ const oppFlush = group === "bottom" ? topFlush : bottomFlush;
805
+ return {
806
+ cls: `${farH}${nearFlush ? "" : ` ${near}`}${oppFlush ? "" : ` ${opp}`}`,
807
+ nudge: nearFlush ? 0 : nudge,
808
+ };
775
809
  }
776
810
  </script>
777
811
 
@@ -975,8 +1009,6 @@
975
1009
  item: SideBarMenuItem,
976
1010
  id: ItemId,
977
1011
  group: "top" | "bottom",
978
- idx: number,
979
- total: number,
980
1012
  reserveIcon: boolean,
981
1013
  depth: number,
982
1014
  )}
@@ -984,16 +1016,15 @@
984
1016
  {#if (((item.children?.length ?? 0) > 0) || !!item.popout) && !isStepped}
985
1017
  {@const top = depth === 0}
986
1018
  {@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. -->
1019
+ ? geometryFor(id, group)
1020
+ : { anchor: triggerRefs[id], align: "start" as const }}
1021
+ <!-- The popout anchors to its trigger row and flies out to the popout side; the anchored engine
1022
+ shift-clamps it within the viewport so it stays on-screen. Height is capped to the strip
1023
+ (`asideH`) so a tall menu scrolls (per-row snap) instead of spilling past the strip. (The
1024
+ viewport boundary — not the aside — is required because the popout is placed OUTSIDE the aside;
1025
+ for a full-height strip the two coincide.) `lightDismiss={false}` because a nested popout is
1026
+ "outside" its parent's top-layer element; the aside-level listener below dismisses the chain. -->
1027
+ {@const maxH = top && asideH ? `${asideH}px` : "calc(100vh - 0.5rem)"}
997
1028
  <Popup
998
1029
  anchor={geom.anchor}
999
1030
  open={isOnPath(id)}
@@ -1001,45 +1032,97 @@
1001
1032
  align={geom.align}
1002
1033
  offset={0}
1003
1034
  margin={top ? 0 : 8}
1035
+ snap={rowPx}
1004
1036
  lightDismiss={false}
1037
+ onplaced={top
1038
+ ? (p) => {
1039
+ placedById[id] = p;
1040
+ }
1041
+ : undefined}
1005
1042
  onclose={() => {
1006
1043
  if (isOnPath(id)) openId = null;
1007
1044
  }}
1008
1045
  >
1009
- {@render popoutPanel(item, id, group, geom.borderClass)}
1046
+ {@render popoutPanel(item, id, group, maxH)}
1010
1047
  </Popup>
1011
1048
  {/if}
1012
1049
  {/snippet}
1013
1050
 
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)}
1051
+ <!-- Full-height ▲/▼ stepper row shown at a popout's top/bottom when its children overflow the strip.
1052
+ Click pages by the visible count; the wheel (3) and ↑/↓ (1) on the panel also step. -->
1053
+ {#snippet arrowRow(id: ItemId, count: number, dir: 1 | -1)}
1054
+ <button
1055
+ type="button"
1056
+ aria-label={dir < 0 ? "Scroll up" : "Scroll down"}
1057
+ class="group flex {sz.row} w-full shrink-0 items-center justify-center transition-colors cursor-pointer [color:color-mix(in_srgb,var(--ui-color-text)_55%,transparent)] hover:[background-color:color-mix(in_srgb,var(--sbm-accent)_12%,transparent)] {sz.iconSvg}"
1058
+ onclick={() => stepPopout(id, count, dir * popoutWindow(id, count).page)}
1059
+ onmouseenter={popoutOnHover ? cancelClose : undefined}
1060
+ onmouseleave={popoutOnHover ? scheduleClose : undefined}
1061
+ >
1062
+ {#if dir < 0}<IconChevronUp />{:else}<IconChevronDown />{/if}
1063
+ </button>
1064
+ {/snippet}
1065
+
1066
+ <!-- Popout panel — the floating menu. Renders a custom `item.popout` snippet, else a WINDOW of the
1067
+ children as nested `menuNode`s (so a child that is itself a parent cascades further). When the
1068
+ children overflow the strip the window shows full-height ▲/▼ stepper rows instead of scrolling;
1069
+ the wheel and ↑/↓ also step (whole items). `data-sbm-popout` + `data-open` are consumer hooks. -->
1070
+ {#snippet popoutPanel(item: SideBarMenuItem, id: ItemId, group: "top" | "bottom", maxHeight: string)}
1018
1071
  {@const popoutId = popoutDomId(id)}
1019
1072
  {@const isOpen = isOnPath(id)}
1020
- <!-- svelte-ignore a11y_interactive_supports_focus -->
1073
+ {@const kids = item.children ?? []}
1074
+ {@const win = popoutWindow(id, kids.length)}
1075
+ {@const childReserve = anyIcon(kids)}
1076
+ {@const rowsShown = (win.up ? 1 : 0) + (win.end - win.start) + (win.down ? 1 : 0)}
1077
+ {@const bd = popoutBorder(id, group, !id.includes(">"), rowsShown)}
1078
+ <!-- svelte-ignore a11y_interactive_supports_focus a11y_no_noninteractive_element_interactions -->
1021
1079
  <div
1022
1080
  role="menu"
1023
1081
  tabindex="-1"
1024
1082
  id={popoutId}
1025
1083
  data-sbm-popout=""
1026
1084
  data-open={isOpen}
1027
- style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} --sbm-accent: {accentVar};"
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}"
1085
+ style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} max-height: {maxHeight}; --sbm-accent: {accentVar};{bd.nudge ? ` transform: translateY(${bd.nudge}px);` : ''}"
1086
+ 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)] {bd.cls}"
1029
1087
  onmouseenter={popoutOnHover ? cancelClose : undefined}
1030
1088
  onmouseleave={popoutOnHover ? scheduleClose : undefined}
1089
+ onwheel={item.popout
1090
+ ? undefined
1091
+ : (e) => {
1092
+ if (win.up || win.down) {
1093
+ e.preventDefault();
1094
+ stepPopout(id, kids.length, Math.sign(e.deltaY) * 3);
1095
+ }
1096
+ }}
1097
+ onkeydown={item.popout
1098
+ ? undefined
1099
+ : (e) => {
1100
+ if (e.key === "ArrowDown") {
1101
+ e.preventDefault();
1102
+ stepPopout(id, kids.length, 1);
1103
+ } else if (e.key === "ArrowUp") {
1104
+ e.preventDefault();
1105
+ stepPopout(id, kids.length, -1);
1106
+ }
1107
+ }}
1031
1108
  >
1032
1109
  {#if item.popout}
1033
- {@render item.popout()}
1110
+ <div
1111
+ class="overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1112
+ style="max-height: {maxHeight};"
1113
+ >
1114
+ {@render item.popout()}
1115
+ </div>
1034
1116
  {:else}
1035
- {@const childReserve = anyIcon(item.children ?? [])}
1117
+ {#if win.up}{@render arrowRow(id, kids.length, -1)}{/if}
1036
1118
  <ul class="flex flex-col">
1037
- {#each item.children ?? [] as child, ci (child.label)}
1119
+ {#each kids.slice(win.start, win.end) as child, i (child.label)}
1038
1120
  <li class="list-none" role="none">
1039
- {@render menuNode(child, `${id}>${ci}`, group, ci, item.children!.length, childReserve, 1)}
1121
+ {@render menuNode(child, `${id}>${win.start + i}`, group, childReserve, 1)}
1040
1122
  </li>
1041
1123
  {/each}
1042
1124
  </ul>
1125
+ {#if win.down}{@render arrowRow(id, kids.length, 1)}{/if}
1043
1126
  {/if}
1044
1127
  </div>
1045
1128
  {/snippet}
@@ -1081,6 +1164,7 @@
1081
1164
  >
1082
1165
  <aside
1083
1166
  bind:this={asideRef}
1167
+ bind:clientHeight={asideH}
1084
1168
  style="width: {asideWidthCss};{maxWidthCss ? ` max-width: ${maxWidthCss};` : ''} --sbm-accent: {accentVar};"
1085
1169
  onmouseenter={hoverExpandActive ? openHoverExpand : undefined}
1086
1170
  onmouseleave={hoverExpandActive ? scheduleHoverCollapse : undefined}
@@ -1121,7 +1205,7 @@
1121
1205
  <ul class="flex flex-col {scrollList}">
1122
1206
  {#each items as item, idx (item.label)}
1123
1207
  <li class="list-none">
1124
- {@render menuNode(item, `top:${idx}`, "top", idx, items.length, railReserve, 0)}
1208
+ {@render menuNode(item, `top:${idx}`, "top", railReserve, 0)}
1125
1209
  </li>
1126
1210
  {/each}
1127
1211
  </ul>
@@ -1137,7 +1221,7 @@
1137
1221
  <ul class="flex flex-col shrink-0">
1138
1222
  {#each bottomItems as item, idx (item.label)}
1139
1223
  <li class="list-none">
1140
- {@render menuNode(item, `bottom:${idx}`, "bottom", idx, bottomItems.length, railReserve, 0)}
1224
+ {@render menuNode(item, `bottom:${idx}`, "bottom", railReserve, 0)}
1141
1225
  </li>
1142
1226
  {/each}
1143
1227
  </ul>