@yewdiel21/motion-kit 0.14.0 → 0.15.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 (154) hide show
  1. package/README.md +174 -155
  2. package/bin/motion-kit.mjs +3 -3
  3. package/dist/auto/index.d.ts +6 -3
  4. package/dist/auto/index.js +8 -2
  5. package/dist/auto/index.js.map +1 -1
  6. package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
  7. package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
  8. package/dist/chunk-A6QQCF6V.js.map +1 -0
  9. package/dist/chunk-JXRS3OEA.js +3638 -0
  10. package/dist/chunk-JXRS3OEA.js.map +1 -0
  11. package/dist/css/components/add-to-cart.css +72 -0
  12. package/dist/css/components/bidirectional-slider.css +168 -0
  13. package/dist/css/components/data-table.css +121 -26
  14. package/dist/css/components/filter-builder.css +10 -15
  15. package/dist/css/components/heatmap-chart.css +391 -0
  16. package/dist/css/components/hover-card.css +8 -2
  17. package/dist/css/components/image-preview.css +90 -2
  18. package/dist/css/components/json-viewer.css +33 -1
  19. package/dist/css/components/nav.css +102 -102
  20. package/dist/css/components/orbit-menu.css +130 -0
  21. package/dist/css/components/pagination.css +10 -5
  22. package/dist/css/components/pie-chart.css +390 -0
  23. package/dist/css/components/pill-button.css +135 -0
  24. package/dist/css/components/radar-chart.css +370 -0
  25. package/dist/css/components/radial-chart.css +398 -0
  26. package/dist/css/components/sankey-chart.css +358 -0
  27. package/dist/css/components/scroll-island.css +111 -0
  28. package/dist/css/components/select.css +136 -0
  29. package/dist/css/components/toolbar.css +122 -0
  30. package/dist/css/components/tree-view.css +79 -0
  31. package/dist/css/components/voice-dots.css +70 -0
  32. package/dist/elements/index.d.ts +180 -7
  33. package/dist/elements/index.js +287 -6
  34. package/dist/elements/index.js.map +1 -1
  35. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  36. package/dist/index.css +1832 -168
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +33 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/react/index.d.ts +117 -11
  41. package/dist/react/index.js +314 -13
  42. package/dist/react/index.js.map +1 -1
  43. package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
  44. package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
  45. package/dist/store-CA2Qcun_.d.ts +453 -0
  46. package/package.json +9 -16
  47. package/registry.json +1096 -948
  48. package/src/auto/auto-init.ts +6 -0
  49. package/src/components/add-to-cart/README.md +34 -0
  50. package/src/components/add-to-cart/add-to-cart.css +71 -0
  51. package/src/components/add-to-cart/element.ts +20 -0
  52. package/src/components/add-to-cart/engine.ts +206 -0
  53. package/src/components/add-to-cart/react.tsx +29 -0
  54. package/src/components/bidirectional-slider/README.md +46 -0
  55. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  56. package/src/components/bidirectional-slider/element.ts +42 -0
  57. package/src/components/bidirectional-slider/engine.ts +289 -0
  58. package/src/components/bidirectional-slider/react.tsx +45 -0
  59. package/src/components/chart/README.md +10 -0
  60. package/src/components/chart/element.ts +2 -0
  61. package/src/components/chart/engine.ts +50 -23
  62. package/src/components/chart/parts.ts +237 -0
  63. package/src/components/chart/react.tsx +2 -2
  64. package/src/components/chart/use-chart.ts +55 -0
  65. package/src/components/data-table/README.md +3 -3
  66. package/src/components/data-table/data-table.css +121 -26
  67. package/src/components/data-table/engine.ts +81 -29
  68. package/src/components/editable-table/engine.ts +67 -11
  69. package/src/components/filter-builder/engine.ts +33 -2
  70. package/src/components/filter-builder/filter-builder.css +10 -15
  71. package/src/components/heatmap-chart/README.md +48 -0
  72. package/src/components/heatmap-chart/element.ts +21 -0
  73. package/src/components/heatmap-chart/engine.ts +281 -0
  74. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  75. package/src/components/heatmap-chart/react.tsx +14 -0
  76. package/src/components/hover-card/engine.ts +30 -1
  77. package/src/components/hover-card/hover-card.css +8 -2
  78. package/src/components/image-preview/element.ts +1 -1
  79. package/src/components/image-preview/engine.ts +95 -15
  80. package/src/components/image-preview/image-preview.css +90 -2
  81. package/src/components/image-preview/react.tsx +3 -3
  82. package/src/components/json-viewer/engine.ts +11 -2
  83. package/src/components/json-viewer/json-viewer.css +33 -1
  84. package/src/components/nav/README.md +12 -10
  85. package/src/components/nav/element.ts +5 -5
  86. package/src/components/nav/engine.ts +21 -14
  87. package/src/components/nav/nav.css +102 -102
  88. package/src/components/nav/react.tsx +4 -4
  89. package/src/components/orbit-menu/README.md +46 -0
  90. package/src/components/orbit-menu/element.ts +27 -0
  91. package/src/components/orbit-menu/engine.ts +179 -0
  92. package/src/components/orbit-menu/orbit-menu.css +129 -0
  93. package/src/components/orbit-menu/react.tsx +44 -0
  94. package/src/components/pagination/README.md +1 -1
  95. package/src/components/pagination/engine.ts +50 -4
  96. package/src/components/pagination/pagination.css +10 -5
  97. package/src/components/pie-chart/README.md +48 -0
  98. package/src/components/pie-chart/element.ts +30 -0
  99. package/src/components/pie-chart/engine.ts +283 -0
  100. package/src/components/pie-chart/pie-chart.css +92 -0
  101. package/src/components/pie-chart/react.tsx +14 -0
  102. package/src/components/pill-button/README.md +46 -0
  103. package/src/components/pill-button/element.ts +16 -0
  104. package/src/components/pill-button/engine.ts +77 -0
  105. package/src/components/pill-button/pill-button.css +134 -0
  106. package/src/components/pill-button/react.tsx +32 -0
  107. package/src/components/radar-chart/README.md +48 -0
  108. package/src/components/radar-chart/element.ts +30 -0
  109. package/src/components/radar-chart/engine.ts +327 -0
  110. package/src/components/radar-chart/radar-chart.css +72 -0
  111. package/src/components/radar-chart/react.tsx +14 -0
  112. package/src/components/radial-chart/README.md +48 -0
  113. package/src/components/radial-chart/element.ts +30 -0
  114. package/src/components/radial-chart/engine.ts +290 -0
  115. package/src/components/radial-chart/radial-chart.css +100 -0
  116. package/src/components/radial-chart/react.tsx +14 -0
  117. package/src/components/sankey-chart/README.md +48 -0
  118. package/src/components/sankey-chart/element.ts +31 -0
  119. package/src/components/sankey-chart/engine.ts +401 -0
  120. package/src/components/sankey-chart/react.tsx +14 -0
  121. package/src/components/sankey-chart/sankey-chart.css +60 -0
  122. package/src/components/scroll-island/README.md +34 -0
  123. package/src/components/scroll-island/element.ts +16 -0
  124. package/src/components/scroll-island/engine.ts +252 -0
  125. package/src/components/scroll-island/react.tsx +26 -0
  126. package/src/components/scroll-island/scroll-island.css +110 -0
  127. package/src/components/select/README.md +34 -0
  128. package/src/components/select/element.ts +26 -0
  129. package/src/components/select/engine.ts +267 -0
  130. package/src/components/select/react.tsx +33 -0
  131. package/src/components/select/select.css +135 -0
  132. package/src/components/toolbar/README.md +46 -0
  133. package/src/components/toolbar/element.ts +30 -0
  134. package/src/components/toolbar/engine.ts +230 -0
  135. package/src/components/toolbar/react.tsx +34 -0
  136. package/src/components/toolbar/toolbar.css +121 -0
  137. package/src/components/tree-view/element.ts +1 -1
  138. package/src/components/tree-view/engine.ts +174 -0
  139. package/src/components/tree-view/react.tsx +7 -3
  140. package/src/components/tree-view/tree-view.css +79 -0
  141. package/src/components/voice-dots/README.md +48 -0
  142. package/src/components/voice-dots/element.ts +45 -0
  143. package/src/components/voice-dots/engine.ts +243 -0
  144. package/src/components/voice-dots/react.tsx +52 -0
  145. package/src/components/voice-dots/voice-dots.css +69 -0
  146. package/src/elements/index.ts +39 -0
  147. package/src/index.ts +16 -0
  148. package/src/motion/liquid.ts +14 -4
  149. package/src/react/index.ts +14 -1
  150. package/dist/chunk-6P4P7NU6.js +0 -1401
  151. package/dist/chunk-6P4P7NU6.js.map +0 -1
  152. package/dist/chunk-CU6SXW3E.js.map +0 -1
  153. package/dist/store-DjkTBX7f.d.ts +0 -195
  154. /package/dist/{chunk-EY4BJKC4.js.map → chunk-6IRJYXJU.js.map} +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useRef, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
4
4
  import { useIsomorphicLayoutEffect } from '../../react/use-recipe';
5
- import { revealShell, type NavStyle } from './engine';
5
+ import { revealShell, toNavStyle, type NavStyle, type NavStyleAlias } from './engine';
6
6
 
7
7
  export interface NavItem {
8
8
  value: string;
@@ -19,7 +19,7 @@ export interface NavProps extends Omit<HTMLAttributes<HTMLElement>, 'onSelect'>
19
19
  /** Value of the current page. */
20
20
  active?: string;
21
21
  onSelect?: (value: string, event: MouseEvent<HTMLElement>) => void;
22
- navStyle?: NavStyle;
22
+ navStyle?: NavStyle | NavStyleAlias;
23
23
  accent?: boolean;
24
24
  /** Reveal the surrounding .mk-shell from the first button on mount. */
25
25
  reveal?: boolean;
@@ -34,7 +34,7 @@ export function Nav({
34
34
  items,
35
35
  active,
36
36
  onSelect,
37
- navStyle = 'bonita',
37
+ navStyle = 'pills',
38
38
  accent = false,
39
39
  reveal = false,
40
40
  first,
@@ -50,7 +50,7 @@ export function Nav({
50
50
  // Only on mount.
51
51
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
52
  }, []);
53
- const classes = ['mk-nav', `mk-nav--${navStyle}`, accent && 'mk-nav--accent', className].filter(Boolean).join(' ');
53
+ const classes = ['mk-nav', `mk-nav--${toNavStyle(navStyle) ?? 'pills'}`, accent && 'mk-nav--accent', className].filter(Boolean).join(' ');
54
54
  return (
55
55
  <nav ref={ref} aria-label="Principal" {...props} className={classes}>
56
56
  {first}
@@ -0,0 +1,46 @@
1
+ # orbit-menu
2
+
3
+ Menú central que despliega acciones en cruz o círculo con giro, desenfoque y resorte.
4
+
5
+ ## Instalación
6
+
7
+ ```tsx
8
+ import '@yewdiel21/motion-kit/css';
9
+ import { OrbitMenu } from '@yewdiel21/motion-kit/react';
10
+ ```
11
+
12
+ ```sh
13
+ npx @yewdiel21/motion-kit add orbit-menu --react --elements
14
+ ```
15
+
16
+ ## React
17
+
18
+ ```tsx
19
+ <OrbitMenu label="Acciones" items={[{ value: "add", label: "Crear", icon: "+" }, { value: "download", label: "Descargar", icon: "↓" }]} onSelect={run} />
20
+ ```
21
+
22
+ Los callbacks del ejemplo (`ask`, `run`, `setTool`, `setVolume`) pertenecen a tu aplicación. React acepta `className`, `style` y atributos HTML. Los componentes con estado exponen `instanceRef` (excepto PillButton).
23
+
24
+ ## HTML / Astro
25
+
26
+ ```html
27
+ <mk-orbit-menu radius="88"><button data-mk-orbit-toggle aria-label="Acciones"></button><button data-mk-orbit-item data-mk-value="add" aria-label="Crear">+</button><button data-mk-orbit-item data-mk-value="download" aria-label="Descargar">↓</button></mk-orbit-menu>
28
+ <script type="module">
29
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
30
+ defineElements(['mk-orbit-menu']);
31
+ </script>
32
+ ```
33
+
34
+ En Astro usa un `<script>` procesado por Astro. En HTML directo el import requiere bundler o import map. También puedes usar `import { createOrbitMenu } from '@yewdiel21/motion-kit'` y llamar a la factory con el contenedor y las mismas opciones. Al desmontar, llama a `instance.destroy()`.
35
+
36
+ ## Opciones
37
+
38
+ radius: 96 px. start: 0 grados desde arriba. closeOnSelect: true. onToggle(open), onSelect(value). Reserva espacio alrededor del botón. Instancia: open(), close(), toggle(), isOpen.
39
+
40
+ Los nombres camelCase se escriben como atributos kebab-case. Los arrays y objetos se pasan como JSON. Los booleanos aceptan atributo vacío o `"false"`.
41
+
42
+ ## Estilos y accesibilidad
43
+
44
+ Variables: --mk-orbit-size, --mk-orbit-face, --mk-orbit-ink, --mk-orbit-shadow.
45
+
46
+ Respeta los colores del tema, foco visible y `prefers-reduced-motion`. Los gráficos incluyen una tabla accesible y selección con flechas, Home, End y Escape. Heatmap usa arriba/abajo para un día e izquierda/derecha para una semana. Toolbar usa flechas y Home/End; OrbitMenu cierra con Escape y devuelve el foco. Los controles conservan nombres accesibles; VoiceDots anuncia los estados sin leer cada fotograma.
@@ -0,0 +1,27 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createOrbitMenu, type OrbitMenuInstance } from './engine';
3
+
4
+ /**
5
+ * <mk-orbit-menu class="mk-orbit-menu">
6
+ * <button data-mk-orbit-toggle aria-label="Acciones"></button>
7
+ * <button data-mk-orbit-item data-mk-value="nuevo" aria-label="Nuevo">…svg…</button> …
8
+ * </mk-orbit-menu>
9
+ * Options swirl out around the middle button. Emits mk:toggle ({open}) and mk:select ({value}).
10
+ */
11
+ export class MkOrbitMenuElement extends MkElement<OrbitMenuInstance> {
12
+ static options: Record<string, OptionType> = { radius: 'number', start: 'number', closeOnSelect: 'boolean' };
13
+
14
+ protected create(options: Record<string, unknown>) {
15
+ return createOrbitMenu(this, options);
16
+ }
17
+
18
+ open() {
19
+ this.instance?.open();
20
+ }
21
+
22
+ close() {
23
+ this.instance?.close();
24
+ }
25
+ }
26
+
27
+ export const defineOrbitMenu = () => defineElement('mk-orbit-menu', MkOrbitMenuElement);
@@ -0,0 +1,179 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, warnOnce, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
6
+
7
+ /*
8
+ * Orbit menu: a square button in the middle whose options come out around it
9
+ * (four make a cross: up, right, down, left; more share the circle). Opening,
10
+ * the button's lines turn into an ×, it goes glassy, and each option swirls out
11
+ * of it along an arc, its distance on the swatch curve (.38,.49,0,1.5) so it
12
+ * overshoots and settles, out of a blur, 45 ms after the one before; closing,
13
+ * they keep turning and are drawn back in (.3 s). Arrows pick the option on
14
+ * that side, Tab walks them, Escape or a click outside closes and gives focus
15
+ * back. Closed options are inert. Choosing one presses it and closes.
16
+ */
17
+
18
+ export interface OrbitMenuOptions {
19
+ /** Distance of the options from the middle, px (default 96). */
20
+ radius?: number;
21
+ /** Where the first option sits, degrees clockwise from the top (default 0). */
22
+ start?: number;
23
+ /** Close after an option is chosen (default true). */
24
+ closeOnSelect?: boolean;
25
+ onToggle?: (open: boolean) => void;
26
+ /** An option was chosen: its data-mk-value, else its position. */
27
+ onSelect?: (value: string) => void;
28
+ }
29
+
30
+ export interface OrbitMenuInstance extends Instance {
31
+ open(): void;
32
+ close(): void;
33
+ toggle(): void;
34
+ readonly isOpen: boolean;
35
+ }
36
+
37
+ export function createOrbitMenu(root: HTMLElement | null, options: OrbitMenuOptions = {}): OrbitMenuInstance {
38
+ const toggle = root?.querySelector<HTMLButtonElement>('[data-mk-orbit-toggle]') ?? null;
39
+ const items = root ? [...root.querySelectorAll<HTMLElement>('[data-mk-orbit-item]')] : [];
40
+ if (!root || !toggle || !items.length) {
41
+ if (root) warnOnce('orbit-menu: needs a [data-mk-orbit-toggle] and at least one [data-mk-orbit-item]');
42
+ return inert({ open() {}, close() {}, toggle() {}, isOpen: false });
43
+ }
44
+ registerMotion();
45
+ const scope = createScope();
46
+ const radius = Number.isFinite(options.radius) ? Math.max(24, options.radius!) : 96;
47
+ const start = ((Number.isFinite(options.start) ? options.start! : 0) * Math.PI) / 180;
48
+ const angles = items.map((_, i) => start + (i * Math.PI * 2) / items.length);
49
+ const valueOf = (item: HTMLElement) => item.dataset.mkValue ?? String(items.indexOf(item));
50
+
51
+ // The lines that turn into an ×, when the button brings no icon of its own.
52
+ if (!toggle.children.length && !toggle.textContent?.trim()) {
53
+ const burger = document.createElement('span');
54
+ burger.dataset.mkOrbitBurger = '';
55
+ burger.setAttribute('aria-hidden', 'true');
56
+ burger.append(document.createElement('i'), document.createElement('i'), document.createElement('i'));
57
+ toggle.append(burger);
58
+ scope.add(() => burger.remove());
59
+ }
60
+ scope.attr(toggle, 'aria-expanded', 'false');
61
+ scope.attr(toggle, 'aria-haspopup', 'true');
62
+ if (!toggle.getAttribute('aria-label') && !toggle.textContent?.trim()) scope.attr(toggle, 'aria-label', 'Menú');
63
+ for (const item of items) {
64
+ scope.attr(item, 'inert', '');
65
+ if (item instanceof HTMLButtonElement) scope.attr(item, 'type', item.getAttribute('type') ?? 'button');
66
+ }
67
+ scope.attr(root, 'data-mk-ready', '');
68
+ scope.attr(root, 'data-mk-state', 'closed');
69
+
70
+ const states = items.map((_, i) => ({ a: angles[i]!, r: 0 }));
71
+ const apply = (i: number) => {
72
+ const { a, r } = states[i]!;
73
+ gsap.set(items[i]!, { x: r * Math.sin(a), y: -r * Math.cos(a) });
74
+ };
75
+ let timeline: gsap.core.Timeline | null = null;
76
+ scope.add(() => {
77
+ timeline?.kill();
78
+ gsap.set(items, { clearProps: 'transform,opacity,filter,visibility' });
79
+ });
80
+ gsap.set(items, { opacity: 0, scale: 0.3, visibility: 'hidden' });
81
+
82
+ let open = false;
83
+ const set = (next: boolean, focus: 'first' | 'toggle' | false = false) => {
84
+ if (next === open) return;
85
+ open = next;
86
+ toggle.setAttribute('aria-expanded', String(open));
87
+ root.dataset.mkState = open ? 'open' : 'closed';
88
+ for (const item of items) item.toggleAttribute('inert', !open);
89
+ timeline?.kill();
90
+ if (reduced()) {
91
+ items.forEach((item, i) => {
92
+ states[i] = { a: angles[i]!, r: open ? radius : 0 };
93
+ apply(i);
94
+ gsap.set(item, { opacity: open ? 1 : 0, scale: open ? 1 : 0.3, filter: 'none', visibility: open ? 'visible' : 'hidden' });
95
+ });
96
+ } else if (open) {
97
+ timeline = gsap.timeline();
98
+ items.forEach((item, i) => {
99
+ const state = states[i]!;
100
+ // From a little behind its place, swirling out to it.
101
+ if (state.r < 1) state.a = angles[i]! - 0.7;
102
+ const at = i * 0.045;
103
+ gsap.set(item, { visibility: 'visible' });
104
+ timeline!.to(state, { r: radius, duration: 0.55, ease: EASE.swatch, onUpdate: () => apply(i) }, at);
105
+ timeline!.to(state, { a: angles[i]!, duration: 0.5, ease: 'power3.out', onUpdate: () => apply(i) }, at);
106
+ timeline!.fromTo(item, { filter: 'blur(8px)' }, { opacity: 1, scale: 1, filter: 'blur(0px)', duration: 0.35, ease: 'power2.out', clearProps: 'filter' }, at);
107
+ });
108
+ } else {
109
+ timeline = gsap.timeline();
110
+ [...items].reverse().forEach((item, k) => {
111
+ const i = items.length - 1 - k;
112
+ const state = states[i]!;
113
+ const at = k * 0.03;
114
+ timeline!.to(state, { r: 0, a: state.a + 0.6, duration: 0.3, ease: 'power2.in', onUpdate: () => apply(i) }, at);
115
+ timeline!.to(item, { opacity: 0, scale: 0.3, filter: 'blur(6px)', duration: 0.28, ease: 'power2.in', onComplete: () => {
116
+ gsap.set(item, { visibility: 'hidden', clearProps: 'filter' });
117
+ } }, at);
118
+ });
119
+ }
120
+ if (focus === 'first') items[0]!.focus();
121
+ if (focus === 'toggle') toggle.focus();
122
+ options.onToggle?.(open);
123
+ root.dispatchEvent(new CustomEvent('mk:toggle', { bubbles: true, detail: { open } }));
124
+ };
125
+
126
+ scope.on(toggle, 'click', (event) => {
127
+ press(toggle);
128
+ // A keyboard click (detail 0) opens onto the first option.
129
+ set(!open, !open && (event as MouseEvent).detail === 0 ? 'first' : false);
130
+ });
131
+ scope.on(root, 'click', (event) => {
132
+ const item = (event.target as Element).closest<HTMLElement>('[data-mk-orbit-item]');
133
+ if (!item || !items.includes(item) || !open) return;
134
+ press(item);
135
+ const value = valueOf(item);
136
+ options.onSelect?.(value);
137
+ root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { value } }));
138
+ if (options.closeOnSelect !== false) set(false, 'toggle');
139
+ });
140
+ // Arrows: the option on that side (the nearest angle).
141
+ const sides: Record<string, number> = { ArrowUp: 0, ArrowRight: Math.PI / 2, ArrowDown: Math.PI, ArrowLeft: (Math.PI * 3) / 2 };
142
+ scope.on(root, 'keydown', (event) => {
143
+ const key = (event as KeyboardEvent).key;
144
+ if (key === 'Escape' && open) {
145
+ event.preventDefault();
146
+ set(false, 'toggle');
147
+ return;
148
+ }
149
+ if (!(key in sides)) return;
150
+ if (!open) {
151
+ if (document.activeElement !== toggle) return;
152
+ event.preventDefault();
153
+ set(true);
154
+ } else event.preventDefault();
155
+ const want = sides[key]!;
156
+ const gap = (a: number) => {
157
+ const d = Math.abs((((a - want) % (Math.PI * 2)) + Math.PI * 3) % (Math.PI * 2) - Math.PI);
158
+ return d;
159
+ };
160
+ let best = 0;
161
+ angles.forEach((a, i) => {
162
+ if (gap(a) < gap(angles[best]!)) best = i;
163
+ });
164
+ items[best]!.focus();
165
+ });
166
+ scope.on(document, 'pointerdown', (event) => {
167
+ if (open && !root.contains(event.target as Node)) set(false);
168
+ });
169
+
170
+ return {
171
+ open: () => set(true),
172
+ close: () => set(false),
173
+ toggle: () => set(!open),
174
+ get isOpen() {
175
+ return open;
176
+ },
177
+ destroy: () => scope.dispose(),
178
+ };
179
+ }
@@ -0,0 +1,129 @@
1
+ /* orbit-menu — soft white squares (16 px corners, a wide low shadow) around a middle button whose lines turn into an ×.
2
+ The options sit on the middle and the engine moves them out; give the menu room around it. */
3
+ .mk-orbit-menu,
4
+ mk-orbit-menu {
5
+ --mk-orbit-size: 4rem;
6
+ --mk-orbit-face: var(--mk-surface-3);
7
+ --mk-orbit-ink: var(--mk-text);
8
+ --mk-orbit-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.22), 0 2px 6px -2px rgb(0 0 0 / 0.08);
9
+ position: relative;
10
+ display: inline-grid;
11
+ place-items: center;
12
+ width: var(--mk-orbit-size);
13
+ height: var(--mk-orbit-size);
14
+ }
15
+
16
+ :is(.mk-orbit-menu, mk-orbit-menu) :is([data-mk-orbit-toggle], [data-mk-orbit-item]) {
17
+ display: grid;
18
+ place-items: center;
19
+ width: var(--mk-orbit-size);
20
+ height: var(--mk-orbit-size);
21
+ padding: 0;
22
+ border: 1px solid var(--mk-outline-soft);
23
+ border-radius: var(--mk-radius-md);
24
+ background: var(--mk-orbit-face);
25
+ box-shadow: var(--mk-orbit-shadow);
26
+ color: var(--mk-orbit-ink);
27
+ cursor: pointer;
28
+ -webkit-tap-highlight-color: transparent;
29
+ }
30
+
31
+ :is(.mk-orbit-menu, mk-orbit-menu) [data-mk-orbit-toggle] {
32
+ position: relative;
33
+ z-index: 2;
34
+ transition:
35
+ background-color 0.3s ease,
36
+ box-shadow 0.3s ease;
37
+ }
38
+
39
+ /* Open: the middle goes glassy, the options are the faces now. */
40
+ :is(.mk-orbit-menu, mk-orbit-menu)[data-mk-state='open'] [data-mk-orbit-toggle] {
41
+ background: color-mix(in srgb, var(--mk-orbit-face) 55%, transparent);
42
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mk-orbit-ink) 8%, transparent);
43
+ backdrop-filter: blur(8px);
44
+ }
45
+
46
+ :is(.mk-orbit-menu, mk-orbit-menu) [data-mk-orbit-item] {
47
+ position: absolute;
48
+ top: 0;
49
+ left: 0;
50
+ z-index: 1;
51
+ }
52
+
53
+ :is(.mk-orbit-menu, mk-orbit-menu) :is([data-mk-orbit-toggle], [data-mk-orbit-item]) svg {
54
+ width: 1.75rem;
55
+ height: 1.75rem;
56
+ transition: transform 0.35s var(--mk-ease-swatch);
57
+ }
58
+
59
+ @media (hover: hover) {
60
+ :is(.mk-orbit-menu, mk-orbit-menu) [data-mk-orbit-item]:hover svg {
61
+ transform: scale(1.12);
62
+ }
63
+ :is(.mk-orbit-menu, mk-orbit-menu) [data-mk-orbit-item]:hover {
64
+ box-shadow: 0 22px 40px -14px rgb(0 0 0 / 0.28), 0 2px 6px -2px rgb(0 0 0 / 0.1);
65
+ }
66
+ }
67
+
68
+ :is(.mk-orbit-menu, mk-orbit-menu) :is([data-mk-orbit-toggle], [data-mk-orbit-item]):focus-visible {
69
+ outline: 2px solid var(--mk-accent);
70
+ outline-offset: 3px;
71
+ }
72
+
73
+ /* The three lines and their × . */
74
+ [data-mk-orbit-burger] {
75
+ position: relative;
76
+ width: 1.5rem;
77
+ height: 1rem;
78
+ }
79
+
80
+ [data-mk-orbit-burger] i {
81
+ position: absolute;
82
+ left: 0;
83
+ width: 100%;
84
+ height: 2px;
85
+ border-radius: 2px;
86
+ background: currentColor;
87
+ transition:
88
+ transform 0.4s var(--mk-ease-swatch),
89
+ opacity 0.2s ease;
90
+ }
91
+
92
+ [data-mk-orbit-burger] i:nth-child(1) {
93
+ top: 0;
94
+ }
95
+
96
+ [data-mk-orbit-burger] i:nth-child(2) {
97
+ top: calc(50% - 1px);
98
+ }
99
+
100
+ [data-mk-orbit-burger] i:nth-child(3) {
101
+ bottom: 0;
102
+ }
103
+
104
+ :is(.mk-orbit-menu, mk-orbit-menu)[data-mk-state='open'] [data-mk-orbit-burger] i:nth-child(1) {
105
+ transform: translateY(calc(0.5rem - 1px)) rotate(45deg);
106
+ }
107
+
108
+ :is(.mk-orbit-menu, mk-orbit-menu)[data-mk-state='open'] [data-mk-orbit-burger] i:nth-child(2) {
109
+ opacity: 0;
110
+ transform: scaleX(0.2);
111
+ }
112
+
113
+ :is(.mk-orbit-menu, mk-orbit-menu)[data-mk-state='open'] [data-mk-orbit-burger] i:nth-child(3) {
114
+ transform: translateY(calc(-0.5rem + 1px)) rotate(-45deg);
115
+ }
116
+
117
+ @media (prefers-reduced-motion: reduce) {
118
+ [data-mk-orbit-burger] i,
119
+ :is(.mk-orbit-menu, mk-orbit-menu) [data-mk-orbit-toggle],
120
+ :is(.mk-orbit-menu, mk-orbit-menu) svg {
121
+ transition: none;
122
+ }
123
+ }
124
+
125
+ @media (forced-colors: active) {
126
+ :is(.mk-orbit-menu, mk-orbit-menu) :is([data-mk-orbit-toggle], [data-mk-orbit-item]) {
127
+ border: 1px solid ButtonText;
128
+ }
129
+ }
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject, type ReactNode } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createOrbitMenu, type OrbitMenuInstance, type OrbitMenuOptions } from './engine';
5
+
6
+ export interface OrbitMenuItem {
7
+ value: string;
8
+ label: string;
9
+ icon: ReactNode;
10
+ }
11
+
12
+ export interface OrbitMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'onToggle'>, OrbitMenuOptions {
13
+ items: OrbitMenuItem[];
14
+ /** The middle button's name (default 'Menú'). */
15
+ label?: string;
16
+ instanceRef?: MutableRefObject<OrbitMenuInstance | null>;
17
+ }
18
+
19
+ /** <OrbitMenu label="Acciones" items={[{ value: 'nuevo', label: 'Nuevo', icon: <Plus /> }, …]} onSelect={run} /> */
20
+ export function OrbitMenu({ items, label = 'Menú', radius, start, closeOnSelect, onToggle, onSelect, instanceRef, className, ...props }: OrbitMenuProps) {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+ const toggled = useLatest(onToggle);
23
+ const selected = useLatest(onSelect);
24
+ const keys = items.map((item) => item.value).join('\u0000');
25
+ useIsomorphicLayoutEffect(() => {
26
+ const created = createOrbitMenu(ref.current, { radius, start, closeOnSelect, onToggle: (open) => toggled.current?.(open), onSelect: (value) => selected.current?.(value) });
27
+ if (instanceRef) instanceRef.current = created;
28
+ return () => {
29
+ created.destroy();
30
+ if (instanceRef) instanceRef.current = null;
31
+ };
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [keys, radius, start, closeOnSelect]);
34
+ return (
35
+ <div {...props} ref={ref} className={['mk-orbit-menu', className].filter(Boolean).join(' ')}>
36
+ <button type="button" data-mk-orbit-toggle="" aria-label={label} />
37
+ {items.map((item) => (
38
+ <button key={item.value} type="button" data-mk-orbit-item="" data-mk-value={item.value} aria-label={item.label}>
39
+ {item.icon}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ );
44
+ }
@@ -1,6 +1,6 @@
1
1
  # pagination
2
2
 
3
- Paginación con ventana acotada de números, extremos, elipsis y respuesta de elevación/presión.
3
+ Paginación con ventana acotada de números, extremos y elipsis. Una píldora de acento se desliza bajo la página actual (.4 s, curva swing); cuando la ventana se mueve, los números que siguen se deslizan a su sitio nuevo y los nuevos entran con un pop desde un blur; las flechas hacen el press y se inclinan hacia su lado; al pasar, los botones se elevan.
4
4
 
5
5
  ```html
6
6
  <mk-pagination class="mk-pagination" total-pages="30" page="4" role="navigation" aria-label="Artículos">
@@ -1,5 +1,8 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
1
3
  import { createScope } from '../../core/scope';
2
4
  import { inert, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
3
6
 
4
7
  export interface PaginationOptions {
5
8
  totalPages?: number;
@@ -17,20 +20,45 @@ export interface PaginationInstance extends Instance {
17
20
  // Reserve room for the page-window arithmetic so incrementing always progresses.
18
21
  const positive = (n: number | undefined, fallback: number) => Number.isFinite(n) ? Math.max(1, Math.min(Number.MAX_SAFE_INTEGER - 32, Math.floor(n!))) : fallback;
19
22
 
20
- /** Renders a bounded window of pages, even when totalPages is very large. */
23
+ /**
24
+ * Renders a bounded window of pages, even when totalPages is very large. A pill
25
+ * ([data-mk-pagination-indicator]) glides under the current page on Melonmind's
26
+ * swing; when the window moves, the numbers that stay slide to their new place
27
+ * and the new ones pop in out of a blur; the arrows press and nudge their way.
28
+ */
21
29
  export function createPagination(root: HTMLElement | null, options: PaginationOptions = {}): PaginationInstance {
22
30
  const pages = root?.querySelector<HTMLElement>('[data-mk-pagination-pages]');
23
31
  if (!root || !pages) return inert({ setPage() {}, setTotalPages() {}, page: 1, totalPages: 1 });
32
+ registerMotion();
24
33
  const scope = createScope();
25
34
  const original = [...pages.childNodes];
35
+ const indicator = document.createElement('span');
36
+ indicator.dataset.mkPaginationIndicator = '';
37
+ indicator.setAttribute('aria-hidden', 'true');
38
+ const effects = new Set<gsap.core.Animation>();
39
+ const keep = (animation: gsap.core.Animation | null) => {
40
+ if (!animation) return;
41
+ effects.add(animation);
42
+ // Finished ones leave as new ones arrive: the animation's own onComplete stays untouched.
43
+ for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
44
+ };
45
+ scope.add(() => {
46
+ for (const effect of effects) effect.kill();
47
+ effects.clear();
48
+ });
49
+ let placed = false;
26
50
  const previous = root.querySelector<HTMLButtonElement>('[data-mk-pagination-previous]');
27
51
  const next = root.querySelector<HTMLButtonElement>('[data-mk-pagination-next]');
28
52
  let total = positive(options.totalPages, 10);
29
53
  let page = Math.min(total, positive(options.page, 1));
30
54
  const siblings = Math.min(10, Math.max(0, Number.isFinite(options.siblings) ? Math.floor(options.siblings!) : 1));
31
55
  scope.attr(root, 'data-mk-ready', '');
32
- const render = () => {
56
+ // The pill draws the current page (the buttons themselves stay clear).
57
+ scope.attr(root, 'data-mk-glide', '');
58
+ const render = (animate = false) => {
33
59
  const hadFocus = pages.contains(document.activeElement);
60
+ // Where each number was, to slide the ones that stay.
61
+ const before = new Map([...pages.querySelectorAll<HTMLElement>('[data-mk-page]')].map((button) => [button.dataset.mkPage!, button.offsetLeft]));
34
62
  const selected = new Set([1, total]);
35
63
  for (let n = Math.max(1, page - siblings); n <= Math.min(total, page + siblings); n++) selected.add(n);
36
64
  const fragment = document.createDocumentFragment();
@@ -52,8 +80,23 @@ export function createPagination(root: HTMLElement | null, options: PaginationOp
52
80
  fragment.append(button);
53
81
  last = n;
54
82
  }
55
- pages.replaceChildren(fragment);
83
+ pages.replaceChildren(indicator, fragment);
56
84
  if (hadFocus) pages.querySelector<HTMLButtonElement>('[aria-current]')?.focus();
85
+ const motion = animate && !reduced();
86
+ if (motion) {
87
+ for (const button of pages.querySelectorAll<HTMLElement>('[data-mk-page]')) {
88
+ const from = before.get(button.dataset.mkPage!);
89
+ if (from === undefined) keep(gsap.fromTo(button, { opacity: 0, scale: 0.6, filter: 'blur(4px)' }, { opacity: 1, scale: 1, filter: 'blur(0px)', duration: 0.3, ease: EASE.pop, clearProps: 'opacity,transform,filter' }));
90
+ else if (from !== button.offsetLeft) keep(gsap.fromTo(button, { x: from - button.offsetLeft }, { x: 0, duration: 0.35, ease: EASE.swatch, clearProps: 'transform' }));
91
+ }
92
+ }
93
+ const current = pages.querySelector<HTMLElement>('[aria-current]');
94
+ if (current) {
95
+ const box = { x: current.offsetLeft, width: current.offsetWidth, height: current.offsetHeight };
96
+ if (motion && placed) keep(gsap.to(indicator, { ...box, duration: 0.4, ease: EASE.swatch }));
97
+ else gsap.set(indicator, box);
98
+ placed = true;
99
+ }
57
100
  if (previous) scope.attr(previous, 'disabled', page === 1 ? '' : null);
58
101
  if (next) scope.attr(next, 'disabled', page === total ? '' : null);
59
102
  };
@@ -63,7 +106,7 @@ export function createPagination(root: HTMLElement | null, options: PaginationOp
63
106
  if (candidate === page) return;
64
107
  const focused = pages.contains(document.activeElement);
65
108
  page = candidate;
66
- render();
109
+ render(true);
67
110
  if (focused) pages.querySelector<HTMLButtonElement>('[aria-current]')?.focus();
68
111
  if (notify) {
69
112
  options.onChange?.(page);
@@ -73,6 +116,9 @@ export function createPagination(root: HTMLElement | null, options: PaginationOp
73
116
  scope.on(root, 'click', (event) => {
74
117
  const target = (event.target as Element).closest<HTMLButtonElement>('button');
75
118
  if (!target || target.disabled) return;
119
+ if (!reduced()) keep(press(target));
120
+ // The arrow leans the way it goes, then settles.
121
+ if ((target === previous || target === next) && !reduced()) keep(gsap.fromTo(target, { x: target === next ? 4 : -4 }, { x: 0, duration: 0.45, ease: EASE.click, clearProps: 'x' }));
76
122
  if (target === previous) setPage(page - 1);
77
123
  else if (target === next) setPage(page + 1);
78
124
  else if (target.dataset.mkPage) setPage(Number(target.dataset.mkPage));
@@ -1,10 +1,15 @@
1
+ /* pagination — round page buttons on surface-2; a pill (accent) glides under the current page; numbers that stay slide
2
+ when the window moves and new ones pop in; hover lifts, press squeezes. */
1
3
  .mk-pagination, mk-pagination { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .35rem; }
2
- .mk-pagination [data-mk-pagination-pages], mk-pagination [data-mk-pagination-pages] { display: flex; align-items: center; gap: .25rem; }
3
- .mk-pagination button, mk-pagination button { min-width: 2.5rem; height: 2.5rem; border: 0; border-radius: var(--mk-radius-full); background: var(--mk-surface-2); color: var(--mk-text); font: inherit; cursor: pointer; transition: transform .3s, background .3s, color .3s; }
4
+ .mk-pagination [data-mk-pagination-pages], mk-pagination [data-mk-pagination-pages] { position: relative; display: flex; align-items: center; gap: .25rem; isolation: isolate; }
5
+ .mk-pagination button, mk-pagination button { position: relative; min-width: 2.5rem; height: 2.5rem; border: 0; border-radius: var(--mk-radius-full); background: var(--mk-surface-2); color: var(--mk-text); font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; transition: translate .3s cubic-bezier(.38,.49,0,1.5), background-color .25s, color .25s; }
4
6
  .mk-pagination button[aria-current], mk-pagination button[aria-current] { background: var(--mk-accent); color: var(--mk-on-accent); }
5
- .mk-pagination button:hover:not(:disabled), mk-pagination button:hover:not(:disabled) { transform: translateY(-3px); }
6
- .mk-pagination button:active:not(:disabled), mk-pagination button:active:not(:disabled) { transform: scale(.9); }
7
+ /* With the engine, the pill paints the current page and its button stays clear. */
8
+ :is(.mk-pagination, mk-pagination)[data-mk-glide] button[aria-current] { background: transparent; }
9
+ :is(.mk-pagination, mk-pagination) [data-mk-pagination-indicator] { position: absolute; top: 0; left: 0; z-index: -1; border-radius: var(--mk-radius-full); background: var(--mk-accent); pointer-events: none; }
10
+ .mk-pagination button:hover:not(:disabled), mk-pagination button:hover:not(:disabled) { translate: 0 -3px; }
11
+ .mk-pagination button:hover:not(:disabled):not([aria-current]), mk-pagination button:hover:not(:disabled):not([aria-current]) { background: var(--mk-surface-3); }
7
12
  .mk-pagination button:focus-visible, mk-pagination button:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
8
13
  .mk-pagination button:disabled, mk-pagination button:disabled { opacity: .35; cursor: default; }
9
14
  .mk-pagination__gap { padding: 0 .15rem; color: var(--mk-muted); }
10
- @media (prefers-reduced-motion: reduce) { .mk-pagination button, mk-pagination button { transition: none; transform: none !important; } }
15
+ @media (prefers-reduced-motion: reduce) { .mk-pagination button, mk-pagination button { transition: none; translate: none !important; } }
@@ -0,0 +1,48 @@
1
+ # pie-chart
2
+
3
+ Gráfica circular o donut con entrada de barrido, selección expansiva y total animado.
4
+
5
+ ## Instalación
6
+
7
+ ```tsx
8
+ import '@yewdiel21/motion-kit/css';
9
+ import { PieChart } from '@yewdiel21/motion-kit/react';
10
+ ```
11
+
12
+ ```sh
13
+ npx @yewdiel21/motion-kit add pie-chart --react --elements
14
+ ```
15
+
16
+ ## React
17
+
18
+ ```tsx
19
+ <PieChart label="Ventas" data={[{ label: "Tienda", value: 60 }, { label: "Web", value: 40 }]} />
20
+ ```
21
+
22
+ Los callbacks del ejemplo (`ask`, `run`, `setTool`, `setVolume`) pertenecen a tu aplicación. React acepta `className`, `style` y atributos HTML. Los componentes con estado exponen `instanceRef` (excepto PillButton).
23
+
24
+ ## HTML / Astro
25
+
26
+ ```html
27
+ <mk-pie-chart label="Ventas" data='[{"label":"Tienda","value":60},{"label":"Web","value":40}]'></mk-pie-chart>
28
+ <script type="module">
29
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
30
+ defineElements(['mk-pie-chart']);
31
+ </script>
32
+ ```
33
+
34
+ En Astro usa un `<script>` procesado por Astro. En HTML directo el import requiere bundler o import map. También puedes usar `import { createPieChart } from '@yewdiel21/motion-kit'` y llamar a la factory con el contenedor y las mismas opciones. Al desmontar, llama a `instance.destroy()`.
35
+
36
+ ## Opciones
37
+
38
+ data: {label, value, color?}[]. donut: 0.62 (0 para pie). gap: 1.5 grados. size: 240 px. totalLabel: Total. legend: true. Se omiten valores no finitos o no positivos.
39
+
40
+ Todos los gráficos aceptan `label`, `emptyLabel` y `formatValue`. Se actualizan con `update(options)` sin desmontarse; `select(index | null)` y `selected` controlan la selección (en heatmap se usa una fecha). `onSelect` también emite `mk:select`.
41
+
42
+ Los nombres camelCase se escriben como atributos kebab-case. Los arrays y objetos se pasan como JSON. Los booleanos aceptan atributo vacío o `"false"`.
43
+
44
+ ## Estilos y accesibilidad
45
+
46
+ Variables: --mk-chart-1…8, --mk-chart-surface.
47
+
48
+ Respeta los colores del tema, foco visible y `prefers-reduced-motion`. Los gráficos incluyen una tabla accesible y selección con flechas, Home, End y Escape. Heatmap usa arriba/abajo para un día e izquierda/derecha para una semana. Toolbar usa flechas y Home/End; OrbitMenu cierra con Escape y devuelve el foco. Los controles conservan nombres accesibles; VoiceDots anuncia los estados sin leer cada fotograma.