@yewdiel21/motion-kit 0.17.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 (146) hide show
  1. package/README.md +23 -0
  2. package/dist/auto/index.d.ts +18 -3
  3. package/dist/auto/index.js +32 -2
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-VJFPTLAK.js → chunk-AWDEKDEJ.js} +2 -2
  6. package/dist/{chunk-BZLMCHUD.js → chunk-EDMXWUW3.js} +2 -2
  7. package/dist/{chunk-7IQTMF7Y.js → chunk-Q7IW4FV3.js} +2580 -58
  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 +11 -7
  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 +130 -103
  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 +62 -45
  28. package/dist/css/components/waveform-player.css +49 -34
  29. package/dist/css/components/wheel-picker.css +16 -15
  30. package/dist/css/tokens.css +4 -0
  31. package/dist/elements/index.d.ts +206 -4
  32. package/dist/elements/index.js +581 -6
  33. package/dist/elements/index.js.map +1 -1
  34. package/dist/{engine-Bn6hC87X.d.ts → engine-t8d7brjn.d.ts} +341 -1
  35. package/dist/index.css +578 -170
  36. package/dist/index.d.ts +23 -23
  37. package/dist/index.js +33 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/react/index.d.ts +113 -7
  40. package/dist/react/index.js +669 -3
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/{reduced-YS6TZjpa.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
  43. package/dist/{store-CZ6vdtIE.d.ts → store-6hZNKiee.d.ts} +1 -1
  44. package/package.json +1 -1
  45. package/registry.json +180 -0
  46. package/src/auto/auto-init.ts +31 -0
  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/drilldown-menu.css +11 -7
  58. package/src/components/drilldown-menu/engine.ts +44 -4
  59. package/src/components/equalizer-slider/README.md +43 -0
  60. package/src/components/equalizer-slider/element.ts +41 -0
  61. package/src/components/equalizer-slider/engine.ts +32 -0
  62. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  63. package/src/components/equalizer-slider/react.tsx +60 -0
  64. package/src/components/equalizer-slider/shared.ts +161 -0
  65. package/src/components/handwriting-text/README.md +29 -0
  66. package/src/components/handwriting-text/element.ts +14 -0
  67. package/src/components/handwriting-text/engine.ts +108 -0
  68. package/src/components/handwriting-text/glyphs.ts +71 -0
  69. package/src/components/handwriting-text/handwriting-text.css +14 -0
  70. package/src/components/handwriting-text/react.tsx +23 -0
  71. package/src/components/magnetize-button/README.md +26 -0
  72. package/src/components/magnetize-button/element.ts +13 -0
  73. package/src/components/magnetize-button/engine.ts +84 -0
  74. package/src/components/magnetize-button/magnetize-button.css +17 -0
  75. package/src/components/magnetize-button/react.tsx +22 -0
  76. package/src/components/morphing-page-dots/README.md +28 -0
  77. package/src/components/morphing-page-dots/element.ts +16 -0
  78. package/src/components/morphing-page-dots/engine.ts +89 -0
  79. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  80. package/src/components/morphing-page-dots/react.tsx +32 -0
  81. package/src/components/not-found/README.md +26 -0
  82. package/src/components/not-found/element.ts +15 -0
  83. package/src/components/not-found/engine.ts +57 -0
  84. package/src/components/not-found/not-found.css +21 -0
  85. package/src/components/not-found/react.tsx +23 -0
  86. package/src/components/notification-stack/README.md +24 -0
  87. package/src/components/notification-stack/element.ts +26 -0
  88. package/src/components/notification-stack/engine.ts +329 -0
  89. package/src/components/notification-stack/notification-stack.css +41 -0
  90. package/src/components/notification-stack/react.tsx +28 -0
  91. package/src/components/particle-button/README.md +26 -0
  92. package/src/components/particle-button/element.ts +13 -0
  93. package/src/components/particle-button/engine.ts +52 -0
  94. package/src/components/particle-button/particle-button.css +13 -0
  95. package/src/components/particle-button/react.tsx +22 -0
  96. package/src/components/radio-group/README.md +43 -0
  97. package/src/components/radio-group/element.ts +41 -0
  98. package/src/components/radio-group/engine.ts +11 -0
  99. package/src/components/radio-group/radio-group.css +28 -0
  100. package/src/components/radio-group/react.tsx +60 -0
  101. package/src/components/radio-group/shared.ts +118 -0
  102. package/src/components/rating-scale/README.md +43 -0
  103. package/src/components/rating-scale/element.ts +41 -0
  104. package/src/components/rating-scale/engine.ts +63 -0
  105. package/src/components/rating-scale/rating-scale.css +22 -0
  106. package/src/components/rating-scale/react.tsx +60 -0
  107. package/src/components/scroll-preview-dock/README.md +24 -0
  108. package/src/components/scroll-preview-dock/element.ts +20 -0
  109. package/src/components/scroll-preview-dock/engine.ts +228 -0
  110. package/src/components/scroll-preview-dock/react.tsx +26 -0
  111. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  112. package/src/components/select/README.md +23 -9
  113. package/src/components/select/select.css +16 -0
  114. package/src/components/signature-pad/engine.ts +550 -437
  115. package/src/components/signature-pad/signature-pad.css +130 -103
  116. package/src/components/stepper/README.md +46 -0
  117. package/src/components/stepper/element.ts +42 -0
  118. package/src/components/stepper/engine.ts +103 -0
  119. package/src/components/stepper/react.tsx +60 -0
  120. package/src/components/stepper/stepper.css +35 -0
  121. package/src/components/sticky-header/README.md +30 -0
  122. package/src/components/sticky-header/element.ts +19 -0
  123. package/src/components/sticky-header/engine.ts +95 -0
  124. package/src/components/sticky-header/react.tsx +25 -0
  125. package/src/components/sticky-header/sticky-header.css +9 -0
  126. package/src/components/temperature-slider/README.md +42 -0
  127. package/src/components/temperature-slider/element.ts +53 -0
  128. package/src/components/temperature-slider/engine.ts +44 -0
  129. package/src/components/temperature-slider/react.tsx +66 -0
  130. package/src/components/temperature-slider/temperature-slider.css +29 -0
  131. package/src/components/voice-message-bubble/engine.ts +11 -0
  132. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
  133. package/src/components/waveform-player/README.md +1 -1
  134. package/src/components/waveform-player/engine.ts +344 -261
  135. package/src/components/waveform-player/waveform-player.css +50 -35
  136. package/src/components/wheel-picker/README.md +3 -1
  137. package/src/components/wheel-picker/element.ts +12 -5
  138. package/src/components/wheel-picker/engine.ts +37 -8
  139. package/src/components/wheel-picker/wheel-picker.css +17 -16
  140. package/src/css/tokens.css +4 -0
  141. package/src/elements/index.ts +45 -0
  142. package/src/index.ts +311 -295
  143. package/src/react/index.ts +16 -0
  144. package/dist/chunk-7IQTMF7Y.js.map +0 -1
  145. /package/dist/{chunk-VJFPTLAK.js.map → chunk-AWDEKDEJ.js.map} +0 -0
  146. /package/dist/{chunk-BZLMCHUD.js.map → chunk-EDMXWUW3.js.map} +0 -0
@@ -0,0 +1,153 @@
1
+ import { Flip, gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { createScope } from '../../core/scope';
3
+ import { reduced } from '../../core/reduced';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { blurIn, press } from '../../motion/micro';
6
+
7
+ export interface BentoGridOptions {
8
+ columns?: number;
9
+ gap?: number;
10
+ /** Matches a whitespace-separated data-mk-category. Null shows every item. */
11
+ filter?: string | null;
12
+ duration?: number;
13
+ onChange?: (keys: string[]) => void;
14
+ }
15
+ export interface BentoGridInstance extends Instance {
16
+ refresh(): void;
17
+ setFilter(filter: string | null): void;
18
+ reorder(keys: string[]): void;
19
+ /** Capture before an authored DOM mutation (add/remove/replace), then animate to its layout. */
20
+ mutate(change: () => void): void;
21
+ setOptions(options: Partial<BentoGridOptions>): void;
22
+ readonly filter: string | null;
23
+ readonly keys: string[];
24
+ }
25
+
26
+ /** Authored direct children are the grid items. Spans live in data-mk-col-span / data-mk-row-span. */
27
+ export function createBentoGrid(root: HTMLElement | null, options: BentoGridOptions = {}): BentoGridInstance {
28
+ if (!root) return inert({ refresh() {}, setFilter() {}, reorder() {}, mutate() {}, setOptions() {}, filter: null, keys: [] });
29
+ registerMotion();
30
+ const scope = createScope();
31
+ let opts = { ...options };
32
+ let filter = opts.filter ?? null;
33
+ let animation: gsap.core.Timeline | null = null;
34
+ const micro = new Set<gsap.core.Animation>();
35
+ const track = (value: gsap.core.Animation | null) => { if (value) micro.add(value); };
36
+ const originals = [...root.childNodes];
37
+ const originalItems = new Set(root.children);
38
+ const remembered = new Set<HTMLElement>();
39
+ let queued = 0;
40
+ scope.attr(root, 'class', `${root.className} mk-bento-grid`.trim());
41
+ scope.attr(root, 'style', root.getAttribute('style'));
42
+ scope.attr(root, 'data-mk-ready', '');
43
+ const items = () => [...root.children].filter((node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute('data-mk-bento-item'));
44
+ const key = (item: HTMLElement) => item.dataset.mkBentoItem!;
45
+ const visible = () => items().filter((item) => !item.hidden);
46
+ const save = (item: HTMLElement) => {
47
+ if (remembered.has(item)) return;
48
+ remembered.add(item);
49
+ for (const attr of ['style', 'hidden', 'inert', 'aria-hidden', 'data-flip-id']) scope.attr(item, attr, item.getAttribute(attr));
50
+ };
51
+ items().forEach(save);
52
+ let last: ReturnType<typeof Flip.getState> | undefined;
53
+ const notify = () => {
54
+ const keys = visible().map(key);
55
+ opts.onChange?.(keys);
56
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { keys, filter } }));
57
+ };
58
+ const paint = () => {
59
+ const desired = Number.isFinite(opts.columns) ? Math.max(1, Math.min(12, Math.round(opts.columns!))) : 3;
60
+ const cols = root.clientWidth > 0 && root.clientWidth < 280 ? 1 : root.clientWidth > 0 && root.clientWidth < 440 ? Math.min(2, desired) : desired;
61
+ root.style.setProperty('--mk-bento-columns', String(cols));
62
+ root.style.setProperty('--mk-bento-gap', `${Math.max(0, opts.gap ?? 12)}px`);
63
+ items().forEach((item) => {
64
+ save(item);
65
+ item.style.setProperty('--mk-bento-col-span', String(Math.max(1, Math.min(cols, Number(item.dataset.mkColSpan) || 1))));
66
+ item.style.setProperty('--mk-bento-row-span', String(Math.max(1, Math.min(8, Number(item.dataset.mkRowSpan) || 1))));
67
+ const show = !filter || (item.dataset.mkCategory ?? '').split(/\s+/).includes(filter);
68
+ if (!show && item.contains(document.activeElement)) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
69
+ item.hidden = !show;
70
+ item.inert = !show;
71
+ item.setAttribute('aria-hidden', String(!show));
72
+ });
73
+ scope.attr(root, 'data-mk-empty', visible().length ? null : '');
74
+ if (!root.hasAttribute('data-mk-empty-label')) scope.attr(root, 'data-mk-empty-label', 'No hay elementos en esta vista.');
75
+ };
76
+ const transition = (state = last, entering: HTMLElement[] = []) => {
77
+ if (scope.disposed) return;
78
+ animation?.kill();
79
+ paint();
80
+ if (!reduced()) {
81
+ animation = Flip.from(state ?? Flip.getState([]), { targets: visible(), duration: Math.max(0, opts.duration ?? .55), ease: EASE.signature, stagger: .025, absolute: false, scale: false, prune: true, onComplete: () => { if (!scope.disposed) last = Flip.getState(visible()); } });
82
+ track(blurIn(entering.filter((item) => !item.hidden), { from: 'soft', stagger: .04, duration: .35 }));
83
+ }
84
+ if (!reduced()) last = Flip.getState(visible());
85
+ };
86
+ const capture = () => {
87
+ animation?.kill();
88
+ if (reduced()) return undefined;
89
+ const focused = document.activeElement as HTMLElement | null;
90
+ Flip.killFlipsOf(items());
91
+ const state = Flip.getState(visible());
92
+ if (focused?.isConnected && focused !== document.activeElement) focused.focus({ preventScroll: true });
93
+ return state;
94
+ };
95
+ const mutate = (change: () => void) => {
96
+ if (scope.disposed) return;
97
+ const focused = document.activeElement as HTMLElement | null;
98
+ const state = capture();
99
+ const before = new Set(items());
100
+ change();
101
+ transition(state, items().filter((item) => !before.has(item)));
102
+ if (focused && focused !== document.body && root.contains(focused) && !focused.closest('[hidden], [inert]')) focused.focus({ preventScroll: true });
103
+ else if (focused && !focused.isConnected) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
104
+ observer?.takeRecords();
105
+ notify();
106
+ };
107
+ const setFilter = (next: string | null) => {
108
+ if (scope.disposed || next === filter) return;
109
+ const state = capture();
110
+ const before = new Set(visible());
111
+ filter = next;
112
+ paint();
113
+ transition(state, visible().filter((item) => !before.has(item)));
114
+ notify();
115
+ };
116
+ const refresh = () => {
117
+ if (scope.disposed) return;
118
+ transition(last, items().filter((item) => !remembered.has(item)));
119
+ };
120
+ const observer = typeof MutationObserver === 'function' ? scope.observe(new MutationObserver((records) => {
121
+ // Observe authored data only: engine style/hidden writes and nested content
122
+ // changes must not schedule another layout or create an observer loop.
123
+ if (!records.some((record) => record.type === 'childList' ? record.target === root : record.target instanceof HTMLElement && record.target.parentElement === root)) return;
124
+ cancelAnimationFrame(queued);
125
+ queued = requestAnimationFrame(refresh);
126
+ })) : null;
127
+ observer?.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-mk-category', 'data-mk-col-span', 'data-mk-row-span'] });
128
+ scope.on(root, 'click', (event) => {
129
+ const control = (event.target as Element).closest<HTMLElement>('button, a');
130
+ if (control && root.contains(control) && !control.matches(':disabled, [aria-disabled="true"]')) { scope.attr(control, 'style', control.getAttribute('style')); track(press(control)); }
131
+ });
132
+ if (typeof ResizeObserver === 'function') {
133
+ let width = root.clientWidth;
134
+ const resize = scope.observe(new ResizeObserver(() => { if (root.clientWidth !== width) { width = root.clientWidth; animation?.kill(); paint(); last = Flip.getState(visible()); } }));
135
+ resize.observe(root);
136
+ }
137
+ paint(); if (!reduced()) last = Flip.getState(visible());
138
+ track(blurIn(visible(), { from: 'soft', stagger: .04 }));
139
+ return {
140
+ refresh, mutate, setFilter,
141
+ reorder(keys) { mutate(() => { const byKey = new Map(items().map((item) => [key(item), item])); for (const id of new Set(keys)) { const item = byKey.get(id); if (item) { root.append(item); byKey.delete(id); } } byKey.forEach((item) => root.append(item)); }); },
142
+ setOptions(next) { if (scope.disposed) return; const state = capture(); opts = { ...opts, ...next }; if (next.filter !== undefined) filter = next.filter; transition(state); },
143
+ get filter() { return filter; }, get keys() { return visible().map(key); },
144
+ destroy() {
145
+ if (scope.disposed) return;
146
+ cancelAnimationFrame(queued); animation?.kill(); micro.forEach((value) => value.kill()); if (!reduced()) Flip.killFlipsOf(items()); gsap.killTweensOf([...remembered]);
147
+ scope.dispose();
148
+ // Preserve externally added content while restoring the initial authored order.
149
+ const added = [...root.childNodes].filter((node) => !originals.includes(node) && !(node instanceof Element && originalItems.has(node)));
150
+ root.replaceChildren(...originals, ...added);
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createBentoGrid, type BentoGridOptions, type BentoGridInstance } from './engine';
5
+
6
+ export interface BentoGridProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, BentoGridOptions {
7
+ instanceRef?: MutableRefObject<BentoGridInstance | null>;
8
+
9
+ }
10
+ export function BentoGrid({ columns, gap, filter, duration, onChange, instanceRef, className, children, ...props }: BentoGridProps) {
11
+ const root = useRef<HTMLDivElement>(null);
12
+ const instance = useRef<BentoGridInstance | null>(null);
13
+ const onChangeRef = useLatest(onChange);
14
+ useIsomorphicLayoutEffect(() => {
15
+ const created = createBentoGrid(root.current, { columns, gap, filter, duration, onChange: (...args) => onChangeRef.current?.(...args) });
16
+ instance.current = created;
17
+ return () => { created.destroy(); instance.current = null; };
18
+ // Options update the existing engine so timers, selection and scroll state survive.
19
+ }, []);
20
+ useIsomorphicLayoutEffect(() => {
21
+ instance.current?.setOptions({ columns, gap, filter, duration });
22
+ }, [columns, gap, filter, duration]);
23
+ useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
24
+ return <div {...props} ref={root} className={['mk-bento-grid', className].filter(Boolean).join(' ')}>{children}</div>;
25
+ }
@@ -8,13 +8,13 @@ mk-drilldown-menu {
8
8
  width: 100%;
9
9
  max-width: 26rem;
10
10
  min-width: 0;
11
- padding: 10px;
12
- border: 1px solid var(--mk-outline-soft);
11
+ padding: 12px;
12
+ border: 0;
13
13
  border-radius: var(--mk-drilldown-radius);
14
14
  background: var(--mk-surface-1);
15
15
  color: var(--mk-text);
16
16
  font-family: var(--md-sys-typescale-body-large-font, inherit);
17
- box-shadow: 0 12px 36px -20px rgb(0 0 0 / 0.25);
17
+ font-family: var(--mk-font);
18
18
  }
19
19
 
20
20
  [data-mk-drilldown-header] {
@@ -85,8 +85,9 @@ mk-drilldown-menu {
85
85
  cursor: pointer;
86
86
  }
87
87
 
88
- [data-mk-drilldown-viewport] { overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
89
- [data-mk-drilldown-level] { display: grid; gap: 3px; }
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; }
90
91
  [data-mk-drilldown-item] {
91
92
  display: flex;
92
93
  align-items: center;
@@ -104,16 +105,19 @@ mk-drilldown-menu {
104
105
  line-height: 1.4;
105
106
  text-align: start;
106
107
  cursor: pointer;
107
- transition: background-color 0.16s ease;
108
+ transition: color .15s, border-radius .3s var(--mk-ease-playful);
108
109
  }
109
110
 
110
111
  [data-mk-drilldown-item]:enabled:hover,
111
- [data-mk-drilldown-item]:focus-visible { background: var(--mk-surface-3); }
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); }
112
114
  [data-mk-drilldown-item]:disabled { opacity: 0.42; cursor: default; }
113
115
  [data-mk-drilldown-text] { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
114
116
  [data-mk-drilldown-label] { font-weight: 550; }
115
117
  [data-mk-drilldown-description] { color: var(--mk-muted); font-size: 0.8rem; }
116
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); }
117
121
  [data-mk-drilldown-empty] { margin: 0; padding: 24px 16px; color: var(--mk-muted); font-size: 0.875rem; }
118
122
 
119
123
  [data-mk-drilldown-item]:focus-visible,
@@ -2,6 +2,7 @@ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
2
  import { reduced } from '../../core/reduced';
3
3
  import { createScope } from '../../core/scope';
4
4
  import { inert, type Instance } from '../../core/types';
5
+ import { press, ringPulse } from '../../motion/micro';
5
6
 
6
7
  export interface DrilldownMenuItem {
7
8
  /** Unique among siblings; IDs form the navigation path. */
@@ -66,6 +67,12 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
66
67
  let focused: string | null = null;
67
68
  const remembered = new Map<string, string>();
68
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
+ };
69
76
 
70
77
  const header = document.createElement('div');
71
78
  header.dataset.mkDrilldownHeader = '';
@@ -91,7 +98,9 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
91
98
  const empty = document.createElement('p');
92
99
  empty.dataset.mkDrilldownEmpty = '';
93
100
  empty.setAttribute('role', 'status');
94
- viewport.append(menu, empty);
101
+ const highlight = document.createElement('span');
102
+ highlight.dataset.mkDrilldownHighlight = ''; highlight.setAttribute('aria-hidden', 'true');
103
+ viewport.append(highlight, menu, empty);
95
104
  backButton.setAttribute('aria-controls', menu.id);
96
105
  root.append(header, breadcrumb, viewport);
97
106
  scope.attr(root, 'data-mk-ready', '');
@@ -112,10 +121,19 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
112
121
  const level = () => levelAt(path);
113
122
  const enabled = () => [...menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')].filter((item) => !item.disabled);
114
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
+ };
115
132
  const cancelMotion = () => {
116
133
  animation?.kill();
134
+ hoverMotion?.kill();
117
135
  animation = null;
118
- gsap.set(menu, { clearProps: 'transform,opacity,filter' });
136
+ gsap.set([menu, heading, breadcrumb, ...menu.children], { clearProps: 'transform,opacity,filter' });
119
137
  gsap.set(viewport, { clearProps: 'height' });
120
138
  };
121
139
  const roving = (id: string | null, focus = false) => {
@@ -127,6 +145,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
127
145
  menu.tabIndex = target ? -1 : 0;
128
146
  if (focused) remembered.set(JSON.stringify(path), focused);
129
147
  if (focus) (target ?? menu).focus({ preventScroll: true });
148
+ if (focus) highlightItem(target);
130
149
  };
131
150
  const notifyPath = () => {
132
151
  const next = [...path];
@@ -137,6 +156,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
137
156
  const render = (direction: number, focus: boolean, preferred?: string | null) => {
138
157
  const oldHeight = viewport.getBoundingClientRect().height;
139
158
  cancelMotion();
159
+ highlightItem(null);
140
160
  const current = level();
141
161
  const label = config.label ?? 'Actions';
142
162
  const title = current.ancestors.at(-1)?.label ?? label;
@@ -144,6 +164,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
144
164
  backButton.hidden = path.length === 0;
145
165
  backButton.setAttribute('aria-label', config.backLabel ?? 'Back');
146
166
  breadcrumb.setAttribute('aria-label', `${label} path`);
167
+ // At the root the heading already names the only crumb.
168
+ breadcrumb.hidden = path.length === 0;
147
169
  const labels = [label, ...current.ancestors.map((item) => item.label)];
148
170
  crumbs.replaceChildren(...labels.map((text, index) => {
149
171
  const li = document.createElement('li');
@@ -203,6 +225,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
203
225
  // duplicated menuitems and allows rapid navigation without stale callbacks.
204
226
  animation = gsap.timeline({ onComplete: () => { animation = null; } })
205
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);
206
230
  const nextHeight = viewport.getBoundingClientRect().height;
207
231
  if (oldHeight > 0 && nextHeight > 0 && oldHeight !== nextHeight) {
208
232
  animation.fromTo(viewport, { height: oldHeight }, { height: nextHeight, duration: 0.36, ease: EASE.signature, clearProps: 'height' }, 0);
@@ -237,6 +261,11 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
237
261
  const item = level().items.find((entry) => entry.id === id);
238
262
  if (!item || item.disabled) return;
239
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
+ }
240
269
  const selected = copyItems([item])[0]!;
241
270
  const selectedPath = [...path, item.id];
242
271
  root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { item: selected, path: [...selectedPath] } }));
@@ -251,8 +280,17 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
251
280
  });
252
281
  scope.on(menu, 'focusin', (event) => {
253
282
  const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
254
- if (button && !button.disabled) roving(button.dataset.mkDrilldownItem!, false);
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);
255
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);
256
294
  scope.on(root, 'keydown', (event) => {
257
295
  const key = (event as KeyboardEvent).key;
258
296
  if ((key === 'ArrowLeft' || key === 'Escape') && path.length) {
@@ -279,7 +317,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
279
317
  activate(button.dataset.mkDrilldownItem!);
280
318
  }
281
319
  });
282
- scope.on(backButton, 'click', () => back());
320
+ scope.on(backButton, 'click', () => { keep(press(backButton)); back(); });
283
321
  scope.on(breadcrumb, 'click', (event) => {
284
322
  const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-crumb]');
285
323
  if (!button || !breadcrumb.contains(button)) return;
@@ -288,6 +326,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
288
326
  });
289
327
  scope.add(() => {
290
328
  cancelMotion();
329
+ for (const effect of effects) effect.kill();
330
+ gsap.killTweensOf([heading, breadcrumb, highlight]);
291
331
  remembered.clear();
292
332
  header.remove();
293
333
  breadcrumb.remove();
@@ -0,0 +1,43 @@
1
+ # EqualizerSlider
2
+
3
+ Un control de nivel con barras de onda y un cursor que acompaña inmediatamente al rango nativo. Las barras que cambian de estado responden con swatch; el número rueda con las teclas o la API y el cursor responde al presionarlo.
4
+
5
+ ```ts
6
+ import { createEqualizerSlider } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/equalizer-slider';
8
+
9
+ const control = createEqualizerSlider(document.querySelector('.mk-equalizer-slider'), {
10
+ value: 60, name: 'volume', label: 'Volumen',
11
+ onInput: (value) => { audio.volume = value / 100; },
12
+ });
13
+ control.setValue(35);
14
+ ```
15
+
16
+ ## Opciones y contrato
17
+
18
+ `min` (0), `max` (100), `step` (1), `value` / `defaultValue` (60), `waveform: number[]` (alturas 0–1), `bars` (40, 12–72), `label`, `name`, `disabled`, `error`, `formatValue`, `onInput(value)`, `onChange(value)`.
19
+
20
+ Es un control numérico; no reproduce, captura ni analiza audio. waveform es una forma decorativa estática. onInput se emite durante el ajuste; onChange al confirmar, una sola vez por valor. En React se puede actualizar con onInput o confirmar con onChange. El arrastre siempre cambia la geometría sin esperar a una animación.
21
+
22
+ La instancia expone `value: number`, `setValue(value, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:input / mk:change` con `detail: { value }`.
23
+
24
+ ## React y HTML
25
+
26
+ ```tsx
27
+ import { EqualizerSlider } from '@yewdiel21/motion-kit/react';
28
+
29
+ <EqualizerSlider value={level} onInput={setLevel} label="Nivel" name="level" />
30
+ ```
31
+
32
+ `value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
33
+
34
+ ```html
35
+ <mk-equalizer-slider class="mk-equalizer-slider" value="60" label="Nivel" name="level" bars="44"></mk-equalizer-slider>
36
+ ```
37
+
38
+ Registra con `defineEqualizerSlider()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`.
39
+
40
+ ## Accesibilidad y lifecycle
41
+
42
+ Se conserva el input nativo, su etiqueta, teclado, tacto, validación y participación en formularios. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
43
+
@@ -0,0 +1,41 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createEqualizerSlider, type EqualizerSliderInstance } from './engine';
3
+
4
+ /** Light DOM; attribute changes keep the current imperative value and reset baseline. */
5
+ export class MkEqualizerSliderElement extends MkElement<EqualizerSliderInstance> {
6
+ static options: Record<string, OptionType> = {"min":"number","max":"number","step":"number","value":"number","label":"string","name":"string","disabled":"boolean","error":"string","waveform":"json","bars":"number"};
7
+ private baseline: number | undefined;
8
+ private saved: number | undefined;
9
+ protected create(options: Record<string, unknown>) {
10
+ const created = createEqualizerSlider(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | undefined, defaultValue: this.baseline });
11
+ if (this.baseline === undefined) this.baseline = created.value;
12
+ this.saved = undefined;
13
+ return created;
14
+ }
15
+ disconnectedCallback() {
16
+ this.saved = this.instance?.value;
17
+ super.disconnectedCallback();
18
+ }
19
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
20
+ if (previous === next) return;
21
+ if (name === 'value' && (!this.instance || !this.isConnected)) {
22
+ this.saved = next === null ? this.baseline ?? 60 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 60;
23
+ return;
24
+ }
25
+ if (!this.instance || !this.isConnected) return;
26
+ if (name === 'value') {
27
+ const parsed = next === null ? this.baseline ?? 60 : Number(next);
28
+ this.instance.setValue(parsed, false);
29
+ return;
30
+ }
31
+ this.saved = this.instance.value;
32
+ super.attributeChangedCallback(name, previous, next);
33
+ }
34
+ get value(): number { return this.instance?.value ?? this.saved ?? 60; }
35
+ set value(next: number) { this.setValue(next); }
36
+ setValue(value: number, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
37
+ reset(notify = false) { this.instance?.reset(notify); }
38
+
39
+ }
40
+ export const defineEqualizerSlider = () => defineElement('mk-equalizer-slider', MkEqualizerSliderElement);
41
+
@@ -0,0 +1,32 @@
1
+ import { gsap, EASE } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createNumericRange, type NumericOptions, type NumericInstance } from './shared';
4
+
5
+ export interface EqualizerSliderOptions extends NumericOptions {
6
+ /** Static waveform heights 0…1. This control does not analyze or play audio. */
7
+ waveform?: number[];
8
+ /** Number of decorative bars, 12…72; default 40. */
9
+ bars?: number;
10
+ }
11
+ export interface EqualizerSliderInstance extends NumericInstance {}
12
+ export function createEqualizerSlider(root: HTMLElement | null, options: EqualizerSliderOptions = {}): EqualizerSliderInstance {
13
+ return createNumericRange(root, options, {
14
+ prefix: 'equalizer', label: 'Volumen', min: 0, max: 100, value: 60, format: (n) => `${n} %`,
15
+ decorate({ track, keep }) {
16
+ const wave = document.createElement('span'); wave.dataset.mkEqualizerWave = ''; wave.setAttribute('aria-hidden', 'true'); track.prepend(wave);
17
+ const count = Math.max(12, Math.min(72, Math.round(Number.isFinite(options.bars) ? options.bars! : 40)));
18
+ const bars = Array.from({ length: count }, (_, i) => {
19
+ const bar = document.createElement('span');
20
+ const supplied = options.waveform?.[i % options.waveform.length];
21
+ const height = Number.isFinite(supplied) ? Math.max(0.1, Math.min(1, supplied!)) : 0.25 + 0.75 * Math.abs(Math.sin(i * 1.73) * Math.cos(i * 0.41));
22
+ bar.style.height = `${height * 100}%`; wave.append(bar); return bar;
23
+ });
24
+ return (ratio, _value, animate) => bars.forEach((bar, i) => {
25
+ const on = i / Math.max(1, count - 1) <= ratio;
26
+ const changed = bar.hasAttribute('data-mk-on') !== on;
27
+ bar.toggleAttribute('data-mk-on', on);
28
+ if (animate && changed && !reduced()) keep(gsap.fromTo(bar, { scaleY: 0.72 }, { scaleY: 1, duration: 0.35, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
29
+ });
30
+ },
31
+ });
32
+ }
@@ -0,0 +1,26 @@
1
+ :is(.mk-equalizer-slider, mk-equalizer-slider) { display: block; width: 100%; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
2
+ [data-mk-equalizer-body] { display: grid; gap: .875rem; }
3
+ [data-mk-equalizer-header] { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
4
+ [data-mk-equalizer-output] { font-variant-numeric: tabular-nums; white-space: nowrap; }
5
+ [data-mk-equalizer-track] { position: relative; height: 76px; min-width: 0; border-radius: 24px; background: var(--mk-surface-2); }
6
+ [data-mk-equalizer-input] { position: absolute; z-index: 3; inset: 0; box-sizing: border-box; width: 100%; height: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; cursor: ew-resize; opacity: 0; }
7
+ [data-mk-equalizer-input]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 32px; height: 76px; background: transparent; }
8
+ [data-mk-equalizer-input]::-moz-range-thumb { width: 32px; height: 76px; border: 0; background: transparent; }
9
+ [data-mk-equalizer-thumb] { position: absolute; z-index: 2; top: 50%; left: calc(16px + (100% - 32px) * var(--mk-equalizer-ratio, .5)); translate: -50% -50%; pointer-events: none; }
10
+ [data-mk-equalizer-track]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
11
+ [data-mk-equalizer-ends] { display: flex; justify-content: space-between; margin-top: -.375rem; color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
12
+ [data-mk-equalizer-message] { color: var(--mk-error); font-size: .8125rem; font-weight: 400; }
13
+ [data-mk-equalizer-message]:empty { display: none; }
14
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] { opacity: .4; }
15
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] [data-mk-equalizer-input] { cursor: not-allowed; }
16
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-error] [data-mk-equalizer-track] { outline: 1px solid var(--mk-error); }
17
+ [data-mk-equalizer-output] { padding: .375rem .75rem; border-radius: 64px; background: var(--mk-surface-2); font-size: .8125rem; }
18
+ [data-mk-equalizer-wave] { position: absolute; inset: 14px 16px; display: flex; align-items: center; gap: 3px; pointer-events: none; }
19
+ [data-mk-equalizer-wave] > span { flex: 1; min-width: 1px; border-radius: 64px; background: var(--mk-outline-soft); transform-origin: center; transition: background .16s; }
20
+ [data-mk-equalizer-wave] > span[data-mk-on] { background: var(--mk-text); }
21
+ [data-mk-equalizer-thumb] { width: 4px; height: 54px; border: 3px solid var(--mk-surface-2); border-radius: 64px; background: var(--mk-text); box-sizing: content-box; transition: height .35s var(--mk-ease-swatch), border-width .35s var(--mk-ease-swatch); }
22
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-held] [data-mk-equalizer-thumb] { height: 66px; border-width: 5px; }
23
+ @media (hover: hover) { [data-mk-equalizer-track]:has(:not(:disabled):hover) [data-mk-equalizer-thumb] { height: 62px; } }
24
+ @media (prefers-reduced-motion: reduce) { [data-mk-equalizer-thumb], [data-mk-equalizer-wave] > span { transition: none; } }
25
+ @media (forced-colors: active) { [data-mk-equalizer-wave] > span { background: GrayText; } [data-mk-equalizer-wave] > span[data-mk-on], [data-mk-equalizer-thumb] { background: Highlight; } }
26
+
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createEqualizerSlider, type EqualizerSliderOptions, type EqualizerSliderInstance } from './engine';
5
+
6
+ export interface EqualizerSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<EqualizerSliderOptions, 'value' | 'defaultValue'> {
7
+ value?: number;
8
+ defaultValue?: number;
9
+ instanceRef?: MutableRefObject<EqualizerSliderInstance | null>;
10
+ /** The engine owns this component's DOM. */
11
+ children?: never;
12
+ dangerouslySetInnerHTML?: never;
13
+ }
14
+ export function EqualizerSlider({ value, defaultValue, min, max, step, label, name, disabled, error, formatValue, waveform, bars, onChange, onInput, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: EqualizerSliderProps) {
15
+ const root = useRef<HTMLDivElement>(null);
16
+ const engine = useRef<EqualizerSliderInstance | null>(null);
17
+ const latestValue = useLatest(value);
18
+ const change = useLatest(onChange);
19
+ const input = useLatest(onInput);
20
+ const baseline = useRef(value !== undefined ? value : defaultValue);
21
+ const current = useRef(baseline.current);
22
+ const reconcile = () => queueMicrotask(() => {
23
+ if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
24
+ });
25
+ useIsomorphicLayoutEffect(() => {
26
+ const created = createEqualizerSlider(root.current, {
27
+ min, max, step, label, name, disabled, error, formatValue, waveform, bars, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
28
+ onChange: (next) => { change.current?.(next); reconcile(); },
29
+ onInput: (next) => { input.current?.(next); if (input.current || !change.current) reconcile(); },
30
+ });
31
+ engine.current = created;
32
+ const form = root.current?.closest('form');
33
+ const pending = new Set<ReturnType<typeof setTimeout>>();
34
+ const reset = (event: Event) => {
35
+ const timer = setTimeout(() => {
36
+ pending.delete(timer);
37
+ if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
38
+ }, 0);
39
+ pending.add(timer);
40
+ };
41
+ form?.addEventListener('reset', reset);
42
+ return () => {
43
+ form?.removeEventListener('reset', reset);
44
+ pending.forEach((timer) => clearTimeout(timer));
45
+ current.current = created.value; created.destroy(); engine.current = null;
46
+ };
47
+ // Callbacks and controlled value are read through refs.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
50
+ useIsomorphicLayoutEffect(() => {
51
+ if (value !== undefined) engine.current?.setValue(value, false);
52
+ });
53
+ useIsomorphicLayoutEffect(() => {
54
+ if (!instanceRef) return;
55
+ instanceRef.current = engine.current;
56
+ return () => { instanceRef.current = null; };
57
+ }, [instanceRef, min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
58
+ return <div {...props} ref={root} className={['mk-equalizer-slider', className].filter(Boolean).join(' ')} />;
59
+ }
60
+