@yewdiel21/motion-kit 0.16.0 → 0.18.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.
Files changed (158) hide show
  1. package/README.md +44 -0
  2. package/dist/auto/index.d.ts +23 -3
  3. package/dist/auto/index.js +43 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
  6. package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
  7. package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
  8. package/dist/chunk-Q7IW4FV3.js.map +1 -0
  9. package/dist/css/components/badge-button.css +16 -0
  10. package/dist/css/components/bento-grid.css +13 -0
  11. package/dist/css/components/drilldown-menu.css +134 -0
  12. package/dist/css/components/equalizer-slider.css +26 -0
  13. package/dist/css/components/handwriting-text.css +15 -0
  14. package/dist/css/components/magnetize-button.css +18 -0
  15. package/dist/css/components/morphing-page-dots.css +13 -0
  16. package/dist/css/components/not-found.css +22 -0
  17. package/dist/css/components/notification-stack.css +42 -0
  18. package/dist/css/components/particle-button.css +14 -0
  19. package/dist/css/components/radio-group.css +29 -0
  20. package/dist/css/components/rating-scale.css +23 -0
  21. package/dist/css/components/scroll-preview-dock.css +18 -0
  22. package/dist/css/components/select.css +16 -0
  23. package/dist/css/components/signature-pad.css +131 -0
  24. package/dist/css/components/stepper.css +36 -0
  25. package/dist/css/components/sticky-header.css +10 -0
  26. package/dist/css/components/temperature-slider.css +29 -0
  27. package/dist/css/components/voice-message-bubble.css +67 -0
  28. package/dist/css/components/waveform-player.css +51 -0
  29. package/dist/css/components/wheel-picker.css +18 -0
  30. package/dist/css/tokens.css +4 -0
  31. package/dist/elements/index.d.ts +292 -4
  32. package/dist/elements/index.js +856 -4
  33. package/dist/elements/index.js.map +1 -1
  34. package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
  35. package/dist/index.css +713 -0
  36. package/dist/index.d.ts +23 -23
  37. package/dist/index.js +43 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/react/index.d.ts +151 -7
  40. package/dist/react/index.js +893 -3
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
  43. package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
  44. package/package.json +110 -110
  45. package/registry.json +244 -0
  46. package/src/auto/auto-init.ts +339 -293
  47. package/src/components/badge-button/README.md +28 -0
  48. package/src/components/badge-button/badge-button.css +15 -0
  49. package/src/components/badge-button/element.ts +16 -0
  50. package/src/components/badge-button/engine.ts +85 -0
  51. package/src/components/badge-button/react.tsx +31 -0
  52. package/src/components/bento-grid/README.md +26 -0
  53. package/src/components/bento-grid/bento-grid.css +12 -0
  54. package/src/components/bento-grid/element.ts +22 -0
  55. package/src/components/bento-grid/engine.ts +153 -0
  56. package/src/components/bento-grid/react.tsx +25 -0
  57. package/src/components/drilldown-menu/README.md +163 -0
  58. package/src/components/drilldown-menu/drilldown-menu.css +133 -0
  59. package/src/components/drilldown-menu/element.ts +59 -0
  60. package/src/components/drilldown-menu/engine.ts +378 -0
  61. package/src/components/drilldown-menu/react.tsx +76 -0
  62. package/src/components/equalizer-slider/README.md +43 -0
  63. package/src/components/equalizer-slider/element.ts +41 -0
  64. package/src/components/equalizer-slider/engine.ts +32 -0
  65. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  66. package/src/components/equalizer-slider/react.tsx +60 -0
  67. package/src/components/equalizer-slider/shared.ts +161 -0
  68. package/src/components/handwriting-text/README.md +29 -0
  69. package/src/components/handwriting-text/element.ts +14 -0
  70. package/src/components/handwriting-text/engine.ts +108 -0
  71. package/src/components/handwriting-text/glyphs.ts +71 -0
  72. package/src/components/handwriting-text/handwriting-text.css +14 -0
  73. package/src/components/handwriting-text/react.tsx +23 -0
  74. package/src/components/magnetize-button/README.md +26 -0
  75. package/src/components/magnetize-button/element.ts +13 -0
  76. package/src/components/magnetize-button/engine.ts +84 -0
  77. package/src/components/magnetize-button/magnetize-button.css +17 -0
  78. package/src/components/magnetize-button/react.tsx +22 -0
  79. package/src/components/morphing-page-dots/README.md +28 -0
  80. package/src/components/morphing-page-dots/element.ts +16 -0
  81. package/src/components/morphing-page-dots/engine.ts +89 -0
  82. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  83. package/src/components/morphing-page-dots/react.tsx +32 -0
  84. package/src/components/not-found/README.md +26 -0
  85. package/src/components/not-found/element.ts +15 -0
  86. package/src/components/not-found/engine.ts +57 -0
  87. package/src/components/not-found/not-found.css +21 -0
  88. package/src/components/not-found/react.tsx +23 -0
  89. package/src/components/notification-stack/README.md +24 -0
  90. package/src/components/notification-stack/element.ts +26 -0
  91. package/src/components/notification-stack/engine.ts +329 -0
  92. package/src/components/notification-stack/notification-stack.css +41 -0
  93. package/src/components/notification-stack/react.tsx +28 -0
  94. package/src/components/particle-button/README.md +26 -0
  95. package/src/components/particle-button/element.ts +13 -0
  96. package/src/components/particle-button/engine.ts +52 -0
  97. package/src/components/particle-button/particle-button.css +13 -0
  98. package/src/components/particle-button/react.tsx +22 -0
  99. package/src/components/radio-group/README.md +43 -0
  100. package/src/components/radio-group/element.ts +41 -0
  101. package/src/components/radio-group/engine.ts +11 -0
  102. package/src/components/radio-group/radio-group.css +28 -0
  103. package/src/components/radio-group/react.tsx +60 -0
  104. package/src/components/radio-group/shared.ts +118 -0
  105. package/src/components/rating-scale/README.md +43 -0
  106. package/src/components/rating-scale/element.ts +41 -0
  107. package/src/components/rating-scale/engine.ts +63 -0
  108. package/src/components/rating-scale/rating-scale.css +22 -0
  109. package/src/components/rating-scale/react.tsx +60 -0
  110. package/src/components/scroll-preview-dock/README.md +24 -0
  111. package/src/components/scroll-preview-dock/element.ts +20 -0
  112. package/src/components/scroll-preview-dock/engine.ts +228 -0
  113. package/src/components/scroll-preview-dock/react.tsx +26 -0
  114. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  115. package/src/components/select/README.md +23 -9
  116. package/src/components/select/select.css +16 -0
  117. package/src/components/signature-pad/README.md +97 -0
  118. package/src/components/signature-pad/element.ts +57 -0
  119. package/src/components/signature-pad/engine.ts +550 -0
  120. package/src/components/signature-pad/react.tsx +56 -0
  121. package/src/components/signature-pad/signature-pad.css +130 -0
  122. package/src/components/stepper/README.md +46 -0
  123. package/src/components/stepper/element.ts +42 -0
  124. package/src/components/stepper/engine.ts +103 -0
  125. package/src/components/stepper/react.tsx +60 -0
  126. package/src/components/stepper/stepper.css +35 -0
  127. package/src/components/sticky-header/README.md +30 -0
  128. package/src/components/sticky-header/element.ts +19 -0
  129. package/src/components/sticky-header/engine.ts +95 -0
  130. package/src/components/sticky-header/react.tsx +25 -0
  131. package/src/components/sticky-header/sticky-header.css +9 -0
  132. package/src/components/temperature-slider/README.md +42 -0
  133. package/src/components/temperature-slider/element.ts +53 -0
  134. package/src/components/temperature-slider/engine.ts +44 -0
  135. package/src/components/temperature-slider/react.tsx +66 -0
  136. package/src/components/temperature-slider/temperature-slider.css +29 -0
  137. package/src/components/voice-message-bubble/README.md +31 -0
  138. package/src/components/voice-message-bubble/element.ts +20 -0
  139. package/src/components/voice-message-bubble/engine.ts +79 -0
  140. package/src/components/voice-message-bubble/react.tsx +27 -0
  141. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
  142. package/src/components/waveform-player/README.md +30 -0
  143. package/src/components/waveform-player/element.ts +21 -0
  144. package/src/components/waveform-player/engine.ts +344 -0
  145. package/src/components/waveform-player/react.tsx +27 -0
  146. package/src/components/waveform-player/waveform-player.css +50 -0
  147. package/src/components/wheel-picker/README.md +35 -0
  148. package/src/components/wheel-picker/element.ts +25 -0
  149. package/src/components/wheel-picker/engine.ts +239 -0
  150. package/src/components/wheel-picker/react.tsx +32 -0
  151. package/src/components/wheel-picker/wheel-picker.css +17 -0
  152. package/src/css/tokens.css +4 -0
  153. package/src/elements/index.ts +318 -258
  154. package/src/index.ts +21 -0
  155. package/src/react/index.ts +192 -171
  156. package/dist/chunk-SDQ776LG.js.map +0 -1
  157. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
  158. /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
@@ -0,0 +1,133 @@
1
+ /* Drilldown menu: a single live level travels in the navigation direction. */
2
+ .mk-drilldown-menu,
3
+ mk-drilldown-menu {
4
+ --mk-drilldown-radius: 28px;
5
+ --mk-drilldown-item-radius: 18px;
6
+ display: block;
7
+ box-sizing: border-box;
8
+ width: 100%;
9
+ max-width: 26rem;
10
+ min-width: 0;
11
+ padding: 12px;
12
+ border: 0;
13
+ border-radius: var(--mk-drilldown-radius);
14
+ background: var(--mk-surface-1);
15
+ color: var(--mk-text);
16
+ font-family: var(--md-sys-typescale-body-large-font, inherit);
17
+ font-family: var(--mk-font);
18
+ }
19
+
20
+ [data-mk-drilldown-header] {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 8px;
24
+ min-height: 48px;
25
+ padding: 4px 10px;
26
+ }
27
+
28
+ [data-mk-drilldown-heading] {
29
+ min-width: 0;
30
+ overflow-wrap: anywhere;
31
+ font-size: 1.1rem;
32
+ font-weight: 650;
33
+ letter-spacing: -0.025em;
34
+ }
35
+
36
+ [data-mk-drilldown-back] {
37
+ display: inline-flex;
38
+ flex: none;
39
+ align-items: center;
40
+ justify-content: center;
41
+ width: 36px;
42
+ height: 36px;
43
+ padding: 0;
44
+ border: 0;
45
+ border-radius: 50%;
46
+ background: var(--mk-surface-3);
47
+ color: var(--mk-text);
48
+ cursor: pointer;
49
+ }
50
+
51
+ [data-mk-drilldown-back] > svg { transform: rotate(180deg); }
52
+ [data-mk-drilldown-back][hidden],
53
+ [data-mk-drilldown-empty][hidden] { display: none; }
54
+
55
+ [data-mk-drilldown-breadcrumb] ol {
56
+ display: flex;
57
+ flex-wrap: wrap;
58
+ align-items: center;
59
+ gap: 4px;
60
+ margin: 0;
61
+ padding: 0 12px 12px;
62
+ list-style: none;
63
+ color: var(--mk-muted);
64
+ font-size: 0.75rem;
65
+ line-height: 1.5;
66
+ }
67
+
68
+ [data-mk-drilldown-breadcrumb] li {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 4px;
72
+ min-width: 0;
73
+ overflow-wrap: anywhere;
74
+ }
75
+
76
+ [data-mk-drilldown-breadcrumb] li + li::before { content: '/'; opacity: 0.5; }
77
+ [data-mk-drilldown-breadcrumb] [aria-current] { color: var(--mk-text); }
78
+ [data-mk-drilldown-crumb] {
79
+ padding: 2px;
80
+ border: 0;
81
+ border-radius: 4px;
82
+ background: transparent;
83
+ color: inherit;
84
+ font: inherit;
85
+ cursor: pointer;
86
+ }
87
+
88
+ [data-mk-drilldown-viewport] { position: relative; isolation: isolate; overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
89
+ [data-mk-drilldown-highlight] { position: absolute; top: 0; left: 0; z-index: -1; border-radius: var(--mk-radius-lg); background: var(--mk-surface-3); pointer-events: none; opacity: 0; }
90
+ [data-mk-drilldown-level] { position: relative; display: grid; gap: 6px; }
91
+ [data-mk-drilldown-item] {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 18px;
96
+ width: 100%;
97
+ min-height: 58px;
98
+ padding: 13px 15px;
99
+ border: 0;
100
+ border-radius: var(--mk-drilldown-item-radius);
101
+ background: transparent;
102
+ color: var(--mk-text);
103
+ font: inherit;
104
+ font-size: 0.9rem;
105
+ line-height: 1.4;
106
+ text-align: start;
107
+ cursor: pointer;
108
+ transition: color .15s, border-radius .3s var(--mk-ease-playful);
109
+ }
110
+
111
+ [data-mk-drilldown-item]:enabled:hover,
112
+ [data-mk-drilldown-item]:focus-visible { background: color-mix(in srgb, var(--mk-text) 3%, transparent); border-radius: var(--mk-radius-xl); }
113
+ [data-mk-drilldown-item][data-mk-chosen]::after { content: '✓'; display: grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; color: var(--mk-on-accent); background: var(--mk-accent); }
114
+ [data-mk-drilldown-item]:disabled { opacity: 0.42; cursor: default; }
115
+ [data-mk-drilldown-text] { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
116
+ [data-mk-drilldown-label] { font-weight: 550; }
117
+ [data-mk-drilldown-description] { color: var(--mk-muted); font-size: 0.8rem; }
118
+ [data-mk-drilldown-arrow] { display: inline-flex; flex: none; color: var(--mk-muted); }
119
+ [data-mk-drilldown-arrow] svg { transition: transform .35s var(--mk-ease-swatch); }
120
+ [data-mk-highlighted] [data-mk-drilldown-arrow] svg { transform: translateX(3px); }
121
+ [data-mk-drilldown-empty] { margin: 0; padding: 24px 16px; color: var(--mk-muted); font-size: 0.875rem; }
122
+
123
+ [data-mk-drilldown-item]:focus-visible,
124
+ [data-mk-drilldown-back]:focus-visible,
125
+ [data-mk-drilldown-crumb]:focus-visible,
126
+ [data-mk-drilldown-level]:focus-visible {
127
+ outline: 2px solid var(--mk-accent);
128
+ outline-offset: -2px;
129
+ }
130
+
131
+ @media (prefers-reduced-motion: reduce) {
132
+ [data-mk-drilldown-item] { transition: none; }
133
+ }
@@ -0,0 +1,59 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createDrilldownMenu, type DrilldownMenuInstance, type DrilldownMenuItem, type DrilldownMenuOptions } from './engine';
3
+
4
+ /** Light DOM action menu; items/path accept JSON. Emits mk:select and mk:path-change. */
5
+ export class MkDrilldownMenuElement extends MkElement<DrilldownMenuInstance> {
6
+ static options: Record<string, OptionType> = { items: 'json', path: 'json', label: 'string', backLabel: 'string', emptyText: 'string' };
7
+ private pendingItems: DrilldownMenuItem[] | undefined;
8
+ private pendingPath: string[] | undefined;
9
+
10
+ protected create(options: Record<string, unknown>) {
11
+ return createDrilldownMenu(this, {
12
+ ...options,
13
+ ...(this.pendingItems !== undefined ? { items: this.pendingItems } : {}),
14
+ ...(this.pendingPath !== undefined ? { path: this.pendingPath } : {}),
15
+ } as DrilldownMenuOptions);
16
+ }
17
+
18
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
19
+ if (previous === next) return;
20
+ if (name === 'items' || name === 'path') {
21
+ try {
22
+ const value: unknown = JSON.parse(next ?? '[]');
23
+ if (!Array.isArray(value)) return;
24
+ if (name === 'items') {
25
+ this.pendingItems = value as DrilldownMenuItem[];
26
+ this.instance?.setItems(this.pendingItems);
27
+ } else {
28
+ this.pendingPath = value as string[];
29
+ this.instance?.setPath(this.pendingPath);
30
+ }
31
+ } catch {
32
+ // Invalid JSON leaves the last valid data and path intact.
33
+ }
34
+ } else if (this.instance) {
35
+ const { label, backLabel, emptyText } = this.options;
36
+ this.instance.update({ label, backLabel, emptyText } as DrilldownMenuOptions);
37
+ }
38
+ }
39
+
40
+ get items() { return this.pendingItems ?? (this.options.items as DrilldownMenuItem[] | undefined) ?? []; }
41
+ set items(items: DrilldownMenuItem[]) {
42
+ this.pendingItems = items;
43
+ this.instance?.setItems(items);
44
+ }
45
+ get path() { return this.instance?.path ?? this.pendingPath ?? (this.options.path as string[] | undefined) ?? []; }
46
+ set path(path: string[]) {
47
+ this.pendingPath = path;
48
+ this.instance?.setPath(path);
49
+ }
50
+ open(id: string) { return this.instance?.open(id) ?? false; }
51
+ back() { return this.instance?.back() ?? false; }
52
+ root() { this.instance?.root(); }
53
+ reset() { this.instance?.reset(); }
54
+ setItems(items: DrilldownMenuItem[]) { this.items = items; }
55
+ setPath(path: string[]) { this.path = path; }
56
+ focusItem(id: string) { this.instance?.focusItem(id); }
57
+ }
58
+
59
+ export const defineDrilldownMenu = () => defineElement('mk-drilldown-menu', MkDrilldownMenuElement);
@@ -0,0 +1,378 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { press, ringPulse } from '../../motion/micro';
6
+
7
+ export interface DrilldownMenuItem {
8
+ /** Unique among siblings; IDs form the navigation path. */
9
+ id: string;
10
+ label: string;
11
+ description?: string;
12
+ disabled?: boolean;
13
+ /** Present (even an empty array) for a level; absent for an action. */
14
+ children?: DrilldownMenuItem[];
15
+ }
16
+
17
+ export interface DrilldownMenuOptions {
18
+ items?: DrilldownMenuItem[];
19
+ /** IDs of branches from the root to the initial level. */
20
+ path?: string[];
21
+ /** Root heading and accessible name (default 'Actions'). */
22
+ label?: string;
23
+ backLabel?: string;
24
+ emptyText?: string;
25
+ /** An action's complete path, including the action's ID. */
26
+ onSelect?: (item: DrilldownMenuItem, path: string[]) => void;
27
+ /** Navigation only; the path contains branch IDs. */
28
+ onPathChange?: (path: string[]) => void;
29
+ }
30
+
31
+ export interface DrilldownMenuInstance extends Instance {
32
+ /** Open a branch in the current level; false for missing, disabled or leaf items. */
33
+ open(id: string): boolean;
34
+ back(): boolean;
35
+ root(): void;
36
+ reset(): void;
37
+ /** Invalid, disabled or non-branch IDs truncate the requested path safely. */
38
+ setPath(path: string[], notify?: boolean): void;
39
+ setItems(items: DrilldownMenuItem[]): void;
40
+ update(options: Partial<DrilldownMenuOptions>): void;
41
+ focusItem(id: string): void;
42
+ readonly path: string[];
43
+ /** Items in the current level. */
44
+ readonly items: DrilldownMenuItem[];
45
+ readonly element: HTMLElement;
46
+ }
47
+
48
+ const CHEVRON = '<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
49
+ let sequence = 0;
50
+ const samePath = (a: string[], b: string[]) => a.length === b.length && a.every((id, i) => id === b[i]);
51
+ const branch = (item: DrilldownMenuItem) => Array.isArray(item.children);
52
+ const copyItems = (items: DrilldownMenuItem[]): DrilldownMenuItem[] => items.map((item) => ({ ...item, ...(branch(item) ? { children: copyItems(item.children!) } : {}) }));
53
+
54
+ /** A single live level: directional slide + blur, roving menu focus, and reversible DOM ownership. */
55
+ export function createDrilldownMenu(root: HTMLElement | null, options: DrilldownMenuOptions = {}): DrilldownMenuInstance {
56
+ if (!root) return inert({ open: () => false, back: () => false, root() {}, reset() {}, setPath() {}, setItems() {}, update() {}, focusItem() {}, path: [], items: [], element: null as unknown as HTMLElement });
57
+ registerMotion();
58
+ const scope = createScope();
59
+ const uid = `mk-drilldown-${++sequence}`;
60
+ const authored = [...root.childNodes];
61
+ // Keep the authored nodes themselves, including any listeners, for destroy().
62
+ const holder = document.createDocumentFragment();
63
+ holder.append(...authored);
64
+ let config = { ...options };
65
+ let data = copyItems(options.items ?? []);
66
+ let path: string[] = [];
67
+ let focused: string | null = null;
68
+ const remembered = new Map<string, string>();
69
+ let animation: gsap.core.Timeline | null = null;
70
+ let hoverMotion: gsap.core.Tween | null = null;
71
+ const effects = new Set<gsap.core.Animation>();
72
+ const keep = (effect: gsap.core.Animation | null) => {
73
+ for (const animation of effects) if (animation.totalProgress() === 1) effects.delete(animation);
74
+ if (effect) effects.add(effect);
75
+ };
76
+
77
+ const header = document.createElement('div');
78
+ header.dataset.mkDrilldownHeader = '';
79
+ const backButton = document.createElement('button');
80
+ backButton.type = 'button';
81
+ backButton.dataset.mkDrilldownBack = '';
82
+ backButton.innerHTML = CHEVRON;
83
+ const heading = document.createElement('span');
84
+ heading.id = `${uid}-heading`;
85
+ heading.dataset.mkDrilldownHeading = '';
86
+ header.append(backButton, heading);
87
+ const breadcrumb = document.createElement('nav');
88
+ breadcrumb.dataset.mkDrilldownBreadcrumb = '';
89
+ const crumbs = document.createElement('ol');
90
+ breadcrumb.append(crumbs);
91
+ const viewport = document.createElement('div');
92
+ viewport.dataset.mkDrilldownViewport = '';
93
+ const menu = document.createElement('div');
94
+ menu.dataset.mkDrilldownLevel = '';
95
+ menu.id = `${uid}-level`;
96
+ menu.setAttribute('role', 'menu');
97
+ menu.setAttribute('aria-labelledby', heading.id);
98
+ const empty = document.createElement('p');
99
+ empty.dataset.mkDrilldownEmpty = '';
100
+ empty.setAttribute('role', 'status');
101
+ const highlight = document.createElement('span');
102
+ highlight.dataset.mkDrilldownHighlight = ''; highlight.setAttribute('aria-hidden', 'true');
103
+ viewport.append(highlight, menu, empty);
104
+ backButton.setAttribute('aria-controls', menu.id);
105
+ root.append(header, breadcrumb, viewport);
106
+ scope.attr(root, 'data-mk-ready', '');
107
+
108
+ const levelAt = (requested: string[]) => {
109
+ const valid: string[] = [];
110
+ const ancestors: DrilldownMenuItem[] = [];
111
+ let items = data;
112
+ for (const id of requested) {
113
+ const item = items.find((entry) => entry.id === id);
114
+ if (!item || item.disabled || !branch(item)) break;
115
+ valid.push(id);
116
+ ancestors.push(item);
117
+ items = item.children!;
118
+ }
119
+ return { valid, ancestors, items };
120
+ };
121
+ const level = () => levelAt(path);
122
+ const enabled = () => [...menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')].filter((item) => !item.disabled);
123
+ const buttonOf = (id: string) => enabled().find((item) => item.dataset.mkDrilldownItem === id);
124
+ const highlightItem = (button?: HTMLButtonElement | null) => {
125
+ hoverMotion?.kill();
126
+ for (const item of enabled()) item.toggleAttribute('data-mk-highlighted', item === button);
127
+ if (!button) { highlight.style.opacity = '0'; return; }
128
+ const geometry = { x: button.offsetLeft + menu.offsetLeft, y: button.offsetTop + menu.offsetTop, width: button.offsetWidth, height: button.offsetHeight, opacity: 1 };
129
+ if (reduced()) gsap.set(highlight, geometry);
130
+ else hoverMotion = gsap.to(highlight, { ...geometry, duration: .35, ease: EASE.swatch });
131
+ };
132
+ const cancelMotion = () => {
133
+ animation?.kill();
134
+ hoverMotion?.kill();
135
+ animation = null;
136
+ gsap.set([menu, heading, breadcrumb, ...menu.children], { clearProps: 'transform,opacity,filter' });
137
+ gsap.set(viewport, { clearProps: 'height' });
138
+ };
139
+ const roving = (id: string | null, focus = false) => {
140
+ const items = enabled();
141
+ const target = items.find((item) => item.dataset.mkDrilldownItem === id) ?? items[0];
142
+ focused = target?.dataset.mkDrilldownItem ?? null;
143
+ for (const item of menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')) item.tabIndex = item === target ? 0 : -1;
144
+ // Empty/all-disabled levels remain a keyboard stop so Left/Escape can return.
145
+ menu.tabIndex = target ? -1 : 0;
146
+ if (focused) remembered.set(JSON.stringify(path), focused);
147
+ if (focus) (target ?? menu).focus({ preventScroll: true });
148
+ if (focus) highlightItem(target);
149
+ };
150
+ const notifyPath = () => {
151
+ const next = [...path];
152
+ root.dispatchEvent(new CustomEvent('mk:path-change', { bubbles: true, detail: { path: [...next] } }));
153
+ config.onPathChange?.(next);
154
+ };
155
+
156
+ const render = (direction: number, focus: boolean, preferred?: string | null) => {
157
+ const oldHeight = viewport.getBoundingClientRect().height;
158
+ cancelMotion();
159
+ highlightItem(null);
160
+ const current = level();
161
+ const label = config.label ?? 'Actions';
162
+ const title = current.ancestors.at(-1)?.label ?? label;
163
+ heading.textContent = title;
164
+ backButton.hidden = path.length === 0;
165
+ backButton.setAttribute('aria-label', config.backLabel ?? 'Back');
166
+ breadcrumb.setAttribute('aria-label', `${label} path`);
167
+ // At the root the heading already names the only crumb.
168
+ breadcrumb.hidden = path.length === 0;
169
+ const labels = [label, ...current.ancestors.map((item) => item.label)];
170
+ crumbs.replaceChildren(...labels.map((text, index) => {
171
+ const li = document.createElement('li');
172
+ if (index === labels.length - 1) {
173
+ const span = document.createElement('span');
174
+ span.setAttribute('aria-current', 'location');
175
+ span.textContent = text;
176
+ li.append(span);
177
+ } else {
178
+ const button = document.createElement('button');
179
+ button.type = 'button';
180
+ button.dataset.mkDrilldownCrumb = String(index);
181
+ button.textContent = text;
182
+ button.setAttribute('aria-controls', menu.id);
183
+ li.append(button);
184
+ }
185
+ return li;
186
+ }));
187
+ menu.replaceChildren(...current.items.map((item, index) => {
188
+ const button = document.createElement('button');
189
+ button.type = 'button';
190
+ button.dataset.mkDrilldownItem = item.id;
191
+ button.setAttribute('role', 'menuitem');
192
+ button.disabled = Boolean(item.disabled);
193
+ if (item.disabled) button.setAttribute('aria-disabled', 'true');
194
+ const text = document.createElement('span');
195
+ text.dataset.mkDrilldownText = '';
196
+ const name = document.createElement('span');
197
+ name.id = `${uid}-item-${index}`;
198
+ name.dataset.mkDrilldownLabel = '';
199
+ name.textContent = item.label;
200
+ button.setAttribute('aria-labelledby', name.id);
201
+ text.append(name);
202
+ if (item.description) {
203
+ const description = document.createElement('span');
204
+ description.id = `${uid}-description-${index}`;
205
+ description.dataset.mkDrilldownDescription = '';
206
+ description.textContent = item.description;
207
+ button.setAttribute('aria-describedby', description.id);
208
+ text.append(description);
209
+ }
210
+ button.append(text);
211
+ if (branch(item)) {
212
+ button.setAttribute('aria-haspopup', 'menu');
213
+ const arrow = document.createElement('span');
214
+ arrow.dataset.mkDrilldownArrow = '';
215
+ arrow.innerHTML = CHEVRON;
216
+ button.append(arrow);
217
+ }
218
+ return button;
219
+ }));
220
+ empty.textContent = config.emptyText ?? 'No actions available';
221
+ empty.hidden = current.items.length !== 0;
222
+ roving(preferred ?? remembered.get(JSON.stringify(path)) ?? null);
223
+ if (direction && !reduced()) {
224
+ // There is only one live menu. Replacing its content before animating avoids
225
+ // duplicated menuitems and allows rapid navigation without stale callbacks.
226
+ animation = gsap.timeline({ onComplete: () => { animation = null; } })
227
+ .fromTo(menu, { x: 28 * direction, opacity: 0, filter: 'blur(6px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.36, ease: EASE.signature, clearProps: 'transform,opacity,filter' });
228
+ animation.fromTo([heading, breadcrumb], { y: -6, opacity: .3, filter: 'blur(6px)' }, { y: 0, opacity: 1, filter: 'blur(0px)', duration: .3, ease: EASE.pop, clearProps: 'transform,opacity,filter' }, 0)
229
+ .fromTo([...menu.children], { x: 12 * direction, opacity: 0 }, { x: 0, opacity: 1, duration: .3, stagger: .035, ease: EASE.swatchLight, clearProps: 'transform,opacity' }, .04);
230
+ const nextHeight = viewport.getBoundingClientRect().height;
231
+ if (oldHeight > 0 && nextHeight > 0 && oldHeight !== nextHeight) {
232
+ animation.fromTo(viewport, { height: oldHeight }, { height: nextHeight, duration: 0.36, ease: EASE.signature, clearProps: 'height' }, 0);
233
+ }
234
+ }
235
+ // Transform measurement may temporarily detach a zero-size element. Restore
236
+ // focus after GSAP has measured, including in hidden surfaces and jsdom.
237
+ if (focus) roving(focused, true);
238
+ };
239
+
240
+ const navigate = (requested: string[], notify: boolean, focus: boolean, preferred?: string | null) => {
241
+ if (scope.disposed) return false;
242
+ const next = levelAt(requested).valid;
243
+ if (samePath(path, next)) return false;
244
+ const previous = path;
245
+ if (focused) remembered.set(JSON.stringify(previous), focused);
246
+ const direction = next.length > path.length ? 1 : -1;
247
+ path = next;
248
+ render(direction, focus, preferred);
249
+ if (notify) notifyPath();
250
+ return true;
251
+ };
252
+ const open = (id: string) => {
253
+ if (scope.disposed) return false;
254
+ const item = level().items.find((entry) => entry.id === id);
255
+ if (!item || item.disabled || !branch(item)) return false;
256
+ return navigate([...path, id], true, true);
257
+ };
258
+ const back = () => path.length ? navigate(path.slice(0, -1), true, true, path.at(-1)) : false;
259
+ const reset = () => navigate([], true, root.contains(document.activeElement), path[0]);
260
+ const activate = (id: string) => {
261
+ const item = level().items.find((entry) => entry.id === id);
262
+ if (!item || item.disabled) return;
263
+ if (branch(item)) return void open(id);
264
+ const button = buttonOf(id);
265
+ if (button) {
266
+ for (const row of enabled()) row.toggleAttribute('data-mk-chosen', row === button);
267
+ keep(press(button)); keep(ringPulse(button));
268
+ }
269
+ const selected = copyItems([item])[0]!;
270
+ const selectedPath = [...path, item.id];
271
+ root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { item: selected, path: [...selectedPath] } }));
272
+ config.onSelect?.(copyItems([item])[0]!, [...selectedPath]);
273
+ };
274
+
275
+ scope.on(menu, 'click', (event) => {
276
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
277
+ if (!button || !menu.contains(button) || button.disabled) return;
278
+ roving(button.dataset.mkDrilldownItem!, false);
279
+ activate(button.dataset.mkDrilldownItem!);
280
+ });
281
+ scope.on(menu, 'focusin', (event) => {
282
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
283
+ if (button && !button.disabled) { roving(button.dataset.mkDrilldownItem!, false); highlightItem(button); }
284
+ });
285
+ scope.on(menu, 'pointerover', event => {
286
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
287
+ if (button && !button.disabled) highlightItem(button);
288
+ });
289
+ scope.on(menu, 'pointerleave', () => highlightItem(menu.contains(document.activeElement) ? buttonOf(focused ?? '') : null));
290
+ if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(() => {
291
+ const current = menu.querySelector<HTMLButtonElement>('[data-mk-highlighted]');
292
+ if (current) highlightItem(current);
293
+ })).observe(menu);
294
+ scope.on(root, 'keydown', (event) => {
295
+ const key = (event as KeyboardEvent).key;
296
+ if ((key === 'ArrowLeft' || key === 'Escape') && path.length) {
297
+ event.preventDefault();
298
+ event.stopPropagation();
299
+ back();
300
+ return;
301
+ }
302
+ if (!menu.contains(event.target as Node)) return;
303
+ const items = enabled();
304
+ const at = items.indexOf(document.activeElement as HTMLButtonElement);
305
+ const moves: Record<string, number> = { ArrowDown: (at + 1) % items.length, ArrowUp: at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length, Home: 0, End: items.length - 1 };
306
+ if (key in moves && items.length) {
307
+ event.preventDefault();
308
+ roving(items[moves[key]]!.dataset.mkDrilldownItem!, true);
309
+ return;
310
+ }
311
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
312
+ if (!button || button.disabled || !menu.contains(button)) return;
313
+ if (key === 'Enter' || key === ' ' || key === 'ArrowRight') {
314
+ const item = level().items.find((entry) => entry.id === button.dataset.mkDrilldownItem);
315
+ if (key === 'ArrowRight' && (!item || !branch(item))) return;
316
+ event.preventDefault();
317
+ activate(button.dataset.mkDrilldownItem!);
318
+ }
319
+ });
320
+ scope.on(backButton, 'click', () => { keep(press(backButton)); back(); });
321
+ scope.on(breadcrumb, 'click', (event) => {
322
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-crumb]');
323
+ if (!button || !breadcrumb.contains(button)) return;
324
+ const depth = Number(button.dataset.mkDrilldownCrumb);
325
+ navigate(path.slice(0, depth), true, true, path[depth]);
326
+ });
327
+ scope.add(() => {
328
+ cancelMotion();
329
+ for (const effect of effects) effect.kill();
330
+ gsap.killTweensOf([heading, breadcrumb, highlight]);
331
+ remembered.clear();
332
+ header.remove();
333
+ breadcrumb.remove();
334
+ viewport.remove();
335
+ root.append(holder);
336
+ });
337
+ path = levelAt(options.path ?? []).valid;
338
+ render(0, false);
339
+
340
+ const update = (next: Partial<DrilldownMenuOptions>) => {
341
+ if (scope.disposed) return;
342
+ const active = document.activeElement as HTMLElement | null;
343
+ const hadFocus = root.contains(active);
344
+ const backFocused = active === backButton;
345
+ const crumbFocused = active?.dataset.mkDrilldownCrumb;
346
+ const previousPath = path;
347
+ const previousFocus = focused;
348
+ config = { ...config, ...next };
349
+ if (next.items !== undefined) data = copyItems(next.items);
350
+ path = levelAt(next.path ?? path).valid;
351
+ const changed = !samePath(previousPath, path);
352
+ const preferred = changed ? previousPath[path.length] : previousFocus;
353
+ render(0, hadFocus, preferred);
354
+ if (hadFocus && !changed) {
355
+ if (backFocused && !backButton.hidden) backButton.focus({ preventScroll: true });
356
+ else if (crumbFocused !== undefined) {
357
+ [...crumbs.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-crumb]')]
358
+ .find((button) => button.dataset.mkDrilldownCrumb === crumbFocused)?.focus({ preventScroll: true });
359
+ }
360
+ }
361
+ // Updating data may invalidate a controlled path; report the repaired path.
362
+ if (changed && next.path === undefined) notifyPath();
363
+ };
364
+ return {
365
+ open,
366
+ back,
367
+ root: reset,
368
+ reset,
369
+ setPath: (next, notify = false) => { navigate(next, notify, root.contains(document.activeElement)); },
370
+ setItems: (items) => update({ items }),
371
+ update,
372
+ focusItem(id) { if (!scope.disposed && buttonOf(id)) roving(id, true); },
373
+ get path() { return [...path]; },
374
+ get items() { return copyItems(level().items); },
375
+ element: root,
376
+ destroy: () => scope.dispose(),
377
+ };
378
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useImperativeHandle, useReducer, useRef, type HTMLAttributes } from 'react';
4
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
5
+ import { createDrilldownMenu, type DrilldownMenuInstance, type DrilldownMenuItem, type DrilldownMenuOptions } from './engine';
6
+
7
+ export interface DrilldownMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'dangerouslySetInnerHTML' | 'onSelect'>, Omit<DrilldownMenuOptions, 'items' | 'path'> {
8
+ items: DrilldownMenuItem[];
9
+ /** Controlled branch path; pair with onPathChange. */
10
+ path?: string[];
11
+ /** Initial branch path when uncontrolled. */
12
+ defaultPath?: string[];
13
+ }
14
+
15
+ export type DrilldownMenuHandle = Pick<DrilldownMenuInstance, 'open' | 'back' | 'root' | 'reset' | 'setPath' | 'focusItem'>;
16
+
17
+ /** Data-driven hierarchical actions with controlled or internal navigation. */
18
+ export const DrilldownMenu = forwardRef<DrilldownMenuHandle, DrilldownMenuProps>(function DrilldownMenu(
19
+ { items, path, defaultPath, label, backLabel, emptyText, onSelect, onPathChange, className, ...props },
20
+ handle,
21
+ ) {
22
+ const { children: _children, dangerouslySetInnerHTML: _html, ...rootProps } = props as HTMLAttributes<HTMLDivElement>;
23
+ const ref = useRef<HTMLDivElement>(null);
24
+ const instance = useRef<DrilldownMenuInstance | null>(null);
25
+ const select = useLatest(onSelect);
26
+ const changed = useLatest(onPathChange);
27
+ const controlled = useLatest(path);
28
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
29
+ useImperativeHandle(handle, () => ({
30
+ open: (id) => instance.current?.open(id) ?? false,
31
+ back: () => instance.current?.back() ?? false,
32
+ root: () => instance.current?.root(),
33
+ reset: () => instance.current?.reset(),
34
+ setPath: (next, notify) => {
35
+ instance.current?.setPath(next, notify);
36
+ // Silent engine synchronization does not invoke onPathChange. Controlled
37
+ // refs must still reconcile with the parent's current path on a render.
38
+ if (controlled.current !== undefined) refresh();
39
+ },
40
+ focusItem: (id) => instance.current?.focusItem(id),
41
+ }), []);
42
+ const itemsKey = JSON.stringify(items);
43
+ useIsomorphicLayoutEffect(() => {
44
+ const created = createDrilldownMenu(ref.current, {
45
+ items,
46
+ path: path ?? defaultPath,
47
+ label,
48
+ backLabel,
49
+ emptyText,
50
+ onSelect: (item, selectedPath) => select.current?.(item, selectedPath),
51
+ onPathChange: (next) => {
52
+ changed.current?.(next);
53
+ // Also render if a controlled parent declines the navigation request.
54
+ if (controlled.current !== undefined) refresh();
55
+ },
56
+ });
57
+ instance.current = created;
58
+ return () => {
59
+ created.destroy();
60
+ instance.current = null;
61
+ };
62
+ // The engine owns its DOM for this mount; subsequent props update it below.
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, []);
65
+ useIsomorphicLayoutEffect(() => {
66
+ // Controlled data and its path are one external model. Applying the path in
67
+ // this update avoids repairing a stale level against replacement items.
68
+ instance.current?.update({ items, label, backLabel, emptyText, ...(path !== undefined ? { path } : {}) });
69
+ // JSON keys also catch edited data without forcing redraws on equal arrays.
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [itemsKey, label, backLabel, emptyText]);
72
+ useIsomorphicLayoutEffect(() => {
73
+ if (path !== undefined) instance.current?.setPath(path);
74
+ });
75
+ return <div {...rootProps} ref={ref} className={['mk-drilldown-menu', className].filter(Boolean).join(' ')} />;
76
+ });