@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.
- package/.claude/skills/svelte-ui/SKILL.md +13 -4
- package/dist/components/Avatar/Avatar.svelte +118 -0
- package/dist/components/Avatar/Avatar.svelte.d.ts +20 -0
- package/dist/components/Avatar/AvatarGroup.svelte +83 -0
- package/dist/components/Avatar/AvatarGroup.svelte.d.ts +24 -0
- package/dist/components/Avatar/index.d.ts +4 -0
- package/dist/components/Avatar/index.js +2 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.svelte +131 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.svelte.d.ts +19 -0
- package/dist/components/Breadcrumbs/index.d.ts +2 -0
- package/dist/components/Breadcrumbs/index.js +1 -0
- package/dist/components/ButtonGroup/ButtonGroup.svelte.d.ts +1 -1
- package/dist/components/Chip/Chip.svelte +139 -0
- package/dist/components/Chip/Chip.svelte.d.ts +22 -0
- package/dist/components/Chip/index.d.ts +1 -0
- package/dist/components/Chip/index.js +1 -0
- package/dist/components/Divider/Divider.svelte +48 -0
- package/dist/components/Divider/Divider.svelte.d.ts +11 -0
- package/dist/components/Divider/index.d.ts +1 -0
- package/dist/components/Divider/index.js +1 -0
- package/dist/components/Pagination/Pagination.svelte +147 -0
- package/dist/components/Pagination/Pagination.svelte.d.ts +20 -0
- package/dist/components/Pagination/index.d.ts +1 -0
- package/dist/components/Pagination/index.js +1 -0
- package/dist/components/Popup.svelte +12 -1
- package/dist/components/Popup.svelte.d.ts +6 -1
- package/dist/components/SideBarMenu/SideBarMenu.svelte +160 -76
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/positioning/anchored.d.ts +4 -0
- package/dist/positioning/anchored.js +19 -4
- package/docs/components.md +64 -0
- package/docs/usage.md +109 -0
- 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) =>
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
988
|
-
: {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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,
|
|
1046
|
+
{@render popoutPanel(item, id, group, maxH)}
|
|
1010
1047
|
</Popup>
|
|
1011
1048
|
{/if}
|
|
1012
1049
|
{/snippet}
|
|
1013
1050
|
|
|
1014
|
-
<!--
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
{@
|
|
1117
|
+
{#if win.up}{@render arrowRow(id, kids.length, -1)}{/if}
|
|
1036
1118
|
<ul class="flex flex-col">
|
|
1037
|
-
{#each
|
|
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}>${
|
|
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",
|
|
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",
|
|
1224
|
+
{@render menuNode(item, `bottom:${idx}`, "bottom", railReserve, 0)}
|
|
1141
1225
|
</li>
|
|
1142
1226
|
{/each}
|
|
1143
1227
|
</ul>
|