@dryui/ui 1.3.1 → 1.4.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.
Files changed (91) hide show
  1. package/dist/accordion/accordion-content.svelte +1 -1
  2. package/dist/alert/alert.svelte +1 -1
  3. package/dist/app-frame/app-frame.svelte +131 -0
  4. package/dist/app-frame/app-frame.svelte.d.ts +10 -0
  5. package/dist/app-frame/index.d.ts +8 -0
  6. package/dist/app-frame/index.js +1 -0
  7. package/dist/aurora/aurora.svelte +22 -59
  8. package/dist/beam/beam.svelte +28 -9
  9. package/dist/carousel/carousel-button-dots.svelte +25 -8
  10. package/dist/carousel/carousel-button-thumbnails.svelte +25 -8
  11. package/dist/carousel/carousel-root.svelte +115 -4
  12. package/dist/carousel/carousel-slide.svelte +5 -1
  13. package/dist/carousel/carousel-viewport.svelte +2 -0
  14. package/dist/carousel/context.svelte.d.ts +5 -0
  15. package/dist/chart/chart-bars.svelte +25 -16
  16. package/dist/chart/chart-donut.svelte +25 -16
  17. package/dist/chart/chart-root.svelte +134 -30
  18. package/dist/chart/chart-root.svelte.d.ts +1 -0
  19. package/dist/chart/context.svelte.d.ts +3 -1
  20. package/dist/chart/context.svelte.js +1 -0
  21. package/dist/chart/index.d.ts +1 -0
  22. package/dist/chromatic-shift/chromatic-shift.svelte +36 -9
  23. package/dist/collapsible/collapsible-content.svelte +2 -1
  24. package/dist/combobox/combobox-content.svelte +26 -44
  25. package/dist/combobox/combobox-content.svelte.d.ts +1 -1
  26. package/dist/combobox/combobox-input-root.svelte +7 -1
  27. package/dist/combobox/combobox-input.svelte +21 -8
  28. package/dist/country-select/country-select-button-input.svelte +124 -260
  29. package/dist/date-picker/datepicker-content.svelte +18 -26
  30. package/dist/date-picker/datepicker-content.svelte.d.ts +2 -1
  31. package/dist/date-picker/datepicker-input-root.svelte +7 -1
  32. package/dist/date-range-picker/date-range-picker-content.svelte +18 -14
  33. package/dist/date-range-picker/date-range-picker-content.svelte.d.ts +2 -1
  34. package/dist/date-range-picker/date-range-picker-root.svelte +7 -1
  35. package/dist/displacement/displacement.svelte +16 -22
  36. package/dist/drag-and-drop/context.svelte.d.ts +2 -0
  37. package/dist/drag-and-drop/drag-and-drop-handle.svelte +34 -5
  38. package/dist/drag-and-drop/drag-and-drop-item.svelte +23 -14
  39. package/dist/drag-and-drop/drag-and-drop-root.svelte +60 -16
  40. package/dist/god-rays/god-rays.svelte +11 -0
  41. package/dist/gradient-mesh/gradient-mesh.svelte +27 -5
  42. package/dist/hover-card/context.svelte.d.ts +1 -10
  43. package/dist/hover-card/context.svelte.js +1 -2
  44. package/dist/hover-card/hover-card-content.svelte +41 -3
  45. package/dist/hover-card/hover-card-root.svelte +7 -55
  46. package/dist/hover-card/hover-card-trigger.svelte +79 -40
  47. package/dist/hover-card/hover-card-trigger.svelte.d.ts +1 -1
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +1 -0
  50. package/dist/internal/motion.d.ts +1 -1
  51. package/dist/internal/motion.js +1 -1
  52. package/dist/marquee/marquee.svelte +42 -5
  53. package/dist/mega-menu/context.svelte.d.ts +2 -1
  54. package/dist/mega-menu/mega-menu-button-trigger.svelte +2 -14
  55. package/dist/mega-menu/mega-menu-item.svelte +3 -1
  56. package/dist/mega-menu/mega-menu-panel.svelte +35 -3
  57. package/dist/mega-menu/mega-menu-root.svelte +28 -13
  58. package/dist/menubar/context.svelte.d.ts +2 -2
  59. package/dist/menubar/menubar-button-trigger.svelte +5 -3
  60. package/dist/menubar/menubar-content.svelte +20 -12
  61. package/dist/menubar/menubar-root.svelte +4 -4
  62. package/dist/multi-select-combobox/multi-select-combobox-content.svelte +18 -55
  63. package/dist/multi-select-combobox/multi-select-combobox-content.svelte.d.ts +1 -1
  64. package/dist/noise/noise.svelte +38 -6
  65. package/dist/notification-center/context.svelte.d.ts +0 -1
  66. package/dist/notification-center/notification-center-panel.svelte +54 -35
  67. package/dist/notification-center/notification-center-root.svelte +0 -1
  68. package/dist/notification-center/notification-center-trigger-button.svelte +1 -8
  69. package/dist/option-picker/option-picker-description.svelte +2 -2
  70. package/dist/option-picker/option-picker-item.svelte +10 -3
  71. package/dist/option-picker/option-picker-label.svelte +2 -2
  72. package/dist/option-picker/option-picker-preview.svelte +18 -13
  73. package/dist/phone-input/phone-input-select.svelte +2 -152
  74. package/dist/phone-input/phone-input-select.svelte.d.ts +1 -7
  75. package/dist/rich-text-editor/rich-text-editor-toolbar-button-input.svelte +84 -29
  76. package/dist/scroll-area/scroll-area.svelte +16 -4
  77. package/dist/select/select-content.svelte +21 -31
  78. package/dist/select/select-content.svelte.d.ts +1 -1
  79. package/dist/select/select-root-input.svelte +7 -1
  80. package/dist/shimmer/shimmer.svelte +22 -12
  81. package/dist/transfer/transfer-item.svelte +0 -3
  82. package/dist/transfer/transfer-list-input.svelte +1 -6
  83. package/dist/tree/context.svelte.d.ts +7 -1
  84. package/dist/tree/tree-item-children.svelte +12 -10
  85. package/dist/tree/tree-item-label.svelte +6 -17
  86. package/dist/tree/tree-item-label.svelte.d.ts +2 -2
  87. package/dist/tree/tree-item.svelte +28 -1
  88. package/dist/tree/tree-root.svelte +135 -59
  89. package/package.json +8 -2
  90. package/skills/dryui/SKILL.md +1 -0
  91. package/dist/hover-card/hover-card-root.svelte.d.ts +0 -9
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
+ import { Button } from '../button/index.js';
4
5
  import { getHoverCardCtx } from './context.svelte.js';
5
6
 
6
- interface Props extends HTMLAttributes<HTMLAnchorElement> {
7
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
7
8
  href?: string;
8
9
  children: Snippet;
9
10
  }
@@ -12,57 +13,95 @@
12
13
 
13
14
  const ctx = getHoverCardCtx();
14
15
 
15
- let anchorEl = $state<HTMLAnchorElement>();
16
+ let triggerEl = $state<HTMLAnchorElement | HTMLButtonElement>();
16
17
 
17
18
  $effect(() => {
18
- if (anchorEl) {
19
- ctx.triggerEl = anchorEl;
19
+ if (triggerEl) {
20
+ ctx.triggerEl = triggerEl;
21
+
22
+ return () => {
23
+ if (ctx.triggerEl === triggerEl) {
24
+ ctx.triggerEl = null;
25
+ }
26
+ };
20
27
  }
21
28
  });
29
+
30
+ function handlePointerEnter() {
31
+ ctx.triggerHovered = true;
32
+ ctx.show();
33
+ }
34
+
35
+ function handlePointerLeave() {
36
+ ctx.triggerHovered = false;
37
+ ctx.close();
38
+ }
39
+
40
+ function handleFocusIn() {
41
+ ctx.triggerFocused = true;
42
+
43
+ if (ctx.ignoreNextTriggerFocusOpen) {
44
+ ctx.ignoreNextTriggerFocusOpen = false;
45
+ return;
46
+ }
47
+
48
+ ctx.showImmediate();
49
+ }
50
+
51
+ function handleFocusOut() {
52
+ ctx.triggerFocused = false;
53
+ ctx.close();
54
+ }
55
+
56
+ function handleKeydown(event: KeyboardEvent) {
57
+ if (event.key === 'Escape' && ctx.open) {
58
+ event.preventDefault();
59
+ ctx.forceClose();
60
+ triggerEl?.focus();
61
+ }
62
+ }
63
+
64
+ function handleRef(el: HTMLButtonElement | HTMLAnchorElement | null) {
65
+ triggerEl = el ?? undefined;
66
+ }
22
67
  </script>
23
68
 
24
- <a
25
- bind:this={anchorEl}
26
- id={ctx.triggerId}
27
- {href}
28
- data-hover-card-trigger
29
- aria-haspopup="true"
30
- aria-expanded={ctx.open}
31
- data-state={ctx.open ? 'open' : 'closed'}
32
- onpointerenter={() => ctx.show()}
33
- onpointerleave={() => ctx.close()}
34
- onfocus={() => ctx.show()}
35
- onblur={() => ctx.close()}
36
- class={className}
37
- {...rest}
38
- >
39
- {@render children()}
40
- </a>
69
+ <span class={className} data-hover-card-trigger-shell>
70
+ <Button
71
+ ref={handleRef}
72
+ variant="link"
73
+ size="sm"
74
+ color="primary"
75
+ id={ctx.triggerId}
76
+ {href}
77
+ data-hover-card-trigger
78
+ aria-haspopup="dialog"
79
+ aria-controls={ctx.contentId}
80
+ aria-expanded={ctx.open}
81
+ data-state={ctx.open ? 'open' : 'closed'}
82
+ onpointerenter={handlePointerEnter}
83
+ onpointerleave={handlePointerLeave}
84
+ onfocusin={handleFocusIn}
85
+ onfocusout={handleFocusOut}
86
+ onkeydown={handleKeydown}
87
+ {...rest}
88
+ >
89
+ {@render children()}
90
+ </Button>
91
+ </span>
41
92
 
42
93
  <style>
43
- [data-hover-card-trigger] {
94
+ [data-hover-card-trigger-shell] {
95
+ --dry-btn-min-height: auto;
96
+ --dry-btn-padding-x: 0;
97
+ --dry-btn-padding-y: 0;
98
+ --dry-btn-font-size: inherit;
99
+ --dry-btn-radius: var(--dry-radius-sm);
44
100
  display: inline-grid;
45
- grid-auto-flow: column;
46
- grid-auto-columns: max-content;
47
- align-items: center;
48
- gap: var(--dry-space-1);
49
- color: var(--dry-color-text-brand);
50
- text-decoration: underline;
51
- text-decoration-color: var(--dry-color-stroke-brand);
52
- text-decoration-thickness: 1px;
53
- text-underline-offset: 0.18em;
54
101
  cursor: help;
55
- transition:
56
- color var(--dry-duration-fast) var(--dry-ease-default),
57
- text-decoration-color var(--dry-duration-fast) var(--dry-ease-default);
58
- }
59
-
60
- [data-hover-card-trigger]:hover {
61
- color: var(--dry-color-fill-brand-hover);
62
- text-decoration-color: currentColor;
63
102
  }
64
103
 
65
- [data-hover-card-trigger]:focus-visible {
104
+ [data-hover-card-trigger-shell]:focus-within {
66
105
  outline: 2px solid var(--dry-color-stroke-focus);
67
106
  outline-offset: 3px;
68
107
  border-radius: var(--dry-radius-sm);
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- interface Props extends HTMLAttributes<HTMLAnchorElement> {
3
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
4
4
  href?: string;
5
5
  children: Snippet;
6
6
  }
package/dist/index.d.ts CHANGED
@@ -98,6 +98,8 @@ export type { BadgeProps, BadgeColor } from './badge/index.js';
98
98
  export { Chart } from './chart/index.js';
99
99
  export type { ChartDataPoint, ChartStackedDataPoint } from './chart/index.js';
100
100
  export type { ChartRootProps, ChartBarsProps, ChartLineProps, ChartAreaProps, ChartDonutProps, ChartStackedBarProps, ChartHorizontalBarProps, ChartXAxisProps, ChartYAxisProps } from './chart/index.js';
101
+ export { AppFrame } from './app-frame/index.js';
102
+ export type { AppFrameProps } from './app-frame/index.js';
101
103
  export { ChatThread } from './chat-thread/index.js';
102
104
  export type { ChatThreadProps } from './chat-thread/index.js';
103
105
  export { TypingIndicator } from './typing-indicator/index.js';
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ export { Table } from './table/index.js';
50
50
  export { Avatar } from './avatar/index.js';
51
51
  export { Badge } from './badge/index.js';
52
52
  export { Chart } from './chart/index.js';
53
+ export { AppFrame } from './app-frame/index.js';
53
54
  export { ChatThread } from './chat-thread/index.js';
54
55
  export { TypingIndicator } from './typing-indicator/index.js';
55
56
  export { Icon } from './icon/index.js';
@@ -1,4 +1,4 @@
1
- export { getReducedMotionPreference, observeReducedMotionPreference, supportsIntersectionObservers, supportsPointerTracking, supportsPropertyRegistration, registerPropertyOnce, supportsWebGL2 } from '@dryui/primitives/internal/motion';
1
+ export { getReducedMotionPreference, observeReducedMotionPreference, supportsIntersectionObservers, supportsPointerTracking, supportsPropertyRegistration, registerPropertyOnce, supportsWebGL2, observeInViewport, observePageVisibility, observeOffscreenState } from '@dryui/primitives/internal/motion';
2
2
  export declare function supportsViewTransitions(): boolean;
3
3
  export declare function supportsScrollTimelines(): boolean;
4
4
  export declare function extractThemeColor(property: string, element?: HTMLElement): [number, number, number];
@@ -1,5 +1,5 @@
1
1
  // Re-export shared motion utilities from primitives (single source of truth)
2
- export { getReducedMotionPreference, observeReducedMotionPreference, supportsIntersectionObservers, supportsPointerTracking, supportsPropertyRegistration, registerPropertyOnce, supportsWebGL2 } from '@dryui/primitives/internal/motion';
2
+ export { getReducedMotionPreference, observeReducedMotionPreference, supportsIntersectionObservers, supportsPointerTracking, supportsPropertyRegistration, registerPropertyOnce, supportsWebGL2, observeInViewport, observePageVisibility, observeOffscreenState } from '@dryui/primitives/internal/motion';
3
3
  // UI-only motion utilities
4
4
  export function supportsViewTransitions() {
5
5
  return typeof document !== 'undefined' && 'startViewTransition' in document;
@@ -2,7 +2,11 @@
2
2
  import { onMount } from 'svelte';
3
3
  import type { Snippet } from 'svelte';
4
4
  import type { HTMLAttributes } from 'svelte/elements';
5
- import { observeReducedMotionPreference } from '@dryui/primitives/internal/motion';
5
+ import {
6
+ observeInViewport,
7
+ observePageVisibility,
8
+ observeReducedMotionPreference
9
+ } from '@dryui/primitives/internal/motion';
6
10
 
7
11
  interface Props extends HTMLAttributes<HTMLDivElement> {
8
12
  speed?: number;
@@ -24,18 +28,40 @@
24
28
  ...rest
25
29
  }: Props = $props();
26
30
 
31
+ let rootEl: HTMLDivElement | undefined = $state();
27
32
  let contentEl: HTMLDivElement | undefined = $state();
28
33
  let contentSize = $state(0);
29
34
  let prefersReducedMotion = $state(false);
35
+ let onScreen = $state(true);
36
+ let tabVisible = $state(true);
30
37
 
31
38
  const isVertical = $derived(direction === 'up' || direction === 'down');
32
39
  const duration = $derived(contentSize > 0 && speed > 0 ? contentSize / speed : 0);
40
+ const paused = $derived(!onScreen || !tabVisible);
33
41
 
34
- onMount(() =>
35
- observeReducedMotionPreference((matches) => {
42
+ onMount(() => {
43
+ const unsubscribeMotion = observeReducedMotionPreference((matches) => {
36
44
  prefersReducedMotion = matches;
37
- })
38
- );
45
+ });
46
+ const unsubscribeVisibility = observePageVisibility((visible) => {
47
+ tabVisible = visible;
48
+ });
49
+ return () => {
50
+ unsubscribeMotion();
51
+ unsubscribeVisibility();
52
+ };
53
+ });
54
+
55
+ $effect(() => {
56
+ if (!rootEl) return;
57
+ return observeInViewport(
58
+ rootEl,
59
+ (inView) => {
60
+ onScreen = inView;
61
+ },
62
+ { rootMargin: '200px' }
63
+ );
64
+ });
39
65
 
40
66
  $effect(() => {
41
67
  if (!contentEl) return;
@@ -69,12 +95,14 @@
69
95
  </script>
70
96
 
71
97
  <div
98
+ bind:this={rootEl}
72
99
  class={className}
73
100
  data-marquee
74
101
  data-direction={direction}
75
102
  data-pause-on-hover={pauseOnHover || undefined}
76
103
  data-fade={fade || undefined}
77
104
  data-reduced-motion={prefersReducedMotion || undefined}
105
+ data-paused={paused || undefined}
78
106
  use:applyRootStyles
79
107
  {...rest}
80
108
  >
@@ -96,6 +124,7 @@
96
124
 
97
125
  overflow: hidden;
98
126
  position: relative;
127
+ contain: content;
99
128
  }
100
129
 
101
130
  [data-marquee-track] {
@@ -104,6 +133,10 @@
104
133
  animation-duration: var(--dry-marquee-speed);
105
134
  animation-timing-function: linear;
106
135
  animation-iteration-count: infinite;
136
+ backface-visibility: hidden;
137
+ }
138
+
139
+ [data-marquee]:not([data-paused]) [data-marquee-track] {
107
140
  will-change: transform;
108
141
  }
109
142
 
@@ -143,6 +176,10 @@
143
176
  animation-play-state: paused;
144
177
  }
145
178
 
179
+ [data-marquee][data-paused] [data-marquee-track] {
180
+ animation-play-state: paused;
181
+ }
182
+
146
183
  @keyframes marquee-horizontal {
147
184
  from {
148
185
  transform: translateX(0);
@@ -1,12 +1,13 @@
1
1
  export interface MegaMenuContext {
2
2
  readonly activeItem: string | null;
3
- openItem: (id: string) => void;
3
+ openItem: (id: string, triggerId: string) => void;
4
4
  closeItem: () => void;
5
5
  }
6
6
  export declare const setMegaMenuCtx: (ctx: MegaMenuContext) => MegaMenuContext, getMegaMenuCtx: () => MegaMenuContext;
7
7
  export interface MegaMenuItemContext {
8
8
  readonly itemId: string;
9
9
  readonly triggerId: string;
10
+ readonly panelId: string;
10
11
  readonly open: boolean;
11
12
  }
12
13
  export declare const setMegaMenuItemCtx: (ctx: MegaMenuItemContext) => MegaMenuItemContext, getMegaMenuItemCtx: () => MegaMenuItemContext;
@@ -17,18 +17,7 @@
17
17
  if (itemCtx.open) {
18
18
  ctx.closeItem();
19
19
  } else {
20
- ctx.openItem(itemCtx.itemId);
21
- }
22
- }
23
-
24
- function handleKeydown(e: KeyboardEvent) {
25
- if (e.key === 'Enter' || e.key === ' ') {
26
- e.preventDefault();
27
- if (itemCtx.open) {
28
- ctx.closeItem();
29
- } else {
30
- ctx.openItem(itemCtx.itemId);
31
- }
20
+ ctx.openItem(itemCtx.itemId, itemCtx.triggerId);
32
21
  }
33
22
  }
34
23
  </script>
@@ -37,13 +26,12 @@
37
26
  variant="trigger"
38
27
  type="button"
39
28
  id={itemCtx.triggerId}
29
+ aria-controls={itemCtx.open ? itemCtx.panelId : undefined}
40
30
  aria-expanded={itemCtx.open}
41
- aria-haspopup="true"
42
31
  data-mega-menu-trigger
43
32
  data-state={itemCtx.open ? 'open' : 'closed'}
44
33
  {...rest}
45
34
  onclick={handleClick}
46
- onkeydown={handleKeydown}
47
35
  >
48
36
  {@render children()}
49
37
  </Button>
@@ -13,17 +13,19 @@
13
13
  const ctx = getMegaMenuCtx();
14
14
  const itemId = generateFormId('mm-item');
15
15
  const triggerId = generateFormId('mm-trigger');
16
+ const panelId = generateFormId('mm-panel');
16
17
 
17
18
  setMegaMenuItemCtx({
18
19
  itemId,
19
20
  triggerId,
21
+ panelId,
20
22
  get open() {
21
23
  return ctx.activeItem === itemId;
22
24
  }
23
25
  });
24
26
 
25
27
  function handlePointerEnter() {
26
- ctx.openItem(itemId);
28
+ ctx.openItem(itemId, triggerId);
27
29
  }
28
30
 
29
31
  function handlePointerLeave() {
@@ -24,6 +24,8 @@
24
24
  const itemCtx = getMegaMenuItemCtx();
25
25
 
26
26
  let panelEl = $state<HTMLDivElement | null>(null);
27
+ let layout = $state<'columns' | 'stacked'>('columns');
28
+ const STACK_THRESHOLD = 32 * 16;
27
29
 
28
30
  const popover = createPositionedPopover({
29
31
  triggerEl: () => document.getElementById(itemCtx.triggerId),
@@ -36,8 +38,18 @@
36
38
  offset: () => 4
37
39
  });
38
40
 
41
+ function watchLayout(node: HTMLDivElement) {
42
+ const update = () => {
43
+ const next = window.innerWidth < STACK_THRESHOLD ? 'stacked' : 'columns';
44
+ if (next !== layout) layout = next;
45
+ };
46
+ update();
47
+ window.addEventListener('resize', update);
48
+ return () => window.removeEventListener('resize', update);
49
+ }
50
+
39
51
  function handlePointerEnter() {
40
- ctx.openItem(itemCtx.itemId);
52
+ ctx.openItem(itemCtx.itemId, itemCtx.triggerId);
41
53
  }
42
54
 
43
55
  function handlePointerLeave() {
@@ -70,12 +82,15 @@
70
82
  {@attach attachPanel}
71
83
  {@attach syncPopover(itemCtx.open)}
72
84
  {@attach fromAction(popover.applyPosition, () => style)}
73
- role="region"
85
+ {@attach watchLayout}
86
+ id={itemCtx.panelId}
87
+ role="group"
74
88
  popover="manual"
75
89
  data-mega-menu-panel
76
90
  aria-labelledby={itemCtx.triggerId}
77
91
  data-state={itemCtx.open ? 'open' : 'closed'}
78
92
  data-full-width={fullWidth || undefined}
93
+ data-layout={layout}
79
94
  class={className}
80
95
  onpointerenter={handlePointerEnter}
81
96
  onpointerleave={handlePointerLeave}
@@ -86,6 +101,13 @@
86
101
  {/if}
87
102
 
88
103
  <style>
104
+ @position-try --dry-mega-menu-panel-viewport-fit {
105
+ position-area: none;
106
+ inset-inline: 4vw;
107
+ inset-block-start: anchor(bottom);
108
+ margin-top: 0.5rem;
109
+ }
110
+
89
111
  [data-mega-menu-panel] {
90
112
  inset: unset;
91
113
  margin: 0;
@@ -95,11 +117,14 @@
95
117
  border-radius: var(--dry-radius-lg, 0.5rem);
96
118
  box-shadow: var(--dry-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
97
119
  padding: var(--dry-space-4, 1rem);
120
+ /* dryui-allow width */
121
+ max-inline-size: var(--dry-mega-menu-panel-max-width, min(92vw, 60rem));
98
122
  display: grid;
99
123
  grid-auto-flow: column;
100
- grid-auto-columns: max-content;
124
+ grid-auto-columns: minmax(var(--dry-mega-menu-panel-column-min, 12rem), max-content);
101
125
  align-items: start;
102
126
  gap: var(--dry-space-6, 1.5rem);
127
+ --dry-anchor-try-fallbacks: flip-block, flip-inline, --dry-mega-menu-panel-viewport-fit;
103
128
  }
104
129
 
105
130
  [data-mega-menu-panel]:not(:popover-open) {
@@ -110,6 +135,13 @@
110
135
  display: grid;
111
136
  }
112
137
 
138
+ [data-mega-menu-panel][data-layout='stacked'] {
139
+ grid-auto-flow: row;
140
+ grid-auto-columns: auto;
141
+ grid-auto-rows: auto;
142
+ gap: var(--dry-space-4, 1rem);
143
+ }
144
+
113
145
  [data-mega-menu-panel][data-full-width] {
114
146
  justify-self: stretch;
115
147
  }
@@ -10,25 +10,45 @@
10
10
  let { class: className, children, ...rest }: Props = $props();
11
11
 
12
12
  let activeItem = $state<string | null>(null);
13
+ let activeTriggerId = $state<string | null>(null);
13
14
  let openTimer: ReturnType<typeof setTimeout> | undefined;
14
15
  let closeTimer: ReturnType<typeof setTimeout> | undefined;
15
16
 
17
+ function clearPendingTimers() {
18
+ clearTimeout(openTimer);
19
+ clearTimeout(closeTimer);
20
+ }
21
+
22
+ function closeImmediately(options?: { restoreFocus?: boolean }) {
23
+ const triggerId = options?.restoreFocus ? activeTriggerId : null;
24
+ clearPendingTimers();
25
+ activeItem = null;
26
+ activeTriggerId = null;
27
+
28
+ if (!triggerId) return;
29
+
30
+ const trigger = document.getElementById(triggerId);
31
+ if (trigger instanceof HTMLElement) {
32
+ trigger.focus();
33
+ }
34
+ }
35
+
16
36
  setMegaMenuCtx({
17
37
  get activeItem() {
18
38
  return activeItem;
19
39
  },
20
- openItem(id) {
21
- clearTimeout(closeTimer);
22
- clearTimeout(openTimer);
40
+ openItem(id, triggerId) {
41
+ clearPendingTimers();
23
42
  openTimer = setTimeout(() => {
24
43
  activeItem = id;
44
+ activeTriggerId = triggerId;
25
45
  }, 150);
26
46
  },
27
47
  closeItem() {
28
- clearTimeout(openTimer);
29
- clearTimeout(closeTimer);
48
+ clearPendingTimers();
30
49
  closeTimer = setTimeout(() => {
31
50
  activeItem = null;
51
+ activeTriggerId = null;
32
52
  }, 300);
33
53
  }
34
54
  });
@@ -36,9 +56,7 @@
36
56
  function handleKeydown(e: KeyboardEvent) {
37
57
  if (e.key === 'Escape' && activeItem) {
38
58
  e.preventDefault();
39
- clearTimeout(openTimer);
40
- clearTimeout(closeTimer);
41
- activeItem = null;
59
+ closeImmediately({ restoreFocus: true });
42
60
  }
43
61
  }
44
62
 
@@ -46,16 +64,13 @@
46
64
  const nav = e.currentTarget as HTMLElement;
47
65
  const related = e.relatedTarget as Node | null;
48
66
  if (related && !nav.contains(related)) {
49
- clearTimeout(openTimer);
50
- clearTimeout(closeTimer);
51
- activeItem = null;
67
+ closeImmediately();
52
68
  }
53
69
  }
54
70
 
55
71
  $effect(() => {
56
72
  return () => {
57
- clearTimeout(openTimer);
58
- clearTimeout(closeTimer);
73
+ clearPendingTimers();
59
74
  };
60
75
  });
61
76
  </script>
@@ -7,8 +7,8 @@ export interface MenubarContext {
7
7
  registerMenu: (id: string) => void;
8
8
  unregisterMenu: (id: string) => void;
9
9
  getMenuIds: () => string[];
10
- focusNextMenu: (currentId: string) => void;
11
- focusPrevMenu: (currentId: string) => void;
10
+ focusNextMenu: (currentId: string, open?: boolean) => void;
11
+ focusPrevMenu: (currentId: string, open?: boolean) => void;
12
12
  }
13
13
  export declare const setMenubarCtx: (ctx: MenubarContext) => MenubarContext, getMenubarCtx: () => MenubarContext;
14
14
  export interface MenubarMenuContext {
@@ -8,10 +8,11 @@
8
8
  children: Snippet;
9
9
  }
10
10
 
11
- let { children, onclick, onkeydown, ...rest }: Props = $props();
11
+ let { children, onclick, onkeydown, id, ...rest }: Props = $props();
12
12
 
13
13
  const ctx = getMenubarCtx();
14
14
  const menuCtx = getMenubarMenuCtx();
15
+ const triggerId = $derived(id ?? `menubar-trigger-${menuCtx.menuId}`);
15
16
 
16
17
  function handleClick(e: MouseEvent & { currentTarget: HTMLButtonElement }) {
17
18
  if (menuCtx.open) {
@@ -32,12 +33,12 @@
32
33
  switch (e.key) {
33
34
  case 'ArrowRight': {
34
35
  e.preventDefault();
35
- ctx.focusNextMenu(menuCtx.menuId);
36
+ ctx.focusNextMenu(menuCtx.menuId, ctx.hasOpenMenu);
36
37
  break;
37
38
  }
38
39
  case 'ArrowLeft': {
39
40
  e.preventDefault();
40
- ctx.focusPrevMenu(menuCtx.menuId);
41
+ ctx.focusPrevMenu(menuCtx.menuId, ctx.hasOpenMenu);
41
42
  break;
42
43
  }
43
44
  case 'ArrowDown': {
@@ -59,6 +60,7 @@
59
60
  <Button
60
61
  variant="trigger"
61
62
  type="button"
63
+ id={triggerId}
62
64
  role="menuitem"
63
65
  aria-haspopup="menu"
64
66
  aria-expanded={menuCtx.open}
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { fromAction } from 'svelte/attachments';
3
4
  import type { HTMLAttributes } from 'svelte/elements';
4
5
  import { createAnchoredPopover, createMenuNavigation } from '@dryui/primitives';
5
6
  import { getMenubarCtx, getMenubarMenuCtx } from './context.svelte.js';
@@ -22,14 +23,21 @@
22
23
  const ctx = getMenubarCtx();
23
24
  const menuCtx = getMenubarMenuCtx();
24
25
 
25
- let el = $state<HTMLDivElement>();
26
- let triggerEl = $state<HTMLElement | null>(null);
26
+ let el = $state<HTMLDivElement | null>(null);
27
+ const triggerEl = $derived(
28
+ ctx.rootElement?.querySelector<HTMLElement>(`[data-menubar-trigger="${menuCtx.menuId}"]`) ??
29
+ null
30
+ );
27
31
 
28
- $effect(() => {
29
- const root = ctx.rootElement;
30
- triggerEl =
31
- root?.querySelector<HTMLElement>(`[data-menubar-trigger="${menuCtx.menuId}"]`) ?? null;
32
- });
32
+ function attachContent(node: HTMLDivElement) {
33
+ el = node;
34
+
35
+ return () => {
36
+ if (el === node) {
37
+ el = null;
38
+ }
39
+ };
40
+ }
33
41
 
34
42
  const popover = createAnchoredPopover({
35
43
  triggerEl: () => triggerEl,
@@ -61,12 +69,12 @@
61
69
  switch (e.key) {
62
70
  case 'ArrowRight': {
63
71
  e.preventDefault();
64
- ctx.focusNextMenu(menuCtx.menuId);
72
+ ctx.focusNextMenu(menuCtx.menuId, true);
65
73
  return;
66
74
  }
67
75
  case 'ArrowLeft': {
68
76
  e.preventDefault();
69
- ctx.focusPrevMenu(menuCtx.menuId);
77
+ ctx.focusPrevMenu(menuCtx.menuId, true);
70
78
  return;
71
79
  }
72
80
  case 'Escape': {
@@ -84,15 +92,15 @@
84
92
  </script>
85
93
 
86
94
  <div
87
- bind:this={el}
95
+ {@attach attachContent}
96
+ {@attach fromAction(popover.applyPosition, () => style)}
88
97
  popover="auto"
89
98
  role="menu"
90
99
  tabindex="-1"
91
- aria-labelledby={`menubar-trigger-${menuCtx.menuId}`}
100
+ aria-labelledby={triggerEl?.id}
92
101
  data-menubar-content
93
102
  data-state={menuCtx.open ? 'open' : 'closed'}
94
103
  class={className}
95
- use:popover.applyPosition={style}
96
104
  ontoggle={(e) => {
97
105
  const newState = (e as ToggleEvent).newState === 'open';
98
106
  if (!newState && menuCtx.open) {
@@ -48,20 +48,20 @@
48
48
  getMenuIds() {
49
49
  return menuIds;
50
50
  },
51
- focusNextMenu(currentId) {
51
+ focusNextMenu(currentId, open = false) {
52
52
  const idx = menuIds.indexOf(currentId);
53
53
  const nextIdx = (idx + 1) % menuIds.length;
54
54
  const nextId = menuIds[nextIdx] ?? null;
55
- activeMenu = nextId;
55
+ activeMenu = open ? nextId : null;
56
56
  requestAnimationFrame(() => {
57
57
  rootEl?.querySelector<HTMLButtonElement>(`[data-menubar-trigger="${nextId}"]`)?.focus();
58
58
  });
59
59
  },
60
- focusPrevMenu(currentId) {
60
+ focusPrevMenu(currentId, open = false) {
61
61
  const idx = menuIds.indexOf(currentId);
62
62
  const prevIdx = (idx - 1 + menuIds.length) % menuIds.length;
63
63
  const prevId = menuIds[prevIdx] ?? null;
64
- activeMenu = prevId;
64
+ activeMenu = open ? prevId : null;
65
65
  requestAnimationFrame(() => {
66
66
  rootEl?.querySelector<HTMLButtonElement>(`[data-menubar-trigger="${prevId}"]`)?.focus();
67
67
  });