@marianmeres/stuic 2.56.0 → 2.58.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.
@@ -22,7 +22,7 @@
22
22
  /** Optional string for color hash calculation (e.g., email, user ID). Falls back to `initials` */
23
23
  hashSource?: string;
24
24
  /** Size preset or custom Tailwind size class */
25
- size?: "sm" | "md" | "lg" | "xl" | string;
25
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | string;
26
26
  /** Click handler - when provided, renders as a button */
27
27
  onclick?: (event: MouseEvent) => void;
28
28
  /** Background color (Tailwind class). Ignored if autoColor=true */
@@ -62,9 +62,10 @@
62
62
 
63
63
  const SIZE_PRESETS: Record<string, { container: string; icon: number }> = {
64
64
  sm: { container: "size-8 text-xs", icon: 16 },
65
- md: { container: "size-10 text-sm", icon: 20 },
66
- lg: { container: "size-14 text-base", icon: 28 },
67
- xl: { container: "size-16 text-lg", icon: 32 },
65
+ md: { container: "size-10 text-base", icon: 20 },
66
+ lg: { container: "size-12 text-lg", icon: 28 },
67
+ xl: { container: "size-14 text-xl", icon: 32 },
68
+ "2xl": { container: "size-16 text-2xl", icon: 36 },
68
69
  };
69
70
 
70
71
  // Extract initials from input string (email, name, or raw initials)
@@ -23,7 +23,7 @@ export interface Props {
23
23
  /** Optional string for color hash calculation (e.g., email, user ID). Falls back to `initials` */
24
24
  hashSource?: string;
25
25
  /** Size preset or custom Tailwind size class */
26
- size?: "sm" | "md" | "lg" | "xl" | string;
26
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | string;
27
27
  /** Click handler - when provided, renders as a button */
28
28
  onclick?: (event: MouseEvent) => void;
29
29
  /** Background color (Tailwind class). Ignored if autoColor=true */
@@ -101,7 +101,7 @@
101
101
  border-button-group-border dark:border-button-group-border-dark
102
102
  flex justify-between
103
103
 
104
- focus-within:border-button-group-accent focus-within:dark:border-button-group-accent-dark
104
+ focus-within:border-button-group-accent focus-within:dark:border-button-group-accent-dark
105
105
  focus-within:ring-button-group-accent/20 focus-within:dark:ring-button-group-accent-dark/20
106
106
  focus-within:ring-4
107
107
  `;
@@ -210,9 +210,9 @@
210
210
  text-neutral-900 dark:text-neutral-100
211
211
  border-neutral-200 dark:border-neutral-700
212
212
  hover:brightness-95 dark:hover:brightness-110
213
- focus-visible:outline-2 focus-visible:outline-offset-2
214
213
  cursor-pointer
215
- `;
214
+ `;
215
+ // focus-visible:outline-2 focus-visible:outline-offset-2
216
216
 
217
217
  export const DROPDOWN_MENU_DROPDOWN_CLASSES = `
218
218
  stuic-dropdown-menu-dropdown
@@ -271,11 +271,12 @@
271
271
  import Thc from "../Thc/Thc.svelte";
272
272
  import "./index.css";
273
273
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
274
+ import { waitForTwoRepaints } from "../../utils/paint.js";
274
275
 
275
276
  let {
276
277
  items,
277
278
  isOpen = $bindable(false),
278
- position = "bottom-span-left",
279
+ position = "bottom-span-right",
279
280
  offset = "0.25rem",
280
281
  maxHeight = "300px",
281
282
  closeOnSelect = true,
@@ -314,7 +315,14 @@
314
315
  let wrapperEl: HTMLDivElement = $state()!;
315
316
  let activeItemEl: HTMLButtonElement | undefined = $state();
316
317
  const reducedMotion = prefersReducedMotion();
317
- const isSupported = untrack(() => !forceFallback && isAnchorPositioningSupported());
318
+
319
+ // Runtime overflow detection state
320
+ let runtimeFallback = $state(false);
321
+ let switchingToFallback = false; // Non-reactive flag to prevent recursion
322
+
323
+ const isSupported = $derived(
324
+ !forceFallback && !runtimeFallback && isAnchorPositioningSupported()
325
+ );
318
326
 
319
327
  // Track expanded sections (independent toggle - multiple can be open)
320
328
  let expandedSections = $state<Set<string | number>>(new Set());
@@ -380,6 +388,50 @@
380
388
  }
381
389
  });
382
390
 
391
+ // Reset runtime fallback when menu closes
392
+ $effect(() => {
393
+ if (!isOpen) {
394
+ // Unlock body scroll if we were in runtime fallback mode
395
+ // (must do this before resetting runtimeFallback, otherwise isSupported
396
+ // becomes true and the main body scroll effect skips the unlock)
397
+ if (runtimeFallback && !noScrollLock) {
398
+ BodyScroll.unlock();
399
+ }
400
+ runtimeFallback = false;
401
+ }
402
+ });
403
+
404
+ // Runtime viewport overflow detection
405
+ $effect(() => {
406
+ if (!isOpen || !dropdownEl || forceFallback || runtimeFallback) return;
407
+ if (!isAnchorPositioningSupported()) return;
408
+ if (switchingToFallback) return;
409
+
410
+ const checkOverflow = async () => {
411
+ await waitForTwoRepaints();
412
+ if (!dropdownEl || !isOpen) return;
413
+
414
+ const rect = dropdownEl.getBoundingClientRect();
415
+ const viewportWidth = window.innerWidth;
416
+ const viewportHeight = window.innerHeight;
417
+
418
+ if (
419
+ rect.left < 0 ||
420
+ rect.right > viewportWidth ||
421
+ rect.top < 0 ||
422
+ rect.bottom > viewportHeight
423
+ ) {
424
+ switchingToFallback = true;
425
+ runtimeFallback = true;
426
+ requestAnimationFrame(() => {
427
+ switchingToFallback = false;
428
+ });
429
+ }
430
+ };
431
+
432
+ checkOverflow();
433
+ });
434
+
383
435
  // Scroll active item into view
384
436
  $effect(() => {
385
437
  if (isOpen && _navItems.active?.id) {
@@ -651,12 +703,12 @@
651
703
  role="menuitem"
652
704
  class={twMerge(
653
705
  DROPDOWN_MENU_ITEM_CLASSES,
706
+ classItem,
707
+ item.class,
654
708
  isActive && "bg-neutral-200 dark:bg-neutral-600",
655
709
  isActive && classItemActive,
656
710
  item.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
657
- item.disabled && classItemDisabled,
658
- classItem,
659
- item.class
711
+ item.disabled && classItemDisabled
660
712
  )}
661
713
  onclick={() => selectItem(item)}
662
714
  onmouseenter={() => navItems.setActive(item)}
@@ -709,12 +761,12 @@
709
761
  class={twMerge(
710
762
  DROPDOWN_MENU_ITEM_CLASSES,
711
763
  "font-medium",
764
+ classExpandable,
765
+ item.class,
712
766
  isExpandableActive && "bg-neutral-200 dark:bg-neutral-600",
713
767
  isExpandableActive && classItemActive,
714
768
  item.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
715
- item.disabled && classItemDisabled,
716
- classExpandable,
717
- item.class
769
+ item.disabled && classItemDisabled
718
770
  )}
719
771
  onclick={() => toggleExpanded(item.id)}
720
772
  onmouseenter={() =>
@@ -763,13 +815,13 @@
763
815
  role="menuitem"
764
816
  class={twMerge(
765
817
  DROPDOWN_MENU_ITEM_CLASSES,
818
+ classItem,
819
+ childItem.class,
766
820
  isChildActive && "bg-neutral-200 dark:bg-neutral-600",
767
821
  isChildActive && classItemActive,
768
822
  childItem.disabled &&
769
823
  "opacity-50 cursor-not-allowed pointer-events-none",
770
- childItem.disabled && classItemDisabled,
771
- classItem,
772
- childItem.class
824
+ childItem.disabled && classItemDisabled
773
825
  )}
774
826
  onclick={() => selectItem(childItem)}
775
827
  onmouseenter={() => navItems.setActive(childItem)}
@@ -143,7 +143,7 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
143
143
  noScrollLock?: boolean;
144
144
  }
145
145
  export declare const DROPDOWN_MENU_BASE_CLASSES = "stuic-dropdown-menu relative inline-block";
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";
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\tcursor-pointer\n\t\t";
147
147
  export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "\n\t\tstuic-dropdown-menu-dropdown\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder border-neutral-200 dark:border-neutral-700\n\t\trounded-md shadow-sm\n\t\tp-1\n\t\toverflow-y-auto\n\t\tz-50\n\t\tmin-w-48\n\t";
148
148
  export declare const DROPDOWN_MENU_ITEM_CLASSES = "\n\t\tw-full\n\t\tflex items-center gap-2\n\t\tpx-3 py-1.5\n\t\tmin-h-[44px]\n\t\ttext-left \n\t\trounded-md\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";
149
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";
@@ -3,10 +3,44 @@
3
3
  scrollbar-width: thin;
4
4
  }
5
5
 
6
+ @position-try --pop-top {
7
+ position-area: top; /* above, centered */
8
+ }
9
+ @position-try --pop-top-span-right {
10
+ position-area: top span-right; /* above, aligned to anchor's left edge */
11
+ }
12
+ @position-try --pop-top-span-left {
13
+ position-area: top span-left; /* above, aligned to anchor's right edge */
14
+ }
15
+ @position-try --pop-bottom {
16
+ position-area: bottom; /* below, centered */
17
+ }
18
+ @position-try --pop-bottom-span-right {
19
+ position-area: bottom span-right;
20
+ }
21
+ @position-try --pop-bottom-span-left {
22
+ position-area: bottom span-left;
23
+ }
24
+ @position-try --pop-left {
25
+ position-area: left;
26
+ }
27
+ @position-try --pop-right {
28
+ position-area: right;
29
+ }
30
+
6
31
  /* CSS Anchor Positioning supported mode - flip only when viewport overflow */
7
32
  @supports (anchor-name: --anchor) {
8
33
  .stuic-dropdown-menu-dropdown {
9
- position-try-fallbacks: flip-block, flip-inline;
34
+ /* position-try-fallbacks: flip-block, flip-inline; */
35
+
36
+ /* position-area is set via inline style based on position param */
37
+
38
+ /* fallbacks ensure popover stays within viewport */
39
+ /* order: try other bottom positions first, then top, then left/right */
40
+ position-try-fallbacks:
41
+ flip-inline, --pop-bottom-span-right, --pop-bottom-span-left, --pop-bottom,
42
+ flip-block, --pop-top-span-right, --pop-top-span-left, --pop-top,
43
+ --pop-left, --pop-right;
10
44
  }
11
45
  }
12
46
 
@@ -75,7 +75,7 @@
75
75
 
76
76
  <kbd
77
77
  class={twMerge(
78
- "text-xs leading-0 space-x-0.5 rounded px-1 py-0 outline outline-neutral-400",
78
+ "text-sm leading-0 space-x-0.5 rounded px-1 py-0 outline outline-neutral-400",
79
79
  classProp
80
80
  )}
81
81
  >
@@ -35,3 +35,4 @@ export { iconLucideCircle as iconCircle } from "@marianmeres/icons-fns/lucide/ic
35
35
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
36
36
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
37
37
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
38
+ export { iconLucideEllipsisVertical as iconEllipsisVertical } from "@marianmeres/icons-fns/lucide/iconLucideEllipsisVertical.js";
@@ -39,3 +39,4 @@ export { iconLucideCircle as iconCircle } from "@marianmeres/icons-fns/lucide/ic
39
39
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
40
40
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
41
41
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
42
+ export { iconLucideEllipsisVertical as iconEllipsisVertical } from "@marianmeres/icons-fns/lucide/iconLucideEllipsisVertical.js";
@@ -29,7 +29,8 @@ const _breakpoints = [
29
29
  * ```
30
30
  */
31
31
  export class Breakpoint {
32
- static #singleton;
32
+ /** Singleton */
33
+ static #instance;
33
34
  #bp = $derived.by(() => {
34
35
  const w = innerWidth.current || 0;
35
36
  return _breakpoints.reduce((m, [k, v]) => {
@@ -39,7 +40,11 @@ export class Breakpoint {
39
40
  }, { current: null, sm: false, md: false, lg: false, xl: false, "2xl": false });
40
41
  });
41
42
  static get instance() {
42
- return (Breakpoint.#singleton ??= new Breakpoint());
43
+ // return (Breakpoint.#instance ??= new Breakpoint();) // does not work with Svelte correctly...
44
+ if (!Breakpoint.#instance) {
45
+ Breakpoint.#instance = new Breakpoint();
46
+ }
47
+ return Breakpoint.#instance;
43
48
  }
44
49
  get current() {
45
50
  return this.#bp.current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.56.0",
3
+ "version": "2.58.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",