@spaethtech/svelte-ui 0.8.0 → 0.9.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.
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
import IconClose from "~icons/mdi/close";
|
|
106
106
|
import IconChevronRight from "~icons/mdi/chevron-right";
|
|
107
107
|
import type { Size } from "../../types/sizes.js";
|
|
108
|
+
import { variantToken, type Variant } from "../../types/variants.js";
|
|
108
109
|
import { BREAKPOINT_PX, type Breakpoint } from "../../types/breakpoints.js";
|
|
109
110
|
|
|
110
111
|
type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
|
|
@@ -114,15 +115,17 @@
|
|
|
114
115
|
bottomItems = [],
|
|
115
116
|
currentPath,
|
|
116
117
|
size = "md",
|
|
118
|
+
variant = "primary",
|
|
117
119
|
modes,
|
|
118
120
|
collapsedAt = "lg",
|
|
119
|
-
collapsedWidth
|
|
120
|
-
expandedWidth
|
|
121
|
+
collapsedWidth,
|
|
122
|
+
expandedWidth,
|
|
121
123
|
open = $bindable(false),
|
|
122
124
|
stepLevel = $bindable(0),
|
|
123
125
|
hamburger = true,
|
|
124
126
|
iconHoverExpand = false,
|
|
125
127
|
hoverExpandCloseMs = 200,
|
|
128
|
+
interactMode,
|
|
126
129
|
side = "left",
|
|
127
130
|
api = $bindable(),
|
|
128
131
|
activeMode = $bindable(),
|
|
@@ -132,6 +135,12 @@
|
|
|
132
135
|
bottomItems?: SideBarMenuItem[];
|
|
133
136
|
currentPath: string;
|
|
134
137
|
size?: Size;
|
|
138
|
+
/**
|
|
139
|
+
* Colour axis — affects ONLY the row hover/open/active background. The active row fills with the
|
|
140
|
+
* variant's accent token (`--ui-color-*`, white text); hover/open rows get a soft mix of it.
|
|
141
|
+
* Everything else (icons, labels, borders) stays neutral. Default `primary`.
|
|
142
|
+
*/
|
|
143
|
+
variant?: Variant;
|
|
135
144
|
/**
|
|
136
145
|
* Per-tier visual mode. Each key in this map sets the mode at and
|
|
137
146
|
* above that breakpoint. The active mode at any viewport is the
|
|
@@ -150,9 +159,13 @@
|
|
|
150
159
|
* `modes` is set explicitly. Prefer passing `modes` directly.
|
|
151
160
|
*/
|
|
152
161
|
collapsedAt?: Exclude<Breakpoint, "base" | "4xs" | "3xs" | "2xs" | "xs">;
|
|
153
|
-
/** Pixel width of the strip in `icon` mode.
|
|
162
|
+
/** Pixel width of the strip in `icon` mode. Defaults to the `size`'s square icon-box width
|
|
163
|
+
* (`sm` 40 · `md` 48 · `lg` 56) so icons are never cramped; override for a custom rail width. */
|
|
154
164
|
collapsedWidth?: number;
|
|
155
|
-
/**
|
|
165
|
+
/** Optional MAX width (px) of the strip in `expanded`/`drawer`/hover-expanded state. The strip
|
|
166
|
+
* always sizes to its content (`max-content` — fits the widest icon + label + chevron so every
|
|
167
|
+
* row aligns); set this to cap that width so an unusually long label truncates instead of blowing
|
|
168
|
+
* the rail out. Unset (default) = no cap, fit the longest label fully. */
|
|
156
169
|
expandedWidth?: number;
|
|
157
170
|
/**
|
|
158
171
|
* Drawer open state when the active mode is `drawer` or `stepped`.
|
|
@@ -180,6 +193,14 @@
|
|
|
180
193
|
iconHoverExpand?: boolean;
|
|
181
194
|
/** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
|
|
182
195
|
hoverExpandCloseMs?: number;
|
|
196
|
+
/**
|
|
197
|
+
* How parent popouts open. `"hover"` opens on mouse-enter and closes shortly after mouse-leave;
|
|
198
|
+
* `"click"` toggles on click and stays open until an outside click / Escape / re-click (mouse-out
|
|
199
|
+
* does NOT close it). When unset (default), it auto-resolves: a plain `icon` rail
|
|
200
|
+
* (`iconHoverExpand={false}`) is compact, so it uses `"click"`; every other popout mode uses
|
|
201
|
+
* `"hover"`. `stepped` ignores this (parents step in place). Set it to force one mode everywhere.
|
|
202
|
+
*/
|
|
203
|
+
interactMode?: "hover" | "click";
|
|
183
204
|
/** Which edge the sidebar docks to. `"right"` mirrors everything: the frame border, the drawer
|
|
184
205
|
* slide direction, the hamburger corner, popout side + border, and item layout (icon on the
|
|
185
206
|
* right, labels right-aligned). Default `"left"`. */
|
|
@@ -308,14 +329,40 @@
|
|
|
308
329
|
// Labels show in every mode except a collapsed (not hover-expanded) icon rail.
|
|
309
330
|
const labelsVisible = $derived(currentMode !== "icon" || effectivelyExpanded);
|
|
310
331
|
|
|
311
|
-
//
|
|
332
|
+
// Collapsed (icon-only) rail width. Defaults to the size's SQUARE icon-box width so the rail is
|
|
333
|
+
// exactly as wide as the icon box is tall (sm 40 · md 48 · lg 56) — no dead space, no clipping.
|
|
334
|
+
const collapsedW = $derived(collapsedWidth ?? ({ sm: 40, md: 48, lg: 56 } as const)[size]);
|
|
335
|
+
|
|
336
|
+
// Variant accent for row hover/open/active backgrounds. Published as a `--sbm-accent` custom property
|
|
337
|
+
// on the aside + popout (so rows read a STATIC Tailwind class and Tailwind still tree-shakes it —
|
|
338
|
+
// interpolating the token straight into the class name would make it invisible to the JIT scanner).
|
|
339
|
+
const accentVar = $derived(`var(${variantToken[variant]})`);
|
|
340
|
+
|
|
341
|
+
// Interim overflow handling: a group taller than the available height scrolls, but with the scrollbar
|
|
342
|
+
// hidden ("silent scroll") — a native scrollbar in a narrow icon rail looks wrong. `flex-1 min-h-0`
|
|
343
|
+
// lets the group take the remaining height and actually scroll; the bottom cluster stays pinned.
|
|
344
|
+
// (A per-section stepper-button affordance is the planned replacement — see FUTURE.md.)
|
|
345
|
+
const scrollList = "flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
|
|
346
|
+
|
|
347
|
+
// Aside width, driven by mode (inline style — robust, no Tailwind arbitrary-var class):
|
|
348
|
+
// - stepped: full width (100%)
|
|
349
|
+
// - collapsed icon rail: the square icon-box width
|
|
350
|
+
// - expanded / drawer / hover-expanded rail: `max-content` so the strip fits the widest row
|
|
351
|
+
// (icon + longest label + chevron) exactly, keeping every row's icon/label/chevron aligned.
|
|
352
|
+
// `expandedWidth` becomes an optional MAX so a very long label truncates instead of blowing out.
|
|
353
|
+
const contentFit = $derived(
|
|
354
|
+
currentMode === "drawer" ||
|
|
355
|
+
currentMode === "expanded" ||
|
|
356
|
+
(currentMode === "icon" && effectivelyExpanded),
|
|
357
|
+
);
|
|
312
358
|
const asideWidthCss = $derived(
|
|
313
359
|
currentMode === "stepped"
|
|
314
360
|
? "100%"
|
|
315
361
|
: currentMode === "icon" && !effectivelyExpanded
|
|
316
|
-
? `${
|
|
317
|
-
:
|
|
362
|
+
? `${collapsedW}px`
|
|
363
|
+
: "max-content",
|
|
318
364
|
);
|
|
365
|
+
const maxWidthCss = $derived(contentFit && expandedWidth != null ? `${expandedWidth}px` : "");
|
|
319
366
|
// Slide (drawer/stepped) or width (icon hover-expand) transition — suppressed on a mode change (see
|
|
320
367
|
// `noTransition`) so resizing between modes snaps rather than animating a wrong intermediate state.
|
|
321
368
|
const transitionClass = $derived(
|
|
@@ -477,6 +524,15 @@
|
|
|
477
524
|
// noise — suppress it. (Per-row `disabled` also drops the tooltip while that parent's popout is open.)
|
|
478
525
|
const tooltipSuppressed = $derived(labelsVisible || hoverExpandActive);
|
|
479
526
|
|
|
527
|
+
// Popout trigger. An explicit `interactMode` wins; otherwise auto — a plain (non-hover-expand) `icon`
|
|
528
|
+
// rail is a deliberately compact target, so it opens popouts on CLICK (click toggles; it does not
|
|
529
|
+
// navigate; mouse-out does not close), while every other popout mode opens on HOVER. `stepped` never
|
|
530
|
+
// uses popouts (it steps in place).
|
|
531
|
+
const resolvedInteract = $derived<"hover" | "click">(
|
|
532
|
+
interactMode ?? (currentMode === "icon" && !iconHoverExpand ? "click" : "hover"),
|
|
533
|
+
);
|
|
534
|
+
const popoutOnHover = $derived(!isStepped && resolvedInteract === "hover");
|
|
535
|
+
|
|
480
536
|
// ── Active state ────────────────────────────────────────────────
|
|
481
537
|
// Match on a SEGMENT boundary, not a raw prefix: a link is active when the current path equals it
|
|
482
538
|
// or is nested under it (`/x` matches `/x/y` but not `/xyz`). `/` (a Home/root link) matches ONLY
|
|
@@ -667,7 +723,8 @@
|
|
|
667
723
|
{@const hasLink = !!item.link}
|
|
668
724
|
{@const isOpen = !!opts.id && openId === opts.id}
|
|
669
725
|
{@const popoutId = opts.id ? "sbm-popout-" + opts.id.replace(":", "-") : undefined}
|
|
670
|
-
{@const
|
|
726
|
+
{@const tipSuppressed = tooltipSuppressed || !!opts.isPopoutChild}
|
|
727
|
+
{@const rowClass = `group flex items-center w-full ${sz.row} ${dockRight ? "flex-row-reverse" : ""} ${labelsVisible || opts.isPopoutChild ? (dockRight ? "pl-3" : "pr-3") : ""} transition-colors cursor-pointer ${active ? "[background-color:var(--sbm-accent)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--sbm-accent)_18%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--sbm-accent)_12%,transparent)]"}`}
|
|
671
728
|
{#if hasLink}
|
|
672
729
|
<a
|
|
673
730
|
href={item.link}
|
|
@@ -681,10 +738,10 @@
|
|
|
681
738
|
use:tooltip={{
|
|
682
739
|
text: item.label,
|
|
683
740
|
side: popoutSide,
|
|
684
|
-
disabled:
|
|
741
|
+
disabled: tipSuppressed || (isParent && opts.id ? openId === opts.id : false),
|
|
685
742
|
}}
|
|
686
|
-
onmouseenter={isParent && opts.id &&
|
|
687
|
-
onmouseleave={isParent && opts.id &&
|
|
743
|
+
onmouseenter={isParent && opts.id && popoutOnHover ? () => openOnHover(opts.id!) : undefined}
|
|
744
|
+
onmouseleave={isParent && opts.id && popoutOnHover ? scheduleClose : undefined}
|
|
688
745
|
onclick={(e) => {
|
|
689
746
|
// Power-user passthrough: Shift/Cmd/Ctrl click opens in
|
|
690
747
|
// a new tab — don't mutate UI state in that case.
|
|
@@ -697,6 +754,9 @@
|
|
|
697
754
|
e.preventDefault();
|
|
698
755
|
stepIntoChildren(item);
|
|
699
756
|
} else {
|
|
757
|
+
// When popouts are click-to-open (a plain icon rail), a click opens the popout
|
|
758
|
+
// instead of following the parent's href.
|
|
759
|
+
if (!popoutOnHover) e.preventDefault();
|
|
700
760
|
toggleOnClick(opts.id);
|
|
701
761
|
}
|
|
702
762
|
} else {
|
|
@@ -717,9 +777,9 @@
|
|
|
717
777
|
aria-haspopup="menu"
|
|
718
778
|
aria-expanded={openId === opts.id}
|
|
719
779
|
aria-controls={isOpen ? popoutId : undefined}
|
|
720
|
-
use:tooltip={{ text: item.label, side: popoutSide, disabled:
|
|
721
|
-
onmouseenter={
|
|
722
|
-
onmouseleave={
|
|
780
|
+
use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || openId === opts.id }}
|
|
781
|
+
onmouseenter={popoutOnHover ? () => openOnHover(opts.id!) : undefined}
|
|
782
|
+
onmouseleave={popoutOnHover ? scheduleClose : undefined}
|
|
723
783
|
onclick={() => (isStepped ? stepIntoChildren(item) : toggleOnClick(opts.id!))}
|
|
724
784
|
>
|
|
725
785
|
{@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
|
|
@@ -731,7 +791,7 @@
|
|
|
731
791
|
type="button"
|
|
732
792
|
use:refIntoMap={opts.id}
|
|
733
793
|
class="{rowClass} text-left"
|
|
734
|
-
use:tooltip={{ text: item.label, side: popoutSide, disabled:
|
|
794
|
+
use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed }}
|
|
735
795
|
onclick={(e) => {
|
|
736
796
|
item.onclick!(e);
|
|
737
797
|
closeAfterNav();
|
|
@@ -758,10 +818,10 @@
|
|
|
758
818
|
role="menu"
|
|
759
819
|
tabindex="-1"
|
|
760
820
|
id={popoutId}
|
|
761
|
-
style="width: {expandedWidth}px;"
|
|
821
|
+
style="width: max-content;{expandedWidth != null ? ` max-width: ${expandedWidth}px;` : ''} --sbm-accent: {accentVar};"
|
|
762
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}"
|
|
763
|
-
onmouseenter={cancelClose}
|
|
764
|
-
onmouseleave={scheduleClose}
|
|
823
|
+
onmouseenter={popoutOnHover ? cancelClose : undefined}
|
|
824
|
+
onmouseleave={popoutOnHover ? scheduleClose : undefined}
|
|
765
825
|
>
|
|
766
826
|
{#if item.popout}
|
|
767
827
|
{@render item.popout()}
|
|
@@ -810,11 +870,11 @@
|
|
|
810
870
|
never the viewport). In every other mode it's `display: contents` (no box) so layout is unchanged. -->
|
|
811
871
|
<div
|
|
812
872
|
class={hoverExpandActive ? "relative shrink-0" : "contents"}
|
|
813
|
-
style="{hoverExpandActive ? `width: ${
|
|
873
|
+
style="{hoverExpandActive ? `width: ${collapsedW}px;` : ''}"
|
|
814
874
|
>
|
|
815
875
|
<aside
|
|
816
876
|
bind:this={asideRef}
|
|
817
|
-
style="width: {asideWidthCss};"
|
|
877
|
+
style="width: {asideWidthCss};{maxWidthCss ? ` max-width: ${maxWidthCss};` : ''} --sbm-accent: {accentVar};"
|
|
818
878
|
onmouseenter={hoverExpandActive ? openHoverExpand : undefined}
|
|
819
879
|
onmouseleave={hoverExpandActive ? scheduleHoverCollapse : undefined}
|
|
820
880
|
class="
|
|
@@ -840,7 +900,7 @@
|
|
|
840
900
|
expected to flip to a `←` icon and set `stepLevel = 0` to
|
|
841
901
|
return here — there is no in-drawer back row by design so
|
|
842
902
|
the back affordance stays in a single, consistent spot. -->
|
|
843
|
-
<ul class="flex flex-col">
|
|
903
|
+
<ul class="flex flex-col {scrollList}">
|
|
844
904
|
{#each currentParent.children ?? [] as child (child.label)}
|
|
845
905
|
<li class="list-none">
|
|
846
906
|
{@render row(child, { isPopoutChild: true })}
|
|
@@ -849,7 +909,7 @@
|
|
|
849
909
|
</ul>
|
|
850
910
|
{:else}
|
|
851
911
|
{#if items.length > 0}
|
|
852
|
-
<ul class="flex flex-col">
|
|
912
|
+
<ul class="flex flex-col {scrollList}">
|
|
853
913
|
{#each items as item, idx (item.label)}
|
|
854
914
|
{@const id = `top:${idx}` as ItemId}
|
|
855
915
|
<li class="list-none">
|
|
@@ -875,12 +935,14 @@
|
|
|
875
935
|
</ul>
|
|
876
936
|
{/if}
|
|
877
937
|
|
|
878
|
-
|
|
938
|
+
<!-- With items present, the scrollable top list (flex-1) already fills the gap; only add a spacer
|
|
939
|
+
to push the bottom cluster down when there are no top items. -->
|
|
940
|
+
{#if items.length === 0 && bottomItems.length > 0}
|
|
879
941
|
<div class="flex-1"></div>
|
|
880
942
|
{/if}
|
|
881
943
|
|
|
882
944
|
{#if bottomItems.length > 0}
|
|
883
|
-
<ul class="flex flex-col">
|
|
945
|
+
<ul class="flex flex-col shrink-0">
|
|
884
946
|
{#each bottomItems as item, idx (item.label)}
|
|
885
947
|
{@const id = `bottom:${idx}` as ItemId}
|
|
886
948
|
<li class="list-none">
|
|
@@ -72,6 +72,7 @@ export type SideBarMenuItem = {
|
|
|
72
72
|
reload?: boolean;
|
|
73
73
|
};
|
|
74
74
|
import type { Size } from "../../types/sizes.js";
|
|
75
|
+
import { type Variant } from "../../types/variants.js";
|
|
75
76
|
import { type Breakpoint } from "../../types/breakpoints.js";
|
|
76
77
|
type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
|
|
77
78
|
type $$ComponentProps = {
|
|
@@ -79,6 +80,12 @@ type $$ComponentProps = {
|
|
|
79
80
|
bottomItems?: SideBarMenuItem[];
|
|
80
81
|
currentPath: string;
|
|
81
82
|
size?: Size;
|
|
83
|
+
/**
|
|
84
|
+
* Colour axis — affects ONLY the row hover/open/active background. The active row fills with the
|
|
85
|
+
* variant's accent token (`--ui-color-*`, white text); hover/open rows get a soft mix of it.
|
|
86
|
+
* Everything else (icons, labels, borders) stays neutral. Default `primary`.
|
|
87
|
+
*/
|
|
88
|
+
variant?: Variant;
|
|
82
89
|
/**
|
|
83
90
|
* Per-tier visual mode. Each key in this map sets the mode at and
|
|
84
91
|
* above that breakpoint. The active mode at any viewport is the
|
|
@@ -97,9 +104,13 @@ type $$ComponentProps = {
|
|
|
97
104
|
* `modes` is set explicitly. Prefer passing `modes` directly.
|
|
98
105
|
*/
|
|
99
106
|
collapsedAt?: Exclude<Breakpoint, "base" | "4xs" | "3xs" | "2xs" | "xs">;
|
|
100
|
-
/** Pixel width of the strip in `icon` mode.
|
|
107
|
+
/** Pixel width of the strip in `icon` mode. Defaults to the `size`'s square icon-box width
|
|
108
|
+
* (`sm` 40 · `md` 48 · `lg` 56) so icons are never cramped; override for a custom rail width. */
|
|
101
109
|
collapsedWidth?: number;
|
|
102
|
-
/**
|
|
110
|
+
/** Optional MAX width (px) of the strip in `expanded`/`drawer`/hover-expanded state. The strip
|
|
111
|
+
* always sizes to its content (`max-content` — fits the widest icon + label + chevron so every
|
|
112
|
+
* row aligns); set this to cap that width so an unusually long label truncates instead of blowing
|
|
113
|
+
* the rail out. Unset (default) = no cap, fit the longest label fully. */
|
|
103
114
|
expandedWidth?: number;
|
|
104
115
|
/**
|
|
105
116
|
* Drawer open state when the active mode is `drawer` or `stepped`.
|
|
@@ -127,6 +138,14 @@ type $$ComponentProps = {
|
|
|
127
138
|
iconHoverExpand?: boolean;
|
|
128
139
|
/** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
|
|
129
140
|
hoverExpandCloseMs?: number;
|
|
141
|
+
/**
|
|
142
|
+
* How parent popouts open. `"hover"` opens on mouse-enter and closes shortly after mouse-leave;
|
|
143
|
+
* `"click"` toggles on click and stays open until an outside click / Escape / re-click (mouse-out
|
|
144
|
+
* does NOT close it). When unset (default), it auto-resolves: a plain `icon` rail
|
|
145
|
+
* (`iconHoverExpand={false}`) is compact, so it uses `"click"`; every other popout mode uses
|
|
146
|
+
* `"hover"`. `stepped` ignores this (parents step in place). Set it to force one mode everywhere.
|
|
147
|
+
*/
|
|
148
|
+
interactMode?: "hover" | "click";
|
|
130
149
|
/** Which edge the sidebar docks to. `"right"` mirrors everything: the frame border, the drawer
|
|
131
150
|
* slide direction, the hamburger corner, popout side + border, and item layout (icon on the
|
|
132
151
|
* right, labels right-aligned). Default `"left"`. */
|