@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 = 48,
120
- expandedWidth = 200,
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
- /** Pixel width of the strip in `expanded` mode and the slide-in drawer when open. */
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
- // Aside width in px, driven by mode (inline style robust, no Tailwind arbitrary-var class).
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
- ? `${collapsedWidth}px`
317
- : `${expandedWidth}px`,
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 rowClass = `group flex items-center w-full ${sz.row} ${dockRight ? "flex-row-reverse lg:pl-3" : "lg:pr-3"} transition-colors cursor-pointer ${active ? "[background-color:var(--ui-color-primary)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"}`}
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: tooltipSuppressed || (isParent && opts.id ? openId === opts.id : false),
741
+ disabled: tipSuppressed || (isParent && opts.id ? openId === opts.id : false),
685
742
  }}
686
- onmouseenter={isParent && opts.id && !isStepped ? () => openOnHover(opts.id!) : undefined}
687
- onmouseleave={isParent && opts.id && !isStepped ? scheduleClose : undefined}
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: tooltipSuppressed || openId === opts.id }}
721
- onmouseenter={!isStepped ? () => openOnHover(opts.id!) : undefined}
722
- onmouseleave={!isStepped ? scheduleClose : undefined}
780
+ use:tooltip={{ text: item.label, side: popoutSide, disabled: tipSuppressed || openId === opts.id }}
781
+ onmouseenter={popoutOnHover ? () => openOnHover(opts.id!) : undefined}
782
+ onmouseleave={popoutOnHover ? scheduleClose : undefined}
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: tooltipSuppressed }}
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: ${collapsedWidth}px;` : ''}"
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
- {#if items.length > 0 && bottomItems.length > 0}
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
- /** Pixel width of the strip in `expanded` mode and the slide-in drawer when open. */
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"`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"