@marianmeres/stuic 2.31.0 → 2.32.1

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.
@@ -400,14 +400,14 @@ export function popover(anchorEl, fn) {
400
400
  closeBtn.innerHTML = `<svg fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" style="width:1.25rem;height:1.25rem"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>`;
401
401
  closeBtn.style.cssText = `
402
402
  position: absolute;
403
- top: 0;
404
- right: 0;
403
+ top: 4px;
404
+ right: 4px;
405
405
  background: black;
406
406
  color: white;
407
407
  border: none;
408
- border-bottom-left-radius: 0.5rem;
408
+ border-radius: 0.25rem;
409
409
  cursor: pointer;
410
- opacity: 0.8;
410
+ opacity: 0.6;
411
411
  padding: 0.33rem;
412
412
  line-height: 1;
413
413
  `;
@@ -1,5 +1,7 @@
1
1
  <script module lang="ts">
2
2
  import type { Snippet } from "svelte";
3
+ import { setContext } from "svelte";
4
+ import { twMerge } from "../../utils/tw-merge.js";
3
5
 
4
6
  export interface Props {
5
7
  id?: string;
@@ -57,9 +59,6 @@
57
59
  </script>
58
60
 
59
61
  <script lang="ts">
60
- import { setContext } from "svelte";
61
- import { twMerge } from "../../utils/tw-merge.js";
62
-
63
62
  let {
64
63
  id = "shell",
65
64
  class: classProp,
@@ -0,0 +1,121 @@
1
+ <script lang="ts" module>
2
+ import { setContext, type Snippet } from "svelte";
3
+ import { twMerge } from "../../utils/tw-merge.js";
4
+
5
+ export interface Props {
6
+ class?: string;
7
+ //
8
+ headerClass?: string;
9
+ railClass?: string;
10
+ asideClass?: string;
11
+ mainClass?: string;
12
+ //
13
+ rail?: Snippet;
14
+ header?: Snippet;
15
+ aside?: Snippet;
16
+ children?: Snippet;
17
+ //
18
+ elRail?: HTMLElement;
19
+ elHeader?: HTMLElement;
20
+ elAside?: HTMLElement;
21
+ elMain?: HTMLElement;
22
+ }
23
+
24
+ export const MAIN_WIDTH = Symbol("MAIN_WIDTH");
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ let {
29
+ class: classProp,
30
+ //
31
+ headerClass,
32
+ railClass,
33
+ asideClass,
34
+ mainClass,
35
+ //
36
+ rail,
37
+ header,
38
+ aside,
39
+ children,
40
+ //
41
+ elRail = $bindable(),
42
+ elHeader = $bindable(),
43
+ elAside = $bindable(),
44
+ elMain = $bindable(),
45
+ }: Props = $props();
46
+
47
+ let headerHeight = $state(0);
48
+
49
+ // pragmatic use case...
50
+ let mainWidth: number = $state(0);
51
+ setContext(MAIN_WIDTH, {
52
+ get current() {
53
+ return mainWidth;
54
+ },
55
+ });
56
+ </script>
57
+
58
+ {#if header}
59
+ <header
60
+ bind:this={elHeader}
61
+ bind:clientHeight={headerHeight}
62
+ data-shell="header"
63
+ class={twMerge("sticky top-0 z-10", headerClass)}
64
+ >
65
+ {@render header()}
66
+ </header>
67
+ {/if}
68
+
69
+ <div class={twMerge("flex", classProp)}>
70
+ {#if rail}
71
+ <div
72
+ bind:this={elRail}
73
+ data-shell="rail"
74
+ style:top="{headerHeight}px"
75
+ style:height="calc(100dvh - {headerHeight}px)"
76
+ class={twMerge(
77
+ "sticky shrink-0",
78
+ "flex flex-col items-center",
79
+ "overflow-x-hidden overflow-y-auto",
80
+ "scrollbar-thin",
81
+ railClass
82
+ )}
83
+ >
84
+ {@render rail()}
85
+ </div>
86
+ {/if}
87
+
88
+ {#if aside}
89
+ <aside
90
+ bind:this={elAside}
91
+ data-shell="aside"
92
+ style:top="{headerHeight}px"
93
+ style:height="calc(100dvh - {headerHeight}px)"
94
+ class={twMerge(
95
+ "sticky shrink-0",
96
+ "flex flex-col items-center",
97
+ "overflow-x-hidden overflow-y-auto",
98
+ "scrollbar-thin",
99
+ asideClass
100
+ )}
101
+ >
102
+ {@render aside()}
103
+ </aside>
104
+ {/if}
105
+
106
+ <main
107
+ bind:this={elMain}
108
+ data-shell="main"
109
+ class={twMerge("flex-1", mainClass)}
110
+ bind:offsetWidth={mainWidth}
111
+ >
112
+ {@render children?.()}
113
+ </main>
114
+ </div>
115
+
116
+ <style>
117
+ .scrollbar-thin {
118
+ scrollbar-width: thin;
119
+ scrollbar-gutter: stable;
120
+ }
121
+ </style>
@@ -0,0 +1,20 @@
1
+ import { type Snippet } from "svelte";
2
+ export interface Props {
3
+ class?: string;
4
+ headerClass?: string;
5
+ railClass?: string;
6
+ asideClass?: string;
7
+ mainClass?: string;
8
+ rail?: Snippet;
9
+ header?: Snippet;
10
+ aside?: Snippet;
11
+ children?: Snippet;
12
+ elRail?: HTMLElement;
13
+ elHeader?: HTMLElement;
14
+ elAside?: HTMLElement;
15
+ elMain?: HTMLElement;
16
+ }
17
+ export declare const MAIN_WIDTH: unique symbol;
18
+ declare const AppShellSimple: import("svelte").Component<Props, {}, "elRail" | "elHeader" | "elAside" | "elMain">;
19
+ type AppShellSimple = ReturnType<typeof AppShellSimple>;
20
+ export default AppShellSimple;
@@ -97,3 +97,8 @@ import { onMount } from 'svelte';
97
97
  // Returns cleanup function automatically
98
98
  onMount(appShellSetHtmlBodyHeight);
99
99
  ```
100
+
101
+ ## AppShellSimple
102
+
103
+ A simplified approach using a sticky header and a sticky left sidebar, allowing the
104
+ page body to follow its natural flow (and the chrome UI is adjusted on scroll correctly).
@@ -1 +1,2 @@
1
1
  export { default as AppShell, type Props as AppShellProps, appShellSetHtmlBodyHeight, MAIN_WIDTH, } from "./AppShell.svelte";
2
+ export { default as AppShellSimple, type Props as AppShellSimpleProps, } from "./AppShellSimple.svelte";
@@ -1 +1,2 @@
1
1
  export { default as AppShell, appShellSetHtmlBodyHeight, MAIN_WIDTH, } from "./AppShell.svelte";
2
+ export { default as AppShellSimple, } from "./AppShellSimple.svelte";
@@ -37,7 +37,7 @@
37
37
  export const BUTTON_STUIC_BASE_CLASSES = `
38
38
  bg-button-bg text-button-text
39
39
  dark:bg-button-bg-dark dark:text-button-text-dark
40
- font-mono text-sm text-center
40
+ text-base text-center
41
41
  leading-none
42
42
  border-1
43
43
  border-button-border dark:border-button-border-dark
@@ -61,7 +61,7 @@
61
61
  export const BUTTON_STUIC_PRESET_CLASSES: ButtonPresetClasses = {
62
62
  size: {
63
63
  sm: `text-sm rounded-md px-3 py-2 min-h-none min-w-none`,
64
- lg: `text-base rounded-xl`,
64
+ lg: `text-lg rounded-xl`,
65
65
  },
66
66
  variant: {
67
67
  primary: `font-medium`,
@@ -31,7 +31,7 @@ export interface ButtonPresetClasses {
31
31
  shadow: string;
32
32
  inverse: string;
33
33
  }
34
- export declare const BUTTON_STUIC_BASE_CLASSES = "\n\t\tbg-button-bg text-button-text\n\t\tdark:bg-button-bg-dark dark:text-button-text-dark\n\t\tfont-mono text-sm text-center\n\t\tleading-none\n\t\tborder-1\n\t\tborder-button-border dark:border-button-border-dark\n\t\trounded-lg\n\t\tinline-flex items-center justify-center gap-x-2\n\t\tpx-4 py-3\n\t\tselect-none\n\t\tmin-h-[44px] min-w-[44px]\n\n\t\thover:brightness-105\n\t\tactive:brightness-95\n\t\tdisabled:hover:brightness-100 disabled:opacity-50\n\n\t\tfocus:brightness-105\n\t\tfocus:border-button-border-focus focus:dark:border-button-border-focus-dark\n\n\t\t focus:outline-4 focus:outline-black/10 focus:dark:outline-white/20\n\t\tfocus-visible:outline-4 focus-visible:outline-black/10 focus-visible:dark:outline-white/20\n\t";
34
+ export declare const BUTTON_STUIC_BASE_CLASSES = "\n\t\tbg-button-bg text-button-text\n\t\tdark:bg-button-bg-dark dark:text-button-text-dark\n\t\ttext-base text-center\n\t\tleading-none\n\t\tborder-1\n\t\tborder-button-border dark:border-button-border-dark\n\t\trounded-lg\n\t\tinline-flex items-center justify-center gap-x-2\n\t\tpx-4 py-3\n\t\tselect-none\n\t\tmin-h-[44px] min-w-[44px]\n\n\t\thover:brightness-105\n\t\tactive:brightness-95\n\t\tdisabled:hover:brightness-100 disabled:opacity-50\n\n\t\tfocus:brightness-105\n\t\tfocus:border-button-border-focus focus:dark:border-button-border-focus-dark\n\n\t\t focus:outline-4 focus:outline-black/10 focus:dark:outline-white/20\n\t\tfocus-visible:outline-4 focus-visible:outline-black/10 focus-visible:dark:outline-white/20\n\t";
35
35
  export declare const BUTTON_STUIC_PRESET_CLASSES: ButtonPresetClasses;
36
36
  import "./index.css";
37
37
  import { type TooltipConfig } from "../../actions/index.js";
@@ -138,6 +138,10 @@
138
138
  classExpandable?: string;
139
139
  /** Classes for expandable section content */
140
140
  classExpandableContent?: string;
141
+ /** Classes for backdrop (fallback mode only) */
142
+ classBackdrop?: string;
143
+ /** Show backdrop in fallback mode (default: true) */
144
+ showBackdrop?: boolean;
141
145
  /** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
142
146
  trigger?: Snippet<
143
147
  [
@@ -165,6 +169,8 @@
165
169
  triggerEl?: HTMLButtonElement;
166
170
  /** Reference to dropdown element */
167
171
  dropdownEl?: HTMLDivElement;
172
+ /** Optional, used only when css positioning not supported (iPhone)*/
173
+ noScrollLock?: boolean;
168
174
  }
169
175
 
170
176
  const POSITION_MAP: Record<string, string> = {
@@ -245,6 +251,12 @@
245
251
  text-neutral-500 dark:text-neutral-400
246
252
  select-none
247
253
  `;
254
+
255
+ export const DROPDOWN_MENU_BACKDROP_CLASSES = `
256
+ stuic-dropdown-menu-backdrop
257
+ fixed inset-0 bg-black/25
258
+ z-40
259
+ `;
248
260
  </script>
249
261
 
250
262
  <script lang="ts">
@@ -255,10 +267,11 @@
255
267
  import { iconLucideChevronDown } from "@marianmeres/icons-fns/lucide/iconLucideChevronDown.js";
256
268
  import { iconLucideChevronRight } from "@marianmeres/icons-fns/lucide/iconLucideChevronRight.js";
257
269
  import { onClickOutside } from "runed";
258
- import { slide } from "svelte/transition";
270
+ import { slide, fade } from "svelte/transition";
259
271
  import { untrack } from "svelte";
260
272
  import Thc from "../Thc/Thc.svelte";
261
273
  import "./index.css";
274
+ import { BodyScroll } from "../../utils/body-scroll-locker.js";
262
275
 
263
276
  let {
264
277
  items,
@@ -280,6 +293,8 @@
280
293
  classHeader,
281
294
  classExpandable,
282
295
  classExpandableContent,
296
+ classBackdrop,
297
+ showBackdrop = true,
283
298
  trigger,
284
299
  children,
285
300
  onOpen,
@@ -287,6 +302,7 @@
287
302
  onSelect,
288
303
  triggerEl = $bindable(),
289
304
  dropdownEl = $bindable(),
305
+ noScrollLock,
290
306
  ...rest
291
307
  }: Props = $props();
292
308
 
@@ -396,6 +412,11 @@
396
412
  wasOpen = isOpen;
397
413
  });
398
414
 
415
+ $effect(() => {
416
+ if (noScrollLock || isSupported) return;
417
+ isOpen ? BodyScroll.lock() : BodyScroll.unlock();
418
+ });
419
+
399
420
  // Click outside handler
400
421
  onClickOutside(
401
422
  () => wrapperEl,
@@ -455,22 +476,15 @@
455
476
  max-height: ${maxHeight};
456
477
  `;
457
478
  } else {
458
- // Fallback: absolute positioning
459
- if (position === "left") {
460
- return `position: absolute; right: 100%; top: 0; margin-right: ${offset}; max-height: ${maxHeight};`;
461
- } else if (position === "right") {
462
- return `position: absolute; left: 100%; top: 0; margin-left: ${offset}; max-height: ${maxHeight};`;
463
- }
464
- const isTop = position.startsWith("top");
465
- const isLeft =
466
- position.includes("left") || position === "bottom" || position === "top";
479
+ // Fallback: centered modal overlay
467
480
  return `
468
- position: absolute;
469
- ${isTop ? "bottom: 100%;" : "top: 100%;"}
470
- ${isLeft ? "left: 0;" : "right: 0;"}
471
- margin-top: ${isTop ? "0" : offset};
472
- margin-bottom: ${isTop ? offset : "0"};
481
+ position: fixed;
482
+ top: 50%;
483
+ left: 50%;
484
+ transform: translate(-50%, -50%);
485
+ max-width: 90vw;
473
486
  max-height: ${maxHeight};
487
+ z-index: 50;
474
488
  `;
475
489
  }
476
490
  });
@@ -567,6 +581,22 @@
567
581
  </button>
568
582
  {/if}
569
583
 
584
+ <!-- Backdrop (fallback mode only) -->
585
+ {#if isOpen && !isSupported && showBackdrop}
586
+ <div
587
+ class={twMerge(DROPDOWN_MENU_BACKDROP_CLASSES, classBackdrop)}
588
+ onclick={() => {
589
+ if (closeOnClickOutside) {
590
+ isOpen = false;
591
+ triggerEl?.focus();
592
+ }
593
+ }}
594
+ onkeydown={() => {}}
595
+ role="presentation"
596
+ transition:fade={{ duration: transitionDuration }}
597
+ ></div>
598
+ {/if}
599
+
570
600
  <!-- Dropdown Menu -->
571
601
  {#if isOpen}
572
602
  <div
@@ -574,10 +604,46 @@
574
604
  id={dropdownId}
575
605
  role="menu"
576
606
  aria-labelledby={triggerId}
577
- class={twMerge(DROPDOWN_MENU_DROPDOWN_CLASSES, classDropdown)}
607
+ class={twMerge(
608
+ DROPDOWN_MENU_DROPDOWN_CLASSES,
609
+ !isSupported && "w-4/5 max-w-32",
610
+ classDropdown
611
+ )}
578
612
  style={dropdownStyle}
579
613
  transition:slide={{ duration: transitionDuration }}
580
614
  >
615
+ <!-- Close button (fallback mode only) -->
616
+ {#if !isSupported}
617
+ <div class="sticky top-0 right-0 z-10 flex just pointer-events-none">
618
+ <button
619
+ type="button"
620
+ aria-label="Close"
621
+ class={[
622
+ "bg-black text-white rounded-md cursor-pointer opacity-60",
623
+ "absolute right-0 top-0 p-2",
624
+ "leading-none hover:opacity-100 pointer-events-auto",
625
+ ]}
626
+ onclick={() => {
627
+ isOpen = false;
628
+ triggerEl?.focus();
629
+ }}
630
+ >
631
+ <svg
632
+ fill="none"
633
+ viewBox="0 0 24 24"
634
+ stroke-width="2.5"
635
+ stroke="currentColor"
636
+ class="w-5 h-5"
637
+ >
638
+ <path
639
+ stroke-linecap="round"
640
+ stroke-linejoin="round"
641
+ d="M6 18 18 6M6 6l12 12"
642
+ />
643
+ </svg>
644
+ </button>
645
+ </div>
646
+ {/if}
581
647
  {#each items as item}
582
648
  {#if item.type === "action"}
583
649
  {@const isActive = _navItems.active?.id === item.id}
@@ -110,6 +110,10 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
110
110
  classExpandable?: string;
111
111
  /** Classes for expandable section content */
112
112
  classExpandableContent?: string;
113
+ /** Classes for backdrop (fallback mode only) */
114
+ classBackdrop?: string;
115
+ /** Show backdrop in fallback mode (default: true) */
116
+ showBackdrop?: boolean;
113
117
  /** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
114
118
  trigger?: Snippet<[
115
119
  {
@@ -135,6 +139,8 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
135
139
  triggerEl?: HTMLButtonElement;
136
140
  /** Reference to dropdown element */
137
141
  dropdownEl?: HTMLDivElement;
142
+ /** Optional, used only when css positioning not supported (iPhone)*/
143
+ noScrollLock?: boolean;
138
144
  }
139
145
  export declare const DROPDOWN_MENU_BASE_CLASSES = "stuic-dropdown-menu relative inline-block";
140
146
  export declare const DROPDOWN_MENU_TRIGGER_CLASSES = "\n\t\tinline-flex items-center justify-center gap-2\n\t\tpx-3 py-2\n\t\trounded-md border\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder-neutral-200 dark:border-neutral-700\n\t\thover:brightness-95 dark:hover:brightness-110\n\t\tfocus-visible:outline-2 focus-visible:outline-offset-2\n\t\tcursor-pointer\n\t";
@@ -142,6 +148,7 @@ export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "\n\t\tstuic-dropdown-menu
142
148
  export declare const DROPDOWN_MENU_ITEM_CLASSES = "\n\t\tw-full\n\t\tflex items-center gap-2\n\t\tpx-2 py-1.5\n\t\tmin-h-[44px]\n\t\ttext-left text-sm\n\t\trounded-sm\n\t\tcursor-pointer\n\t\ttouch-action-manipulation\n\t\thover:bg-neutral-100 dark:hover:bg-neutral-700\n\t\tfocus:outline-none\n\t\tfocus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-600\n\t";
143
149
  export declare const DROPDOWN_MENU_DIVIDER_CLASSES = "\n\t\th-px my-1\n\t\tbg-neutral-200 dark:bg-neutral-700\n\t";
144
150
  export declare const DROPDOWN_MENU_HEADER_CLASSES = "\n\t\tpx-2 py-1.5\n\t\ttext-xs font-semibold uppercase tracking-wide\n\t\ttext-neutral-500 dark:text-neutral-400\n\t\tselect-none\n\t";
151
+ export declare const DROPDOWN_MENU_BACKDROP_CLASSES = "\n\t\tstuic-dropdown-menu-backdrop\n\t\tfixed inset-0 bg-black/25\n\t\tz-40\n\t";
145
152
  import "./index.css";
146
153
  declare const DropdownMenu: import("svelte").Component<Props, {}, "isOpen" | "triggerEl" | "dropdownEl">;
147
154
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
@@ -14,3 +14,8 @@
14
14
  .stuic-dropdown-menu-expandable-content {
15
15
  overflow: hidden;
16
16
  }
17
+
18
+ /* Backdrop for fallback mode */
19
+ .stuic-dropdown-menu-backdrop {
20
+ transition-property: opacity;
21
+ }
@@ -40,6 +40,9 @@ export class BodyScroll {
40
40
  document.body.style.top = `-${scrollY}px`;
41
41
  document.body.style.width = "100%";
42
42
  document.body.style.overflow = "hidden";
43
+ //
44
+ document.body.style.left = "0";
45
+ document.body.style.right = "0";
43
46
  }
44
47
  else {
45
48
  // Another component already locked the scroll, just increment the counter
@@ -79,10 +82,19 @@ export class BodyScroll {
79
82
  top: style.position || null,
80
83
  width: style.width || null,
81
84
  overflow: style.overflow || null,
85
+ left: style.left || null,
86
+ right: style.left || null,
82
87
  });
83
88
  }
84
89
  static _restore_body_styles(originalJsonString) {
85
- let original = { position: null, top: null, width: null, overflow: null };
90
+ let original = {
91
+ position: null,
92
+ top: null,
93
+ width: null,
94
+ overflow: null,
95
+ left: null,
96
+ right: null,
97
+ };
86
98
  try {
87
99
  original = JSON.parse(originalJsonString);
88
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.31.0",
3
+ "version": "2.32.1",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",