@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.
- package/README.md +44 -0
- package/dist/auto/index.d.ts +23 -3
- package/dist/auto/index.js +43 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
- package/dist/chunk-Q7IW4FV3.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/drilldown-menu.css +134 -0
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +16 -0
- package/dist/css/components/signature-pad.css +131 -0
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +67 -0
- package/dist/css/components/waveform-player.css +51 -0
- package/dist/css/components/wheel-picker.css +18 -0
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +292 -4
- package/dist/elements/index.js +856 -4
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
- package/dist/index.css +713 -0
- package/dist/index.d.ts +23 -23
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +151 -7
- package/dist/react/index.js +893 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +244 -0
- package/src/auto/auto-init.ts +339 -293
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +133 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +378 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/select.css +16 -0
- package/src/components/signature-pad/README.md +97 -0
- package/src/components/signature-pad/element.ts +57 -0
- package/src/components/signature-pad/engine.ts +550 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +130 -0
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +79 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
- package/src/components/waveform-player/README.md +30 -0
- package/src/components/waveform-player/element.ts +21 -0
- package/src/components/waveform-player/engine.ts +344 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +50 -0
- package/src/components/wheel-picker/README.md +35 -0
- package/src/components/wheel-picker/element.ts +25 -0
- package/src/components/wheel-picker/engine.ts +239 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +17 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +318 -258
- package/src/index.ts +21 -0
- package/src/react/index.ts +192 -171
- package/dist/chunk-SDQ776LG.js.map +0 -1
- /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Badge button
|
|
2
|
+
|
|
3
|
+
Botón con contador: el primer elemento hace aparecer el badge con `pop`; las cifras cambian con `rollDigits` y una pequeña pulsación `swatch`. El valor y el nombre accesible se actualizan en el momento de la acción. El badge visual usa `99+` por defecto, mientras el nombre conserva la cantidad real.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createBadgeButton } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/badge-button';
|
|
8
|
+
const button = createBadgeButton(document.querySelector('#pending'), {
|
|
9
|
+
count: 0, onChange: (count) => updateLocalList(count),
|
|
10
|
+
});
|
|
11
|
+
button.setCount(10); // sin notificar
|
|
12
|
+
button.setCount(0, true);
|
|
13
|
+
// button.destroy();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`BadgeButtonOptions`: `label` (Guardar, cuando falta contenido), `count` (0), `step` (1, incremento por activación; admite 0 y valores negativos), `hideZero` (true), `max` (99), `onChange(count)`. Los valores se redondean y limitan a enteros no negativos. `BadgeButtonInstance`: `setCount(count, notify = false)`, `count`, `destroy()`. Emite `mk:change` con `{count}` al notificar. Un paso 0 conserva el contador y permite que la app gestione su acción con un listener nativo.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<BadgeButton label="Añadir pendiente" count={count} onChange={setCount} />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
React admite `count` controlado o `defaultCount`, atributos de botón e `instanceRef`. El valor controlado sigue siendo la autoridad si el padre rechaza un cambio. Usa `label`; los hijos y `dangerouslySetInnerHTML` se filtran en ejecución. Cambiar presentación conserva el estado no controlado.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<mk-badge-button count="0" hide-zero="true"><button type="button">Añadir pendiente</button></mk-badge-button>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`defineBadgeButton()` registra `MkBadgeButtonElement`; atributos `label`, `count`, `step`, `hide-zero`, `max`. Conserva el contador vivo en cambios de presentación y reconexiones; el atributo `count` explícito lo reemplaza. El botón nativo tiene type button por defecto y respeta disabled. Movimiento reducido actualiza directamente. `destroy()` restaura el contenido y estilos escritos por el consumidor y libera las animaciones y eventos.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.mk-badge-button,
|
|
2
|
+
mk-badge-button { display: inline-flex; }
|
|
3
|
+
[data-mk-badge-button] {
|
|
4
|
+
display: inline-flex; align-items: center; justify-content: center; gap: .7rem; position: relative;
|
|
5
|
+
min-height: 48px; padding: .65rem 1rem .65rem 1.25rem; border: 1px solid var(--mk-outline-soft); border-radius: 16px;
|
|
6
|
+
background: var(--mk-surface-3); color: var(--mk-text); font: 500 .9375rem var(--mk-font); cursor: pointer;
|
|
7
|
+
transition: background 160ms, border-radius 320ms var(--mk-ease-swatch);
|
|
8
|
+
}
|
|
9
|
+
[data-mk-badge-button]:hover:not(:disabled) { border-radius: 24px; background: var(--mk-surface-4); }
|
|
10
|
+
[data-mk-badge-button]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
11
|
+
[data-mk-badge-button]:disabled,
|
|
12
|
+
[data-mk-badge-button][aria-disabled='true'] { opacity: .4; cursor: not-allowed; }
|
|
13
|
+
[data-mk-badge-count] { display: inline-grid; place-items: center; min-width: 26px; height: 26px; padding: 0 6px; border-radius: 999px; background: var(--mk-accent); color: var(--mk-on-accent); font-size: .75rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
14
|
+
[data-mk-badge-count][hidden] { display: none; }
|
|
15
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-badge-button] { transition: none; } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createBadgeButton, type BadgeButtonInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkBadgeButtonElement extends MkElement<BadgeButtonInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { label: 'string', count: 'number', step: 'number', hideZero: 'boolean', max: 'number' };
|
|
6
|
+
private retained: number | undefined;
|
|
7
|
+
protected create(options: Record<string, unknown>) { return createBadgeButton(this, { ...options, ...(this.retained === undefined ? {} : { count: this.retained }) }); }
|
|
8
|
+
disconnectedCallback() { this.retained = this.instance?.count ?? this.retained; super.disconnectedCallback(); }
|
|
9
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
10
|
+
const focused = this.contains(document.activeElement);
|
|
11
|
+
if (previous !== next) this.retained = name === 'count' ? undefined : this.instance?.count ?? this.retained;
|
|
12
|
+
super.attributeChangedCallback(name, previous, next);
|
|
13
|
+
if (focused) this.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const defineBadgeButton = () => defineElement('mk-badge-button', MkBadgeButtonElement);
|
|
@@ -0,0 +1,85 @@
|
|
|
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, rollDigits } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface BadgeButtonOptions {
|
|
8
|
+
label?: string;
|
|
9
|
+
count?: number;
|
|
10
|
+
/** Increment requested by an activation; use 0 for a host-owned count. Default 1. */
|
|
11
|
+
step?: number;
|
|
12
|
+
/** Hide the badge when its count is zero (default true). */
|
|
13
|
+
hideZero?: boolean;
|
|
14
|
+
max?: number;
|
|
15
|
+
onChange?: (count: number) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface BadgeButtonInstance extends Instance {
|
|
18
|
+
setCount(count: number, notify?: boolean): void;
|
|
19
|
+
readonly count: number;
|
|
20
|
+
}
|
|
21
|
+
const normalize = (value: number) => Number.isFinite(value) ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.round(value))) : 0;
|
|
22
|
+
let sequence = 0;
|
|
23
|
+
export function createBadgeButton(root: HTMLElement | null, options: BadgeButtonOptions = {}): BadgeButtonInstance {
|
|
24
|
+
if (!root) return inert({ setCount() {}, count: 0 });
|
|
25
|
+
registerMotion();
|
|
26
|
+
const scope = createScope();
|
|
27
|
+
let button = root.matches('button') ? root as HTMLButtonElement : root.querySelector<HTMLButtonElement>('button');
|
|
28
|
+
if (!button) { button = document.createElement('button'); root.append(button); const made = button; scope.add(() => made.remove()); }
|
|
29
|
+
const control = button;
|
|
30
|
+
if (!control.textContent?.trim() && !control.querySelector('[aria-label], svg, img')) {
|
|
31
|
+
const label = document.createElement('span'); label.textContent = options.label ?? 'Guardar'; control.append(label); scope.add(() => label.remove());
|
|
32
|
+
}
|
|
33
|
+
const originalStyle = control.getAttribute('style');
|
|
34
|
+
const originalTransform = control.style.transform;
|
|
35
|
+
scope.add(() => { if (originalStyle === null) control.removeAttribute('style'); else control.setAttribute('style', originalStyle); });
|
|
36
|
+
const badge = document.createElement('span'); badge.dataset.mkBadgeCount = ''; badge.setAttribute('aria-hidden', 'true'); control.append(badge); scope.add(() => badge.remove());
|
|
37
|
+
// Keep the actual quantity in the accessible name even when the visual badge reads "99+".
|
|
38
|
+
const spoken = document.createElement('span'); spoken.className = 'mk-sr-only'; control.append(spoken); scope.add(() => spoken.remove());
|
|
39
|
+
const authoredLabel = control.getAttribute('aria-label');
|
|
40
|
+
const labelledBy = control.getAttribute('aria-labelledby');
|
|
41
|
+
if (labelledBy?.trim()) {
|
|
42
|
+
spoken.id = `mk-badge-quantity-${++sequence}`;
|
|
43
|
+
scope.attr(control, 'aria-labelledby', `${labelledBy} ${spoken.id}`);
|
|
44
|
+
}
|
|
45
|
+
scope.attr(control, 'type', control.getAttribute('type') ?? 'button');
|
|
46
|
+
scope.attr(control, 'data-mk-badge-button', '');
|
|
47
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
48
|
+
let count = normalize(options.count ?? 0);
|
|
49
|
+
const max = Math.max(1, normalize(options.max ?? 99));
|
|
50
|
+
const step = Number.isFinite(options.step) ? Math.round(options.step!) : 1;
|
|
51
|
+
let rolling: gsap.core.Animation | null = null;
|
|
52
|
+
let bump: gsap.core.Animation | null = null;
|
|
53
|
+
let pressing: gsap.core.Animation | null = null;
|
|
54
|
+
const format = () => count > max ? `${max}+` : String(count);
|
|
55
|
+
const announce = () => {
|
|
56
|
+
spoken.textContent = ` (${count})`;
|
|
57
|
+
if (!labelledBy?.trim() && authoredLabel?.trim()) scope.attr(control, 'aria-label', `${authoredLabel} (${count})`);
|
|
58
|
+
};
|
|
59
|
+
const paint = () => { badge.hidden = options.hideZero !== false && count === 0; badge.textContent = format(); announce(); };
|
|
60
|
+
const setCount = (next: number, notify = false) => {
|
|
61
|
+
if (scope.disposed || !Number.isFinite(next)) return;
|
|
62
|
+
const previous = count;
|
|
63
|
+
count = normalize(next);
|
|
64
|
+
if (count === previous) return;
|
|
65
|
+
const wasHidden = badge.hidden;
|
|
66
|
+
badge.hidden = options.hideZero !== false && count === 0;
|
|
67
|
+
announce();
|
|
68
|
+
rolling?.kill(); bump?.kill();
|
|
69
|
+
if (reduced() || badge.hidden) paint();
|
|
70
|
+
else {
|
|
71
|
+
rolling = rollDigits(badge, format(), count >= previous ? 1 : -1, .34);
|
|
72
|
+
bump = gsap.fromTo(badge, { scale: wasHidden ? .35 : .8, y: wasHidden ? 7 : 0 }, { scale: 1, y: 0, duration: .5, ease: wasHidden ? EASE.pop : EASE.swatch });
|
|
73
|
+
}
|
|
74
|
+
if (notify) { options.onChange?.(count); root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { count } })); }
|
|
75
|
+
};
|
|
76
|
+
scope.on(control, 'click', (event) => {
|
|
77
|
+
if (control.disabled || control.getAttribute('aria-disabled') === 'true') { event.preventDefault(); return; }
|
|
78
|
+
pressing?.kill(); pressing = press(control);
|
|
79
|
+
pressing?.eventCallback('onComplete', () => { gsap.set(control, { clearProps: 'transform,scale' }); control.style.transform = originalTransform; if (!control.getAttribute('style')) control.removeAttribute('style'); });
|
|
80
|
+
setCount(count + step, true);
|
|
81
|
+
});
|
|
82
|
+
paint();
|
|
83
|
+
scope.add(() => { rolling?.kill(); bump?.kill(); pressing?.kill(); gsap.killTweensOf(badge); });
|
|
84
|
+
return { setCount, get count() { return count; }, destroy: () => scope.dispose() };
|
|
85
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type ButtonHTMLAttributes, type Ref } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createBadgeButton, type BadgeButtonInstance, type BadgeButtonOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface BadgeButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'dangerouslySetInnerHTML' | 'onChange'>, BadgeButtonOptions {
|
|
7
|
+
defaultCount?: number;
|
|
8
|
+
instanceRef?: Ref<BadgeButtonInstance>;
|
|
9
|
+
}
|
|
10
|
+
export function BadgeButton({ label = 'Guardar', count, defaultCount = 0, step, hideZero, max, onChange, instanceRef, type = 'button', className, ...props }: BadgeButtonProps) {
|
|
11
|
+
const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as ButtonHTMLAttributes<HTMLButtonElement>;
|
|
12
|
+
const root = useRef<HTMLButtonElement>(null);
|
|
13
|
+
const instance = useRef<BadgeButtonInstance | null>(null);
|
|
14
|
+
const current = useLatest({ count, onChange });
|
|
15
|
+
const retained = useRef(defaultCount);
|
|
16
|
+
useIsomorphicLayoutEffect(() => {
|
|
17
|
+
let created: BadgeButtonInstance;
|
|
18
|
+
created = createBadgeButton(root.current, {
|
|
19
|
+
label, count: current.current.count ?? retained.current, step, hideZero, max,
|
|
20
|
+
onChange: (next) => { current.current.onChange?.(next); if (current.current.count !== undefined) created.setCount(current.current.count); },
|
|
21
|
+
});
|
|
22
|
+
instance.current = created;
|
|
23
|
+
return () => { retained.current = created.count; created.destroy(); instance.current = null; };
|
|
24
|
+
}, [label, step, hideZero, max]);
|
|
25
|
+
useIsomorphicLayoutEffect(() => { if (count !== undefined) instance.current?.setCount(count); }, [count]);
|
|
26
|
+
useIsomorphicLayoutEffect(() => {
|
|
27
|
+
if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
|
|
28
|
+
return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
|
|
29
|
+
}, [instanceRef, label, step, hideZero, max]);
|
|
30
|
+
return <button {...safe} type={type} ref={root} className={['mk-badge-button', className].filter(Boolean).join(' ')} />;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Bento grid
|
|
2
|
+
|
|
3
|
+
A reusable responsive authored grid. Existing cards travel with Flip when reordered or filtered; entering cards pop through a short blur. No hover-repulsion recipe is required.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<div id="grid">
|
|
7
|
+
<article data-mk-bento-item="activity" data-mk-col-span="2" data-mk-category="work">…</article>
|
|
8
|
+
<article data-mk-bento-item="notes" data-mk-row-span="2" data-mk-category="work files">…</article>
|
|
9
|
+
</div>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const grid = createBentoGrid(document.querySelector('#grid'), { columns: 3, gap: 12 });
|
|
14
|
+
grid.setFilter('files'); // null shows everything
|
|
15
|
+
grid.reorder(['notes', 'activity']);
|
|
16
|
+
grid.mutate(() => document.querySelector('#grid').append(newCard));
|
|
17
|
+
grid.destroy();
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Exports: `createBentoGrid`, `BentoGridOptions`, `BentoGridInstance`; React `BentoGrid`, `BentoGridProps`; elements `MkBentoGridElement`, `defineBentoGrid` (`mk-bento-grid`). CSS: `bento-grid.css` plus kit tokens/theme/fonts.
|
|
21
|
+
|
|
22
|
+
Options: `columns` (default 3, bounded 1–12), `gap` (pixels, default 12), `filter` (category or null), `duration` (seconds, default .55), `onChange(keys)`. Direct children opt in with unique `data-mk-bento-item` keys; column/row spans default to one. The grid reduces to two columns below 440px of its own width and one below 280px; spans clamp to available columns.
|
|
23
|
+
|
|
24
|
+
Instance: `refresh()`, `setFilter(filter)`, `reorder(keys)`, `mutate(change)`, `setOptions(partial)`, readonly `filter`/`keys` (visible order), `destroy()`. `mk:change` carries `{keys,filter}`. `mutate` measures before authored DOM edits; a MutationObserver also handles framework additions and authored category/span changes. Unknown/duplicate reorder keys are ignored; unlisted keys follow in their existing order. Focus survives reordering; filtering a focused card returns focus to the grid. Filtered cards are hidden and inert. Customize the empty copy with `data-mk-empty-label`.
|
|
25
|
+
|
|
26
|
+
React deliberately accepts authored children with stable keys and the same data attributes. Change their array order to reorder declaratively. Elements accept scalar option attributes and expose the instance methods. Destroy restores authored attributes and original order, retaining externally added children. Reduced motion keeps filtering and layout immediate.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.mk-bento-grid, mk-bento-grid { container-type: inline-size; display: grid; grid-template-columns: repeat(var(--mk-bento-columns, 3), minmax(0, 1fr)); grid-auto-rows: minmax(112px, auto); gap: var(--mk-bento-gap, 12px); color: var(--mk-text); font-family: var(--mk-font); }
|
|
2
|
+
.mk-bento-grid > [data-mk-bento-item], mk-bento-grid > [data-mk-bento-item] { box-sizing: border-box; min-width: 0; grid-column: span var(--mk-bento-col-span, 1); grid-row: span var(--mk-bento-row-span, 1); padding: 20px; border-radius: 24px; background: var(--mk-surface); transition: background .2s, box-shadow .25s; }
|
|
3
|
+
.mk-bento-grid > [data-mk-bento-item][hidden], mk-bento-grid > [data-mk-bento-item][hidden] { display: none !important; }
|
|
4
|
+
.mk-bento-grid > [data-mk-bento-item]:focus-within, mk-bento-grid > [data-mk-bento-item]:focus-within { background: var(--mk-surface-2); }
|
|
5
|
+
.mk-bento-grid :is(a,button):focus-visible, mk-bento-grid :is(a,button):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
6
|
+
.mk-bento-grid :is(button,a)[aria-disabled=true], .mk-bento-grid button:disabled { opacity: .4; cursor: not-allowed; }
|
|
7
|
+
.mk-bento-grid[data-mk-empty]::after, mk-bento-grid[data-mk-empty]::after { content: attr(data-mk-empty-label); grid-column: 1 / -1; min-height: 112px; display: grid; place-items: center; border-radius: 24px; color: var(--mk-muted); background: var(--mk-surface); font-size: 13px; }
|
|
8
|
+
@media (hover: hover) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item]:hover { background: var(--mk-surface-2); box-shadow: 0 10px 24px -24px var(--mk-text); } }
|
|
9
|
+
/* The container can be a 640px playground card, independent of viewport width. */
|
|
10
|
+
@container (max-width: 440px) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item] { grid-column: span min(var(--mk-bento-col-span, 1), 2); padding: 16px; border-radius: 16px; } }
|
|
11
|
+
@media (max-width: 480px) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item] { padding: 16px; border-radius: 16px; } }
|
|
12
|
+
@media (prefers-reduced-motion: reduce) { :is(.mk-bento-grid,mk-bento-grid) * { transition: none !important; } }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createBentoGrid, type BentoGridInstance, type BentoGridOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkBentoGridElement extends MkElement<BentoGridInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = {"columns":"number","gap":"number","filter":"string","duration":"number"};
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createBentoGrid(this, options); }
|
|
7
|
+
setOptions(options: Partial<BentoGridOptions>) { this.instance?.setOptions(options); }
|
|
8
|
+
|
|
9
|
+
refresh() { this.instance?.refresh(); }
|
|
10
|
+
setFilter(filter: string | null) { this.instance?.setFilter(filter); }
|
|
11
|
+
reorder(keys: string[]) { this.instance?.reorder(keys); }
|
|
12
|
+
mutate(change: () => void) { this.instance?.mutate(change); }
|
|
13
|
+
get filter() { return this.instance?.filter ?? null; }
|
|
14
|
+
get keys() { return this.instance?.keys ?? []; }
|
|
15
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
16
|
+
if (previous === next || !this.isConnected || !this.instance) return;
|
|
17
|
+
const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
18
|
+
const defaults: Record<string, unknown> = {"columns":3,"gap":12,"filter":null,"duration":0.55};
|
|
19
|
+
this.instance.setOptions({ [option]: this.options[option] ?? defaults[option] });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const defineBentoGrid = () => defineElement('mk-bento-grid', MkBentoGridElement);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Drilldown Menu
|
|
2
|
+
|
|
3
|
+
A hierarchical action menu that shows one level at a time. Opening a branch slides the new level in from the right through a 6 px blur; going back reverses the direction. Motion uses the kit's signature curve over 360 ms. The engine keeps one live menu, cancels interrupted motion, and honors reduced motion.
|
|
4
|
+
|
|
5
|
+
## Vanilla
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<div id="actions" class="mk-drilldown-menu"></div>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createDrilldownMenu, type DrilldownMenuItem } from '@yewdiel21/motion-kit';
|
|
13
|
+
import '@yewdiel21/motion-kit/css';
|
|
14
|
+
|
|
15
|
+
const items: DrilldownMenuItem[] = [
|
|
16
|
+
{
|
|
17
|
+
id: 'project', label: 'Project', description: 'Manage your workspace',
|
|
18
|
+
children: [{
|
|
19
|
+
id: 'share', label: 'Share',
|
|
20
|
+
children: [
|
|
21
|
+
{ id: 'link', label: 'Copy link', description: 'Anyone with the link can view' },
|
|
22
|
+
{ id: 'invite', label: 'Invite people', disabled: true },
|
|
23
|
+
],
|
|
24
|
+
}],
|
|
25
|
+
},
|
|
26
|
+
{ id: 'help', label: 'Help' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const menu = createDrilldownMenu(document.querySelector('#actions'), {
|
|
30
|
+
items,
|
|
31
|
+
label: 'Workspace actions',
|
|
32
|
+
onSelect: (item, path) => console.log(item.label, path),
|
|
33
|
+
onPathChange: (path) => console.log('Level', path),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
menu.open('project');
|
|
37
|
+
menu.open('share');
|
|
38
|
+
menu.back();
|
|
39
|
+
menu.root(); // reset() also returns to the root
|
|
40
|
+
menu.setPath(['project', 'share']);
|
|
41
|
+
menu.setItems(items);
|
|
42
|
+
menu.update({ label: 'Quick actions', emptyText: 'No available actions' });
|
|
43
|
+
// When the component is removed:
|
|
44
|
+
menu.destroy();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `@yewdiel21/motion-kit/css/components/drilldown-menu` for just this component's styles; theme tokens must already be present. The root needs `mk-drilldown-menu`; the custom element receives the same styles directly.
|
|
48
|
+
|
|
49
|
+
## React
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { useState } from 'react';
|
|
53
|
+
import { DrilldownMenu } from '@yewdiel21/motion-kit/react';
|
|
54
|
+
import type { DrilldownMenuItem } from '@yewdiel21/motion-kit';
|
|
55
|
+
|
|
56
|
+
function Actions({ items }: { items: DrilldownMenuItem[] }) {
|
|
57
|
+
const [path, setPath] = useState<string[]>([]);
|
|
58
|
+
const [selection, setSelection] = useState('Choose an action');
|
|
59
|
+
return <>
|
|
60
|
+
<DrilldownMenu
|
|
61
|
+
items={items}
|
|
62
|
+
label="Workspace actions"
|
|
63
|
+
path={path}
|
|
64
|
+
onPathChange={setPath}
|
|
65
|
+
onSelect={(item) => setSelection(item.label)}
|
|
66
|
+
/>
|
|
67
|
+
<p role="status">{selection}</p>
|
|
68
|
+
</>;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Omit `path` for internal navigation; use `defaultPath` to choose its initial level. Passing `path` makes navigation controlled: `onPathChange` requests the next branch path, and the parent accepts it by updating the prop. A parent that keeps the previous path declines the request. Invalid paths are safely truncated to their valid prefix. Updating `items` retains a valid path and focused action; removing or disabling an ancestor returns to the nearest valid parent and reports the repaired path.
|
|
73
|
+
|
|
74
|
+
`DrilldownMenuProps` also accepts normal `<div>` attributes, excluding children and the native `onSelect`. A forwarded `DrilldownMenuHandle` exposes `open`, `back`, `root`, `reset`, `setPath`, and `focusItem`. In controlled mode the `path` prop remains authoritative over ref-driven navigation.
|
|
75
|
+
|
|
76
|
+
## Custom element
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { defineDrilldownMenu, type MkDrilldownMenuElement } from '@yewdiel21/motion-kit/elements';
|
|
80
|
+
defineDrilldownMenu(); // safe to call more than once
|
|
81
|
+
|
|
82
|
+
const element = document.querySelector<MkDrilldownMenuElement>('mk-drilldown-menu')!;
|
|
83
|
+
element.items = items; // may also be assigned before connection
|
|
84
|
+
element.path = ['project'];
|
|
85
|
+
element.addEventListener('mk:select', (event) => {
|
|
86
|
+
console.log((event as CustomEvent).detail); // { item, path }
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<mk-drilldown-menu
|
|
92
|
+
label="Workspace actions"
|
|
93
|
+
back-label="Previous level"
|
|
94
|
+
empty-text="No available actions"
|
|
95
|
+
items='[{"id":"project","label":"Project","children":[{"id":"share","label":"Share","children":[{"id":"link","label":"Copy link"}]}]}]'
|
|
96
|
+
path='["project"]'
|
|
97
|
+
></mk-drilldown-menu>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`items` and `path` attributes accept JSON arrays and update the live instance; malformed JSON leaves the last valid value intact. `items`/`path` properties and `setItems`/`setPath` methods accept arrays directly. `open`, `back`, `root`, `reset`, and `focusItem` forward to the live engine. The `instance` property is null while disconnected. Reconnecting creates a fresh instance from the current attributes/properties. The element uses light DOM, so inherited themes apply.
|
|
101
|
+
|
|
102
|
+
## Data and API
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
interface DrilldownMenuItem {
|
|
106
|
+
id: string;
|
|
107
|
+
label: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
disabled?: boolean;
|
|
110
|
+
children?: DrilldownMenuItem[];
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
IDs must be unique among siblings. The same ID may appear in unrelated levels. A `children` array marks a branch; `children: []` opens an empty level. An item without children is an action. Labels/descriptions are inserted as text; they never execute HTML. Input data and returned item arrays are copied, so the engine does not mutate application data. Supply finite, acyclic data.
|
|
115
|
+
|
|
116
|
+
| Option | Default | Meaning |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `items` | `[]` | Root actions and branches |
|
|
119
|
+
| `path` | `[]` | Initial branch IDs (engine); controlled branch IDs (React) |
|
|
120
|
+
| `label` | `Actions` | Root heading and accessible menu name |
|
|
121
|
+
| `backLabel` | `Back` | Accessible name of the back button |
|
|
122
|
+
| `emptyText` | `No actions available` | Empty-level status |
|
|
123
|
+
| `onSelect(item, path)` | — | Leaf action and full ID path, including the leaf |
|
|
124
|
+
| `onPathChange(path)` | — | User/programmatic navigation or a repaired data path |
|
|
125
|
+
|
|
126
|
+
`DrilldownMenuInstance` exposes:
|
|
127
|
+
|
|
128
|
+
| Member | Behavior |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `open(id): boolean` | Opens a current-level enabled branch and focuses its first action |
|
|
131
|
+
| `back(): boolean` | Returns one level and restores focus to its parent branch |
|
|
132
|
+
| `root()` / `reset()` | Returns to the root; keeps focus inside if it was inside |
|
|
133
|
+
| `setPath(path, notify = false)` | Navigates to a valid prefix; optional path callback/event |
|
|
134
|
+
| `setItems(items)` | Replaces root data, repairs invalid paths and preserves focus |
|
|
135
|
+
| `update(options)` | Updates data, path, text and callbacks together |
|
|
136
|
+
| `focusItem(id)` | Focuses an enabled current-level item |
|
|
137
|
+
| `path` | Copy of the current branch ID path |
|
|
138
|
+
| `items` | Copy of the current level's items |
|
|
139
|
+
| `element` | Original root element |
|
|
140
|
+
| `destroy()` | Cancels motion, removes listeners, restores original child nodes/attributes; idempotent |
|
|
141
|
+
|
|
142
|
+
`open`, `back`, `root`, and `reset` notify on a changed path. Explicit `setPath` and `update({ path })` are silent by default. `setItems` reports a path repaired by removed/disabled/non-branch ancestors. `mk:path-change` bubbles with `{ path }`; `mk:select` bubbles with `{ item, path }`. Callbacks receive the same information. Choosing an action leaves the menu at its current level.
|
|
143
|
+
|
|
144
|
+
## Keyboard and accessibility
|
|
145
|
+
|
|
146
|
+
The current level has `role="menu"`, named by its visible header. Each action is a button with `role="menuitem"`; its label provides its accessible name and its description is referenced separately. Branches expose `aria-haspopup="menu"`. Disabled actions use both native `disabled` and `aria-disabled` and are skipped by focus navigation. Header/back and ancestor breadcrumbs sit outside the menu. The current breadcrumb uses `aria-current="location"`.
|
|
147
|
+
|
|
148
|
+
| Key | Action |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| Up / Down | Previous / next enabled item, wrapping |
|
|
151
|
+
| Home / End | First / last enabled item |
|
|
152
|
+
| Enter / Space | Open branch or choose leaf action |
|
|
153
|
+
| Right | Open a focused branch |
|
|
154
|
+
| Left / Escape | Return one level and focus the branch you came from |
|
|
155
|
+
| Tab / Shift+Tab | Normal page navigation through header/crumb controls and one roving menu stop |
|
|
156
|
+
|
|
157
|
+
At the root, Left/Escape are left available to a containing surface. Empty and all-disabled levels keep the menu itself focusable so the back keys work. The initial render does not take focus; opening a branch intentionally focuses the new level. Updates preserve focus only when focus was already inside. Reduced-motion users receive immediate navigation without slide, blur or animated height.
|
|
158
|
+
|
|
159
|
+
## Styling
|
|
160
|
+
|
|
161
|
+
The component inherits `--mk-text`, `--mk-muted`, `--mk-surface-1`, `--mk-surface-3`, `--mk-outline-soft`, and `--mk-accent`. Override `--mk-drilldown-radius` (28 px) and `--mk-drilldown-item-radius` (18 px) for corner shapes. Width is fluid up to 26 rem; override `max-width` on the root for wider panels. Labels wrap, breadcrumbs wrap, and descriptions remain readable in narrow surfaces.
|
|
162
|
+
|
|
163
|
+
This is an inline hierarchical action menu. Place it in an existing dialog/popover if you need an anchored or dismissible surface. It does not route to URLs, fetch children, virtualize data, or provide typeahead.
|