@spaethtech/svelte-ui 0.7.1-dev.42.aca96e0 → 0.7.1-dev.44.97afb0d

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.
@@ -121,6 +121,9 @@
121
121
  open = $bindable(false),
122
122
  stepLevel = $bindable(0),
123
123
  hamburger = true,
124
+ iconHoverExpand = false,
125
+ hoverExpandCloseMs = 200,
126
+ side = "left",
124
127
  api = $bindable(),
125
128
  class: additionalClass = "",
126
129
  }: {
@@ -171,6 +174,15 @@
171
174
  * and binds to `open`.
172
175
  */
173
176
  hamburger?: boolean;
177
+ /** In `icon` mode, expand the rail to `expanded` ON HOVER — as a floating overlay over a spacer
178
+ * that reserves the collapsed width, so page content never reflows. Default `false`. */
179
+ iconHoverExpand?: boolean;
180
+ /** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
181
+ hoverExpandCloseMs?: number;
182
+ /** Which edge the sidebar docks to. `"right"` mirrors everything: the frame border, the drawer
183
+ * slide direction, the hamburger corner, popout side + border, and item layout (icon on the
184
+ * right, labels right-aligned). Default `"left"`. */
185
+ side?: "left" | "right";
174
186
  /** Imperative handle (bindable). Call `api.reset()` from your router's navigation lifecycle
175
187
  * (SvelteKit `afterNavigate`, etc.) so a programmatic `goto()`/back-button closes the drawer +
176
188
  * any open popout and returns a stepped drawer to the top. */
@@ -224,7 +236,45 @@
224
236
 
225
237
  const isStepped = $derived(currentMode === "stepped");
226
238
  const isDrawer = $derived(currentMode === "drawer" || currentMode === "stepped");
227
- const labelsVisible = $derived(currentMode !== "icon");
239
+ // Dock side. `right` mirrors the frame, drawer slide, hamburger corner, popout side, and item layout.
240
+ const dockRight = $derived(side === "right");
241
+ const popoutSide = $derived<"left" | "right">(dockRight ? "left" : "right");
242
+ const dockEdge = $derived(dockRight ? "right-0" : "left-0");
243
+ const dockOff = $derived(dockRight ? "translate-x-full" : "-translate-x-full");
244
+ const dockBorder = $derived(
245
+ dockRight
246
+ ? "[border-left:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]"
247
+ : "[border-right:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]",
248
+ );
249
+
250
+ // ── Icon-mode hover-expand ──────────────────────────────────────
251
+ // When enabled + in `icon` mode, hovering the rail floats it out to the `expanded` render OVER a
252
+ // spacer that reserves the collapsed width — page content never reflows. Exit is delayed.
253
+ let hoverExpanded = $state(false);
254
+ let hoverExpandTimer: ReturnType<typeof setTimeout> | null = null;
255
+ const hoverExpandActive = $derived(iconHoverExpand && currentMode === "icon");
256
+ const effectivelyExpanded = $derived(hoverExpandActive && hoverExpanded);
257
+ function openHoverExpand() {
258
+ if (hoverExpandTimer) {
259
+ clearTimeout(hoverExpandTimer);
260
+ hoverExpandTimer = null;
261
+ }
262
+ if (hoverExpandActive) hoverExpanded = true;
263
+ }
264
+ function scheduleHoverCollapse() {
265
+ if (hoverExpandTimer) clearTimeout(hoverExpandTimer);
266
+ hoverExpandTimer = setTimeout(() => {
267
+ hoverExpanded = false;
268
+ openId = null;
269
+ hoverExpandTimer = null;
270
+ }, hoverExpandCloseMs);
271
+ }
272
+ $effect(() => {
273
+ if (!hoverExpandActive) hoverExpanded = false;
274
+ });
275
+
276
+ // Labels show in every mode except a collapsed (not hover-expanded) icon rail.
277
+ const labelsVisible = $derived(currentMode !== "icon" || effectivelyExpanded);
228
278
 
229
279
  // ── DEV depth warning ───────────────────────────────────────────
230
280
  if (DEV) {
@@ -470,7 +520,7 @@
470
520
  // height get clamped to the viewport by the `anchored` engine
471
521
  // boundary. That case also needs in-popout scroll (TODO) and
472
522
  // the border rule may need to account for viewport-edge cases.
473
- let borderClass = "border-r";
523
+ let borderClass = dockRight ? "border-l" : "border-r";
474
524
  if (group === "bottom") {
475
525
  borderClass += spans ? " border-t" : " border-t border-b";
476
526
  } else {
@@ -529,7 +579,9 @@
529
579
  what `truncate` (overflow-hidden + text-ellipsis) needs to
530
580
  actually clip overflow. Without `min-w-0` the label refuses
531
581
  to shrink past its natural width and the row overflows. -->
532
- <span class="flex-1 min-w-0 truncate {labelClassResolved} {sz.text}">{item.label}</span>
582
+ <span class="flex-1 min-w-0 truncate {dockRight ? 'text-right' : ''} {labelClassResolved} {sz.text}"
583
+ >{item.label}</span
584
+ >
533
585
  {#if isParent}
534
586
  <!-- Chevron — visible only when the label is visible (no point
535
587
  dangling a `>` next to an icon-only icon). Rotates 180° to
@@ -555,7 +607,8 @@
555
607
  {@const isParent = (hasChildren || !!item.popout) && !opts.isPopoutChild}
556
608
  {@const hasLink = !!item.link}
557
609
  {@const isOpen = !!opts.id && openId === opts.id}
558
- {@const rowClass = `group flex items-center w-full ${sz.row} 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)]"}`}
610
+ {@const popoutId = opts.id ? "sbm-popout-" + opts.id.replace(":", "-") : undefined}
611
+ {@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)]"}`}
559
612
  {#if hasLink}
560
613
  <a
561
614
  href={item.link}
@@ -563,6 +616,9 @@
563
616
  class={rowClass}
564
617
  data-sveltekit-reload={item.reload ? "" : undefined}
565
618
  aria-current={ownActive ? "page" : undefined}
619
+ aria-haspopup={isParent ? "menu" : undefined}
620
+ aria-expanded={isParent && opts.id ? isOpen : undefined}
621
+ aria-controls={isParent && isOpen ? popoutId : undefined}
566
622
  use:tooltip={{
567
623
  text: item.label,
568
624
  side: "right",
@@ -601,6 +657,7 @@
601
657
  class="{rowClass} text-left"
602
658
  aria-haspopup="menu"
603
659
  aria-expanded={openId === opts.id}
660
+ aria-controls={isOpen ? popoutId : undefined}
604
661
  use:tooltip={{ text: item.label, side: "right", disabled: openId === opts.id }}
605
662
  onmouseenter={!isStepped ? () => openOnHover(opts.id!) : undefined}
606
663
  onmouseleave={!isStepped ? scheduleClose : undefined}
@@ -636,11 +693,12 @@
636
693
  mirrors main strip styling. `borderClass` is computed per popout
637
694
  by `geometryFor` so the visible edges get borders and the edges
638
695
  that coincide with the sidebar's top/bottom don't. -->
639
- {#snippet popout(item: SideBarMenuItem, borderClass: string)}
696
+ {#snippet popout(item: SideBarMenuItem, borderClass: string, popoutId: string)}
640
697
  <!-- svelte-ignore a11y_interactive_supports_focus -->
641
698
  <div
642
699
  role="menu"
643
700
  tabindex="-1"
701
+ id={popoutId}
644
702
  class="w-[var(--sbm-expanded-w)] 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}"
645
703
  onmouseenter={cancelClose}
646
704
  onmouseleave={scheduleClose}
@@ -665,7 +723,9 @@
665
723
  to suppress this when the consumer provides its own trigger. -->
666
724
  <button
667
725
  type="button"
668
- class="fixed top-3 left-3 z-[80] inline-flex w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
726
+ class="fixed top-3 {dockRight
727
+ ? 'right-3'
728
+ : 'left-3'} z-[80] inline-flex w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
669
729
  aria-label={open ? "Close menu" : "Open menu"}
670
730
  aria-expanded={open}
671
731
  onclick={() => (open = !open)}
@@ -685,24 +745,34 @@
685
745
  ></div>
686
746
  {/if}
687
747
 
688
- <aside
689
- bind:this={asideRef}
748
+ <!-- Root wrapper. In icon hover-expand it becomes a `relative` spacer reserving the collapsed rail
749
+ width, so the expanded float below is positioned against the SIDEBAR's own box (container-relative,
750
+ never the viewport). In every other mode it's `display: contents` (no box) so layout is unchanged. -->
751
+ <div
752
+ class={hoverExpandActive ? "relative shrink-0 [width:var(--sbm-collapsed-w)]" : "contents"}
690
753
  style="--sbm-collapsed-w: {collapsedWidth}px; --sbm-expanded-w: {expandedWidth}px;"
691
- class="
754
+ >
755
+ <aside
756
+ bind:this={asideRef}
757
+ onmouseenter={hoverExpandActive ? openHoverExpand : undefined}
758
+ onmouseleave={hoverExpandActive ? scheduleHoverCollapse : undefined}
759
+ class="
692
760
  flex flex-col z-[60] overflow-hidden
693
761
  [background-color:var(--ui-color-background)]
694
762
  [color:var(--ui-color-text)]
695
- [border-right:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]
763
+ {dockBorder}
696
764
  {currentMode === 'stepped'
697
- ? `absolute inset-y-0 left-0 w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
698
- : currentMode === 'drawer'
699
- ? `absolute inset-y-0 left-0 w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
700
- : currentMode === 'icon'
701
- ? 'relative w-[var(--sbm-collapsed-w)]'
702
- : 'relative w-[var(--sbm-expanded-w)]'}
765
+ ? `absolute inset-y-0 ${dockEdge} w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : dockOff}`
766
+ : currentMode === 'drawer'
767
+ ? `absolute inset-y-0 ${dockEdge} w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : dockOff}`
768
+ : currentMode === 'icon'
769
+ ? hoverExpandActive
770
+ ? `absolute inset-y-0 ${dockEdge} z-[65] transition-[width] duration-200 ${effectivelyExpanded ? 'w-[var(--sbm-expanded-w)] shadow-lg' : 'w-[var(--sbm-collapsed-w)]'}`
771
+ : 'relative w-[var(--sbm-collapsed-w)]'
772
+ : 'relative w-[var(--sbm-expanded-w)]'}
703
773
  {additionalClass}
704
774
  "
705
- >
775
+ >
706
776
  {#if isStepped && stepLevel === 1 && currentParent}
707
777
  <!-- Stepped sub-view (<2xs only): show just the children of the
708
778
  parent the user tapped. The consumer's hamburger button is
@@ -729,14 +799,14 @@
729
799
  <Popup
730
800
  anchor={geom.anchor}
731
801
  open={openId === id}
732
- side="right"
802
+ side={popoutSide}
733
803
  align={geom.align}
734
804
  offset={0}
735
805
  onclose={() => {
736
806
  if (openId === id) openId = null;
737
807
  }}
738
808
  >
739
- {@render popout(item, geom.borderClass)}
809
+ {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
740
810
  </Popup>
741
811
  {/if}
742
812
  {/each}
@@ -765,18 +835,19 @@
765
835
  <Popup
766
836
  anchor={geom.anchor}
767
837
  open={openId === id}
768
- side="right"
838
+ side={popoutSide}
769
839
  align={geom.align}
770
840
  offset={0}
771
841
  onclose={() => {
772
842
  if (openId === id) openId = null;
773
843
  }}
774
844
  >
775
- {@render popout(item, geom.borderClass)}
845
+ {@render popout(item, geom.borderClass, "sbm-popout-" + id.replace(":", "-"))}
776
846
  </Popup>
777
847
  {/if}
778
848
  {/each}
779
849
  </ul>
780
850
  {/if}
781
851
  {/if}
782
- </aside>
852
+ </aside>
853
+ </div>
@@ -122,6 +122,15 @@ type $$ComponentProps = {
122
122
  * and binds to `open`.
123
123
  */
124
124
  hamburger?: boolean;
125
+ /** In `icon` mode, expand the rail to `expanded` ON HOVER — as a floating overlay over a spacer
126
+ * that reserves the collapsed width, so page content never reflows. Default `false`. */
127
+ iconHoverExpand?: boolean;
128
+ /** Exit delay (ms) before a hover-expanded rail collapses on mouse-leave. Default `200`. */
129
+ hoverExpandCloseMs?: number;
130
+ /** Which edge the sidebar docks to. `"right"` mirrors everything: the frame border, the drawer
131
+ * slide direction, the hamburger corner, popout side + border, and item layout (icon on the
132
+ * right, labels right-aligned). Default `"left"`. */
133
+ side?: "left" | "right";
125
134
  /** Imperative handle (bindable). Call `api.reset()` from your router's navigation lifecycle
126
135
  * (SvelteKit `afterNavigate`, etc.) so a programmatic `goto()`/back-button closes the drawer +
127
136
  * any open popout and returns a stepped drawer to the top. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.7.1-dev.42.aca96e0",
3
+ "version": "0.7.1-dev.44.97afb0d",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"