@yewdiel21/motion-kit 0.14.0 → 0.16.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 (156) hide show
  1. package/README.md +178 -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-NFZIX3IA.js} +2 -2
  7. package/dist/chunk-RBEKR5UZ.js +3660 -0
  8. package/dist/chunk-RBEKR5UZ.js.map +1 -0
  9. package/dist/{chunk-CU6SXW3E.js → chunk-SDQ776LG.js} +1596 -485
  10. package/dist/chunk-SDQ776LG.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/chart.css +7 -0
  14. package/dist/css/components/data-table.css +121 -26
  15. package/dist/css/components/filter-builder.css +10 -15
  16. package/dist/css/components/heatmap-chart.css +398 -0
  17. package/dist/css/components/hover-card.css +8 -2
  18. package/dist/css/components/image-preview.css +90 -2
  19. package/dist/css/components/json-viewer.css +33 -1
  20. package/dist/css/components/nav.css +102 -102
  21. package/dist/css/components/orbit-menu.css +130 -0
  22. package/dist/css/components/pagination.css +10 -5
  23. package/dist/css/components/pie-chart.css +397 -0
  24. package/dist/css/components/pill-button.css +135 -0
  25. package/dist/css/components/radar-chart.css +377 -0
  26. package/dist/css/components/radial-chart.css +412 -0
  27. package/dist/css/components/sankey-chart.css +365 -0
  28. package/dist/css/components/scroll-island.css +114 -0
  29. package/dist/css/components/select.css +136 -0
  30. package/dist/css/components/toolbar.css +122 -0
  31. package/dist/css/components/tree-view.css +79 -0
  32. package/dist/css/components/voice-dots.css +70 -0
  33. package/dist/elements/index.d.ts +180 -7
  34. package/dist/elements/index.js +293 -7
  35. package/dist/elements/index.js.map +1 -1
  36. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  37. package/dist/index.css +1849 -168
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +33 -3
  40. package/dist/index.js.map +1 -1
  41. package/dist/react/index.d.ts +117 -11
  42. package/dist/react/index.js +320 -13
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/{reduced-BBUusld6.d.ts → reduced-DQwVXQYb.d.ts} +1 -1
  45. package/dist/{reveal-DEvp6U6T.d.ts → reveal-DRpNO4KP.d.ts} +109 -4
  46. package/dist/store-Da3zSh8Z.d.ts +457 -0
  47. package/package.json +9 -16
  48. package/registry.json +1096 -948
  49. package/src/auto/auto-init.ts +6 -0
  50. package/src/components/add-to-cart/README.md +49 -0
  51. package/src/components/add-to-cart/add-to-cart.css +71 -0
  52. package/src/components/add-to-cart/element.ts +20 -0
  53. package/src/components/add-to-cart/engine.ts +206 -0
  54. package/src/components/add-to-cart/react.tsx +33 -0
  55. package/src/components/bidirectional-slider/README.md +46 -0
  56. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  57. package/src/components/bidirectional-slider/element.ts +42 -0
  58. package/src/components/bidirectional-slider/engine.ts +289 -0
  59. package/src/components/bidirectional-slider/react.tsx +45 -0
  60. package/src/components/chart/README.md +49 -24
  61. package/src/components/chart/chart.css +7 -0
  62. package/src/components/chart/element.ts +4 -1
  63. package/src/components/chart/engine.ts +153 -34
  64. package/src/components/chart/parts.ts +237 -0
  65. package/src/components/chart/react.tsx +2 -2
  66. package/src/components/chart/use-chart.ts +55 -0
  67. package/src/components/data-table/README.md +3 -3
  68. package/src/components/data-table/data-table.css +121 -26
  69. package/src/components/data-table/engine.ts +81 -29
  70. package/src/components/editable-table/engine.ts +67 -11
  71. package/src/components/filter-builder/engine.ts +33 -2
  72. package/src/components/filter-builder/filter-builder.css +10 -15
  73. package/src/components/heatmap-chart/README.md +48 -0
  74. package/src/components/heatmap-chart/element.ts +21 -0
  75. package/src/components/heatmap-chart/engine.ts +281 -0
  76. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  77. package/src/components/heatmap-chart/react.tsx +14 -0
  78. package/src/components/hover-card/engine.ts +30 -1
  79. package/src/components/hover-card/hover-card.css +8 -2
  80. package/src/components/image-preview/element.ts +1 -1
  81. package/src/components/image-preview/engine.ts +95 -15
  82. package/src/components/image-preview/image-preview.css +90 -2
  83. package/src/components/image-preview/react.tsx +3 -3
  84. package/src/components/json-viewer/engine.ts +11 -2
  85. package/src/components/json-viewer/json-viewer.css +33 -1
  86. package/src/components/nav/README.md +12 -10
  87. package/src/components/nav/element.ts +5 -5
  88. package/src/components/nav/engine.ts +21 -14
  89. package/src/components/nav/nav.css +102 -102
  90. package/src/components/nav/react.tsx +4 -4
  91. package/src/components/orbit-menu/README.md +46 -0
  92. package/src/components/orbit-menu/element.ts +27 -0
  93. package/src/components/orbit-menu/engine.ts +179 -0
  94. package/src/components/orbit-menu/orbit-menu.css +129 -0
  95. package/src/components/orbit-menu/react.tsx +44 -0
  96. package/src/components/pagination/README.md +1 -1
  97. package/src/components/pagination/engine.ts +50 -4
  98. package/src/components/pagination/pagination.css +10 -5
  99. package/src/components/pie-chart/README.md +48 -0
  100. package/src/components/pie-chart/element.ts +30 -0
  101. package/src/components/pie-chart/engine.ts +283 -0
  102. package/src/components/pie-chart/pie-chart.css +92 -0
  103. package/src/components/pie-chart/react.tsx +14 -0
  104. package/src/components/pill-button/README.md +46 -0
  105. package/src/components/pill-button/element.ts +16 -0
  106. package/src/components/pill-button/engine.ts +77 -0
  107. package/src/components/pill-button/pill-button.css +134 -0
  108. package/src/components/pill-button/react.tsx +32 -0
  109. package/src/components/radar-chart/README.md +48 -0
  110. package/src/components/radar-chart/element.ts +30 -0
  111. package/src/components/radar-chart/engine.ts +327 -0
  112. package/src/components/radar-chart/radar-chart.css +72 -0
  113. package/src/components/radar-chart/react.tsx +14 -0
  114. package/src/components/radial-chart/README.md +58 -0
  115. package/src/components/radial-chart/element.ts +30 -0
  116. package/src/components/radial-chart/engine.ts +317 -0
  117. package/src/components/radial-chart/radial-chart.css +107 -0
  118. package/src/components/radial-chart/react.tsx +14 -0
  119. package/src/components/sankey-chart/README.md +48 -0
  120. package/src/components/sankey-chart/element.ts +31 -0
  121. package/src/components/sankey-chart/engine.ts +401 -0
  122. package/src/components/sankey-chart/react.tsx +14 -0
  123. package/src/components/sankey-chart/sankey-chart.css +60 -0
  124. package/src/components/scroll-island/README.md +44 -0
  125. package/src/components/scroll-island/element.ts +16 -0
  126. package/src/components/scroll-island/engine.ts +274 -0
  127. package/src/components/scroll-island/react.tsx +26 -0
  128. package/src/components/scroll-island/scroll-island.css +113 -0
  129. package/src/components/select/README.md +34 -0
  130. package/src/components/select/element.ts +26 -0
  131. package/src/components/select/engine.ts +267 -0
  132. package/src/components/select/react.tsx +33 -0
  133. package/src/components/select/select.css +135 -0
  134. package/src/components/toolbar/README.md +46 -0
  135. package/src/components/toolbar/element.ts +30 -0
  136. package/src/components/toolbar/engine.ts +230 -0
  137. package/src/components/toolbar/react.tsx +34 -0
  138. package/src/components/toolbar/toolbar.css +121 -0
  139. package/src/components/tree-view/element.ts +1 -1
  140. package/src/components/tree-view/engine.ts +174 -0
  141. package/src/components/tree-view/react.tsx +7 -3
  142. package/src/components/tree-view/tree-view.css +79 -0
  143. package/src/components/voice-dots/README.md +48 -0
  144. package/src/components/voice-dots/element.ts +45 -0
  145. package/src/components/voice-dots/engine.ts +243 -0
  146. package/src/components/voice-dots/react.tsx +52 -0
  147. package/src/components/voice-dots/voice-dots.css +69 -0
  148. package/src/elements/index.ts +39 -0
  149. package/src/index.ts +16 -0
  150. package/src/motion/liquid.ts +14 -4
  151. package/src/react/index.ts +14 -1
  152. package/dist/chunk-6P4P7NU6.js +0 -1401
  153. package/dist/chunk-6P4P7NU6.js.map +0 -1
  154. package/dist/chunk-CU6SXW3E.js.map +0 -1
  155. package/dist/store-DjkTBX7f.d.ts +0 -195
  156. /package/dist/{chunk-EY4BJKC4.js.map → chunk-NFZIX3IA.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { requestFrame } from '../../core/frame';
2
+ import { reduced } from '../../core/reduced';
2
3
  import { createScope } from '../../core/scope';
3
4
  import { inert, type Instance } from '../../core/types';
4
5
 
@@ -20,12 +21,16 @@ let sequence = 0;
20
21
  /** Distance kept from the viewport edges. */
21
22
  const EDGE = 12;
22
23
  const GAP = 12;
24
+ /** Longest fold back into the trigger (the CSS one is .22 s); then it hides anyway. */
25
+ const FOLD_MS = 400;
23
26
 
24
27
  /**
25
28
  * Nonmodal preview: pointer, keyboard focus and tap all reveal the same panel.
26
29
  * The panel stays inside the viewport: it flips to the other side when its own
27
30
  * has no room and shifts sideways (--mk-hover-shift) away from the edges, again
28
31
  * on scroll and resize. Opened from the keyboard it appears without the entrance.
32
+ * It grows out of the trigger's centre (--mk-hover-origin-x) and, when the
33
+ * pointer leaves, folds back into it (data-mk-closing, .22 s) before hiding.
29
34
  */
30
35
  export function createHoverCard(root: HTMLElement | null, options: HoverCardOptions = {}): HoverCardInstance {
31
36
  const trigger = root?.querySelector<HTMLElement>('[data-mk-hover-trigger]');
@@ -68,6 +73,9 @@ export function createHoverCard(root: HTMLElement | null, options: HoverCardOpti
68
73
  if (left + box.width > view.width - EDGE) shift = view.width - EDGE - box.width - left;
69
74
  if (left + shift < EDGE) shift = EDGE - left;
70
75
  if (shift) root.style.setProperty('--mk-hover-shift', `${Math.round(shift)}px`);
76
+ // The panel grows from (and folds back into) the trigger's centre.
77
+ const target = trigger.getBoundingClientRect();
78
+ root.style.setProperty('--mk-hover-origin-x', `${Math.round(target.left + target.width / 2 - (left + shift))}px`);
71
79
  };
72
80
  let pending: (() => void) | null = null;
73
81
  const schedulePlace = () => {
@@ -80,13 +88,34 @@ export function createHoverCard(root: HTMLElement | null, options: HoverCardOpti
80
88
  scope.add(() => pending?.());
81
89
 
82
90
  const cancel = () => { clearTimeout(timer); timer = undefined; };
91
+ // The fold back into the trigger: the panel stays until its animation ends (or FOLD_MS passes).
92
+ let folding: ReturnType<typeof setTimeout> | undefined;
93
+ const settleFold = () => {
94
+ clearTimeout(folding);
95
+ folding = undefined;
96
+ panel.removeEventListener('animationend', settleFold);
97
+ if (scope.disposed) return;
98
+ scope.attr(root, 'data-mk-closing', null);
99
+ if (!opened) scope.attr(panel, 'hidden', '');
100
+ };
101
+ scope.add(() => {
102
+ clearTimeout(folding);
103
+ panel.removeEventListener('animationend', settleFold);
104
+ });
83
105
  const set = (value: boolean) => {
84
106
  cancel();
85
107
  if (scope.disposed) return;
86
108
  const changed = value !== opened;
87
109
  opened = value;
88
110
  scope.attr(trigger, 'aria-expanded', String(value));
89
- scope.attr(panel, 'hidden', value ? null : '');
111
+ if (value) {
112
+ if (folding !== undefined) settleFold();
113
+ scope.attr(panel, 'hidden', null);
114
+ } else if (changed && !reduced() && root.dataset.mkInput !== 'keyboard' && !panel.hidden) {
115
+ scope.attr(root, 'data-mk-closing', '');
116
+ panel.addEventListener('animationend', settleFold);
117
+ folding = setTimeout(settleFold, FOLD_MS);
118
+ } else if (folding === undefined) scope.attr(panel, 'hidden', '');
90
119
  scope.attr(root, 'data-mk-open', value ? '' : null);
91
120
  if (value && changed) {
92
121
  scope.attr(root, 'data-mk-input', pointerInput ? 'pointer' : 'keyboard');
@@ -1,6 +1,7 @@
1
1
  .mk-hover-card, mk-hover-card { position: relative; display: inline-flex; }
2
2
  .mk-hover-card [data-mk-hover-panel], mk-hover-card [data-mk-hover-panel] {
3
- transform-origin: 50% 100%;
3
+ /* It grows from, and folds back into, the trigger's centre (the engine measures it). */
4
+ transform-origin: var(--mk-hover-origin-x, 50%) 100%;
4
5
  position: absolute; z-index: 10; bottom: calc(100% + .75rem); left: var(--mk-hover-shift, 0);
5
6
  width: min(19rem, calc(100vw - 1.5rem)); padding: 1.25rem; border-radius: var(--mk-radius-lg);
6
7
  color: var(--mk-on-glass-inverse); background: var(--mk-glass-inverse); border: 0; border-radius: var(--mk-radius-lg, 24px);
@@ -10,10 +11,15 @@
10
11
  }
11
12
  /* Bridge over the gap so the pointer can travel into the panel. */
12
13
  .mk-hover-card [data-mk-hover-panel]::after, mk-hover-card [data-mk-hover-panel]::after { content: ''; position: absolute; inset: -1rem 0; z-index: -1; }
13
- :is(.mk-hover-card, mk-hover-card)[data-mk-side=bottom] [data-mk-hover-panel] { top: calc(100% + .75rem); bottom: auto; transform-origin: 50% 0; animation-name: mk-hover-card-down; }
14
+ :is(.mk-hover-card, mk-hover-card)[data-mk-side=bottom] [data-mk-hover-panel] { top: calc(100% + .75rem); bottom: auto; transform-origin: var(--mk-hover-origin-x, 50%) 0; animation-name: mk-hover-card-down; }
15
+ /* Leaving: it shrinks back into the trigger out of a blur, then the engine hides it. */
16
+ :is(.mk-hover-card, mk-hover-card)[data-mk-closing] [data-mk-hover-panel] { pointer-events: none; animation: mk-hover-card-out .22s cubic-bezier(.4,0,1,1) both; }
17
+ :is(.mk-hover-card, mk-hover-card)[data-mk-closing][data-mk-side=bottom] [data-mk-hover-panel] { animation-name: mk-hover-card-out-up; }
14
18
  /* Opened from the keyboard: no entrance. */
15
19
  :is(.mk-hover-card, mk-hover-card)[data-mk-input=keyboard] [data-mk-hover-panel] { animation: none; }
16
20
  .mk-hover-card [hidden], mk-hover-card [hidden] { display: none !important; }
17
21
  @keyframes mk-hover-card-in { from { opacity: 0; filter: blur(32px); transform: translateY(8px) scale(.7); } to { opacity: 1; filter: none; transform: none; } }
22
+ @keyframes mk-hover-card-out { to { opacity: 0; filter: blur(12px); transform: translateY(10px) scale(.3); } }
23
+ @keyframes mk-hover-card-out-up { to { opacity: 0; filter: blur(12px); transform: translateY(-10px) scale(.3); } }
18
24
  @keyframes mk-hover-card-down { from { opacity: 0; filter: blur(32px); transform: translateY(-8px) scale(.7); } to { opacity: 1; filter: none; transform: none; } }
19
25
  @media (prefers-reduced-motion: reduce) { .mk-hover-card [data-mk-hover-panel], mk-hover-card [data-mk-hover-panel] { animation: none; } }
@@ -8,7 +8,7 @@ import { createImagePreview, type ImagePreviewInstance } from './engine';
8
8
  * Emits mk:open, mk:change ({index, previous}) and mk:close.
9
9
  */
10
10
  export class MkImagePreviewElement extends MkElement<ImagePreviewInstance> {
11
- static options: Record<string, OptionType> = { loop: 'boolean', labels: 'json' };
11
+ static options: Record<string, OptionType> = { loop: 'boolean', strip: 'boolean', labels: 'json' };
12
12
 
13
13
  protected create(options: Record<string, unknown>) {
14
14
  return createImagePreview(this, options);
@@ -8,14 +8,19 @@ import { rollDigits } from '../../motion/micro';
8
8
  * Image preview: thumbnails ([data-mk-image]) open a modal <dialog> where the
9
9
  * picture grows out of its thumbnail (0.7 s on the signature curve, the
10
10
  * thumbnail's radius easing to 24 px) over a blurred backdrop. Previous/next
11
- * (buttons, arrow keys, a swipe) slide the next picture in out of a blur; the
11
+ * (buttons, arrow keys, a swipe, the strip of thumbnails) move both pictures at
12
+ * once: the old one leaves as a copy that slides away, shrinking into a blur,
13
+ * while the new one (decoded first) comes in from the side it goes to; a ring
14
+ * glides along the strip to the current thumbnail; the
12
15
  * counter rolls. A double click or tap zooms ×2 where you point and a drag pans
13
16
  * it. Dragging down, Escape or the close button send the picture back to its
14
17
  * thumbnail, and focus returns to it.
15
18
  */
16
19
 
17
20
  export interface ImagePreviewOptions {
18
- labels?: { close?: string; prev?: string; next?: string; dialog?: string };
21
+ labels?: { close?: string; prev?: string; next?: string; dialog?: string; strip?: string; picture?: string };
22
+ /** A strip of thumbnails under the picture (default true; hidden on phones and with one picture). */
23
+ strip?: boolean;
19
24
  /** Loop from the last picture to the first (default true). */
20
25
  loop?: boolean;
21
26
  onChange?: (index: number) => void;
@@ -52,7 +57,7 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
52
57
  registerMotion();
53
58
  const scope = createScope();
54
59
  const loop = options.loop ?? true;
55
- const labels = { close: 'Cerrar', prev: 'Imagen anterior', next: 'Imagen siguiente', dialog: 'Vista de imagen', ...options.labels };
60
+ const labels = { close: 'Cerrar', prev: 'Imagen anterior', next: 'Imagen siguiente', dialog: 'Vista de imagen', strip: 'Miniaturas', picture: 'Imagen', ...options.labels };
56
61
 
57
62
  const pictures = (): Picture[] =>
58
63
  [...root.querySelectorAll<HTMLElement>('[data-mk-image]')].map((thumb) => {
@@ -84,7 +89,15 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
84
89
  const counter = document.createElement('span');
85
90
  counter.dataset.mkLightbox = 'count';
86
91
  counter.setAttribute('aria-live', 'polite');
87
- dialog.append(backdrop, stage, prev, next, close, counter);
92
+ const strip = document.createElement('div');
93
+ strip.dataset.mkLightbox = 'strip';
94
+ strip.setAttribute('role', 'group');
95
+ strip.setAttribute('aria-label', labels.strip);
96
+ strip.hidden = true;
97
+ const ring = document.createElement('span');
98
+ ring.dataset.mkLightboxRing = '';
99
+ ring.setAttribute('aria-hidden', 'true');
100
+ dialog.append(backdrop, stage, prev, next, close, counter, strip);
88
101
  root.append(dialog);
89
102
  scope.add(() => dialog.remove());
90
103
  scope.attr(root, 'data-mk-ready', '');
@@ -128,6 +141,44 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
128
141
  prev.hidden = next.hidden = list.length < 2;
129
142
  };
130
143
 
144
+ let ringShown = false;
145
+ const buildStrip = () => {
146
+ const show = options.strip !== false && list.length > 1;
147
+ strip.hidden = !show;
148
+ dialog.toggleAttribute('data-mk-strip', show);
149
+ ringShown = false;
150
+ if (!show) return strip.replaceChildren();
151
+ strip.replaceChildren(
152
+ ring,
153
+ ...list.map((picture, i) => {
154
+ const thumb = document.createElement('button');
155
+ thumb.type = 'button';
156
+ thumb.dataset.mkLightboxThumb = String(i);
157
+ thumb.setAttribute('aria-label', `${labels.picture} ${i + 1} de ${list.length}`);
158
+ const small = thumbImg(picture);
159
+ const img = document.createElement('img');
160
+ img.src = (small instanceof HTMLImageElement ? small.getAttribute('src') || small.currentSrc : '') || picture.src;
161
+ img.alt = '';
162
+ img.draggable = false;
163
+ thumb.append(img);
164
+ return thumb;
165
+ }),
166
+ );
167
+ };
168
+ const paintStrip = () => {
169
+ if (strip.hidden) return;
170
+ const thumbs = [...strip.querySelectorAll<HTMLElement>('[data-mk-lightbox-thumb]')];
171
+ thumbs.forEach((thumb, i) => (i === index ? thumb.setAttribute('aria-current', 'true') : thumb.removeAttribute('aria-current')));
172
+ const active = thumbs[index];
173
+ if (!active) return;
174
+ const box = { x: active.offsetLeft, y: active.offsetTop, width: active.offsetWidth, height: active.offsetHeight };
175
+ if (!ringShown || reduced()) gsap.set(ring, box);
176
+ else keep(gsap.to(ring, { ...box, duration: 0.35, ease: EASE.swatch }));
177
+ ringShown = true;
178
+ // A long strip keeps the current thumbnail in its middle (the page behind never scrolls).
179
+ if (typeof strip.scrollTo === 'function') strip.scrollTo({ left: active.offsetLeft + active.offsetWidth / 2 - strip.clientWidth / 2, behavior: reduced() ? 'auto' : 'smooth' });
180
+ };
181
+
131
182
  /** Transform that puts the big image over the thumbnail. */
132
183
  const fromThumb = (picture: Picture) => {
133
184
  const small = thumbImg(picture)!.getBoundingClientRect();
@@ -158,16 +209,18 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
158
209
  isOpen = true;
159
210
  index = target;
160
211
  counter.textContent = '';
212
+ buildStrip();
161
213
  write(target);
162
214
  if (typeof dialog.showModal === 'function') dialog.showModal();
163
215
  else dialog.setAttribute('open', '');
216
+ paintStrip();
164
217
  close.focus({ preventScroll: true });
165
218
  const picture = list[target]!;
166
219
  options.onOpenChange?.(true);
167
220
  root.dispatchEvent(new CustomEvent('mk:open', { bubbles: true, detail: { index } }));
168
221
  if (reduced()) return;
169
222
  keep(gsap.fromTo(backdrop, { opacity: 0 }, { opacity: 1, duration: 0.4, ease: 'power2.out', clearProps: 'opacity' }));
170
- keep(gsap.fromTo([prev, next, close, counter, caption], { opacity: 0 }, { opacity: 1, duration: 0.3, delay: 0.3, clearProps: 'opacity' }));
223
+ keep(gsap.fromTo([prev, next, close, counter, caption, strip], { opacity: 0 }, { opacity: 1, duration: 0.3, delay: 0.3, clearProps: 'opacity' }));
171
224
  const grow = () => {
172
225
  const start = fromThumb(picture);
173
226
  if (!start) return void keep(gsap.fromTo(image, { opacity: 0, scale: 0.92 }, { opacity: 1, scale: 1, duration: 0.35, ease: EASE.pop, clearProps: 'opacity,scale,transform' }));
@@ -201,11 +254,11 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
201
254
  gsap.set(image, { scale: 1, x: 0, y: 0 });
202
255
  const end = fromThumb(picture);
203
256
  keep(gsap.to(backdrop, { opacity: 0, duration: 0.35, ease: 'power2.in' }));
204
- keep(gsap.to([prev, next, close, counter, caption], { opacity: 0, duration: 0.15 }));
257
+ keep(gsap.to([prev, next, close, counter, caption, strip], { opacity: 0, duration: 0.15 }));
205
258
  if (!end) return void keep(gsap.to(image, { opacity: 0, scale: 0.92, duration: 0.25, ease: 'power2.in', onComplete: finish }));
206
259
  thumbImg(picture)!.style.visibility = 'hidden';
207
260
  keep(gsap.to(image, { ...end, duration: 0.5, ease: EASE.signature, onComplete: () => {
208
- gsap.set([prev, next, close, counter, caption], { clearProps: 'opacity' });
261
+ gsap.set([prev, next, close, counter, caption, strip], { clearProps: 'opacity' });
209
262
  finish();
210
263
  } }));
211
264
  };
@@ -222,16 +275,43 @@ export function createImagePreview(root: HTMLElement | null, options: ImagePrevi
222
275
  index = target;
223
276
  options.onChange?.(index);
224
277
  root!.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { index, previous: before } }));
225
- if (reduced()) return write(target);
278
+ if (reduced()) {
279
+ write(target);
280
+ paintStrip();
281
+ return;
282
+ }
226
283
  gsap.killTweensOf(image);
227
- keep(
228
- gsap
229
- .timeline()
230
- .to(image, { x: -60 * direction, opacity: 0, filter: 'blur(12px)', duration: 0.18, ease: 'power2.in' })
231
- .call(() => write(target))
232
- .fromTo(image, { x: 60 * direction, opacity: 0, filter: 'blur(12px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.4, ease: EASE.signature, clearProps: 'transform,opacity,filter' }),
233
- );
284
+ ghost?.remove();
285
+ // The picture that leaves is a copy over the old one (where a swipe left it), so the new one can come in at once.
286
+ const leaving = image.cloneNode() as HTMLImageElement;
287
+ leaving.dataset.mkLightbox = 'ghost';
288
+ leaving.alt = '';
289
+ leaving.setAttribute('aria-hidden', 'true');
290
+ Object.assign(leaving.style, { position: 'absolute', left: `${image.offsetLeft}px`, top: `${image.offsetTop}px`, width: `${image.offsetWidth}px`, height: `${image.offsetHeight}px`, margin: '0' });
291
+ stage.append(leaving);
292
+ ghost = leaving;
293
+ write(target);
294
+ paintStrip();
295
+ gsap.set(image, { opacity: 0, x: 0, y: 0 });
296
+ keep(gsap.to(leaving, { xPercent: -28 * direction, scale: 0.86, opacity: 0, filter: 'blur(10px)', duration: 0.45, ease: EASE.signature, onComplete: () => {
297
+ leaving.remove();
298
+ if (ghost === leaving) ghost = null;
299
+ } }));
300
+ const enter = () => {
301
+ if (!isOpen || index !== target) return;
302
+ keep(gsap.fromTo(image, { xPercent: 24 * direction, scale: 1.04, opacity: 0, filter: 'blur(10px)' }, { xPercent: 0, scale: 1, opacity: 1, filter: 'blur(0px)', duration: 0.5, ease: EASE.signature, clearProps: 'transform,opacity,filter' }));
303
+ };
304
+ // The new picture enters decoded (never blank), waiting at most 250 ms for it.
305
+ const decoded = typeof image.decode === 'function' ? image.decode().catch(() => undefined) : Promise.resolve();
306
+ if (image.complete && image.naturalWidth) enter();
307
+ else void Promise.race([decoded, new Promise((done) => setTimeout(done, 250))]).then(enter);
234
308
  }
309
+ let ghost: HTMLImageElement | null = null;
310
+ scope.add(() => ghost?.remove());
311
+ scope.on(strip, 'click', (event) => {
312
+ const thumb = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-lightbox-thumb]');
313
+ if (thumb) go(Number(thumb.dataset.mkLightboxThumb));
314
+ });
235
315
 
236
316
  scope.on(root, 'click', (event) => {
237
317
  const thumb = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-image]');
@@ -1,5 +1,6 @@
1
1
  /* image-preview — thumbnails in Melonmind cards (16 px radius) and a full-screen dialog: a blurred dark backdrop, the
2
- picture at 16 px radius, round glass buttons and a counter pill. */
2
+ picture at 16 px radius, round glass buttons, a counter pill and a glass strip of 8 px thumbnails with a ring that
3
+ glides to the current one. */
3
4
  .mk-image-preview,
4
5
  mk-image-preview {
5
6
  display: grid;
@@ -88,6 +89,83 @@ mk-image-preview {
88
89
  user-select: none;
89
90
  }
90
91
 
92
+ /* The picture that leaves while the next one comes in (the engine places it over the old one). */
93
+ [data-mk-lightbox='ghost'] {
94
+ border-radius: 16px;
95
+ object-fit: contain;
96
+ pointer-events: none;
97
+ }
98
+
99
+ [data-mk-lightbox-dialog][data-mk-strip] [data-mk-lightbox='image'] {
100
+ max-height: min(78dvh, calc(100dvh - 11rem));
101
+ }
102
+
103
+ [data-mk-lightbox='strip'] {
104
+ position: absolute;
105
+ bottom: 1rem;
106
+ left: 50%;
107
+ display: flex;
108
+ gap: 0.375rem;
109
+ max-width: calc(100vw - 2rem);
110
+ padding: 0.375rem;
111
+ overflow-x: auto;
112
+ border-radius: 14px;
113
+ background: rgb(255 255 255 / 0.08);
114
+ box-shadow:
115
+ inset 1px 1px 1px rgb(255 255 255 / 0.12),
116
+ inset -1px -1px 1px rgb(255 255 255 / 0.05);
117
+ backdrop-filter: blur(16px);
118
+ scrollbar-width: none;
119
+ translate: -50% 0;
120
+ }
121
+
122
+ [data-mk-lightbox='strip'][hidden] {
123
+ display: none;
124
+ }
125
+
126
+ [data-mk-lightbox-thumb] {
127
+ position: relative;
128
+ z-index: 1;
129
+ flex: none;
130
+ width: 2.75rem;
131
+ height: 2.75rem;
132
+ padding: 0;
133
+ overflow: hidden;
134
+ border: 0;
135
+ border-radius: 8px;
136
+ background: rgb(255 255 255 / 0.06);
137
+ opacity: 0.5;
138
+ cursor: pointer;
139
+ transition: opacity 0.2s ease;
140
+ }
141
+
142
+ [data-mk-lightbox-thumb]:hover,
143
+ [data-mk-lightbox-thumb][aria-current='true'] {
144
+ opacity: 1;
145
+ }
146
+
147
+ [data-mk-lightbox-thumb]:focus-visible {
148
+ outline: 2px solid #fff;
149
+ outline-offset: 2px;
150
+ }
151
+
152
+ [data-mk-lightbox-thumb] img {
153
+ display: block;
154
+ width: 100%;
155
+ height: 100%;
156
+ object-fit: cover;
157
+ }
158
+
159
+ [data-mk-lightbox-ring] {
160
+ position: absolute;
161
+ top: 0;
162
+ left: 0;
163
+ z-index: 2;
164
+ border-radius: 9px;
165
+ box-shadow: 0 0 0 2px #fff;
166
+ pointer-events: none;
167
+ }
168
+
91
169
  [data-mk-lightbox-dialog][data-mk-zoomed] [data-mk-lightbox='image'] {
92
170
  cursor: move;
93
171
  }
@@ -177,6 +255,15 @@ mk-image-preview {
177
255
  }
178
256
 
179
257
  @media (max-width: 680px) {
258
+ /* Phones swipe; the arrows take the bottom. */
259
+ [data-mk-lightbox='strip'] {
260
+ display: none;
261
+ }
262
+
263
+ [data-mk-lightbox-dialog][data-mk-strip] [data-mk-lightbox='image'] {
264
+ max-height: 78dvh;
265
+ }
266
+
180
267
  [data-mk-lightbox='prev'],
181
268
  [data-mk-lightbox='next'] {
182
269
  top: auto;
@@ -187,7 +274,8 @@ mk-image-preview {
187
274
 
188
275
  @media (prefers-reduced-motion: reduce) {
189
276
  :is(.mk-image-preview, mk-image-preview) [data-mk-image],
190
- [data-mk-lightbox-dialog] button[data-mk-lightbox] {
277
+ [data-mk-lightbox-dialog] button[data-mk-lightbox],
278
+ [data-mk-lightbox-thumb] {
191
279
  transition: none;
192
280
  scale: none !important;
193
281
  }
@@ -17,20 +17,20 @@ export interface ImagePreviewProps extends Omit<HTMLAttributes<HTMLDivElement>,
17
17
  }
18
18
 
19
19
  /** <ImagePreview images={[{ src, thumb, alt, caption }]} />: a grid of thumbnails that open a viewer. */
20
- export function ImagePreview({ images, labels, loop, onChange, onOpenChange, instanceRef, className, ...props }: ImagePreviewProps) {
20
+ export function ImagePreview({ images, labels, loop, strip, onChange, onOpenChange, instanceRef, className, ...props }: ImagePreviewProps) {
21
21
  const ref = useRef<HTMLDivElement>(null);
22
22
  const change = useLatest(onChange);
23
23
  const openChange = useLatest(onOpenChange);
24
24
  const key = JSON.stringify(labels ?? {});
25
25
  useIsomorphicLayoutEffect(() => {
26
- const created = createImagePreview(ref.current, { labels, loop, onChange: (i) => change.current?.(i), onOpenChange: (open) => openChange.current?.(open) });
26
+ const created = createImagePreview(ref.current, { labels, loop, strip, onChange: (i) => change.current?.(i), onOpenChange: (open) => openChange.current?.(open) });
27
27
  if (instanceRef) instanceRef.current = created;
28
28
  return () => {
29
29
  created.destroy();
30
30
  if (instanceRef) instanceRef.current = null;
31
31
  };
32
32
  // eslint-disable-next-line react-hooks/exhaustive-deps
33
- }, [key, loop]);
33
+ }, [key, loop, strip]);
34
34
  return (
35
35
  <div {...props} ref={ref} className={['mk-image-preview', className].filter(Boolean).join(' ')}>
36
36
  {images.map((image, i) => (
@@ -3,7 +3,7 @@ import { reduced } from '../../core/reduced';
3
3
  import { createScope } from '../../core/scope';
4
4
  import { inert, type Instance } from '../../core/types';
5
5
  import { walk, type TreeNode } from '../../core/tree';
6
- import { press } from '../../motion/micro';
6
+ import { blurIn, press } from '../../motion/micro';
7
7
  import { markText } from '../../core/mark';
8
8
  import { createTreeView } from '../tree-view/engine';
9
9
 
@@ -16,7 +16,10 @@ import { createTreeView } from '../tree-view/engine';
16
16
  * pop; Ctrl/⌘+C copies the focused value. The search marks keys and values and
17
17
  * opens what holds them. setData() keeps what was open and flashes the values
18
18
  * that changed (and the keys that are new) in the accent for a moment. The
19
- * selected row's path ($.user.roles[0]) shows below, ready to copy.
19
+ * selected row's path ($.user.roles[0]) shows below, ready to copy: it rises
20
+ * out of a blur the first time and slides in on each new choice. On hover the
21
+ * copy button slides in and the value sits on a faint pill of its colour; an
22
+ * open branch lets its summary ("4 claves") step back.
20
23
  */
21
24
 
22
25
  export type JsonType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
@@ -336,8 +339,14 @@ export function createJsonViewer(root: HTMLElement | null, options: JsonViewerOp
336
339
  },
337
340
  onSelect: (node) => {
338
341
  const meta = node.data as JsonMeta;
342
+ const appearing = foot.hidden;
343
+ const moved = pathText.textContent !== meta.path;
339
344
  foot.hidden = false;
340
345
  pathText.textContent = meta.path;
346
+ if (!reduced()) {
347
+ if (appearing) keep(blurIn(foot, { from: 'rise', duration: 0.32 }));
348
+ else if (moved) keep(blurIn(pathText, { from: 'soft', duration: 0.28 }));
349
+ }
341
350
  options.onSelect?.(meta.path, meta.value);
342
351
  root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { path: meta.path, value: meta.value } }));
343
352
  },
@@ -79,7 +79,8 @@ mk-json-viewer {
79
79
  transition:
80
80
  background-color 0.15s,
81
81
  color 0.15s,
82
- opacity 0.15s;
82
+ opacity 0.15s,
83
+ translate 0.2s ease;
83
84
  }
84
85
 
85
86
  [data-mk-jv-tool]:hover,
@@ -189,6 +190,27 @@ mk-json-viewer {
189
190
  color: var(--mk-muted);
190
191
  font-size: 0.75rem;
191
192
  text-overflow: ellipsis;
193
+ transition:
194
+ opacity 0.25s ease,
195
+ translate 0.3s cubic-bezier(0.38, 0.49, 0, 1.5);
196
+ }
197
+
198
+ /* An open branch shows its content: the count steps back. */
199
+ [role='treeitem'][aria-expanded='true'] > [data-mk-tree-row] [data-mk-jv-summary] {
200
+ opacity: 0.45;
201
+ translate: -3px 0;
202
+ }
203
+
204
+ /* Hovered values sit on a faint pill of their own colour. */
205
+ [data-mk-jv-value] {
206
+ margin: 0 -0.2rem;
207
+ padding: 0 0.2rem;
208
+ border-radius: 4px;
209
+ transition: background-color 0.2s ease;
210
+ }
211
+
212
+ [data-mk-tree-row]:hover [data-mk-jv-value] {
213
+ background: color-mix(in srgb, currentColor 12%, transparent);
192
214
  }
193
215
 
194
216
  [data-mk-jv-copy] {
@@ -197,11 +219,14 @@ mk-json-viewer {
197
219
  margin-left: auto;
198
220
  border-radius: 6px;
199
221
  opacity: 0;
222
+ translate: 4px 0;
200
223
  }
201
224
 
225
+ /* The copy button slides in on hover (or focus). */
202
226
  [data-mk-tree-row]:hover [data-mk-jv-copy],
203
227
  [role='treeitem']:focus-visible > [data-mk-tree-row] [data-mk-jv-copy] {
204
228
  opacity: 1;
229
+ translate: 0 0;
205
230
  }
206
231
 
207
232
  @media (hover: none) {
@@ -268,4 +293,11 @@ mk-json-viewer {
268
293
  [data-mk-jv-added] {
269
294
  animation: none;
270
295
  }
296
+
297
+ [data-mk-jv-copy],
298
+ [data-mk-jv-summary],
299
+ [data-mk-jv-value] {
300
+ transition: none;
301
+ translate: none;
302
+ }
271
303
  }
@@ -4,13 +4,15 @@ Navegación con siete estilos de escritorio y una sola barra de cristal en el m
4
4
 
5
5
  | Estilo | Cómo se ve |
6
6
  |---|---|
7
- | `clasica` | Barra lateral con borde; la página actual en una píldora rellena |
8
- | `bonita` | Píldoras de texto centradas que se ensanchan al pasar y al estar activas (padding con resorte, 0.3 s `cubic(.38,.49,0,1.5)`) |
7
+ | `sidebar` | Barra lateral con borde; la página actual en una píldora rellena |
8
+ | `pills` | Píldoras de texto centradas que se ensanchan al pasar y al estar activas (padding con resorte, 0.3 s `cubic(.38,.49,0,1.5)`) |
9
9
  | `dock` | Barra flotante de cristal centrada abajo; el botón activo se ensancha a 80 px |
10
- | `guapa` | La vista es una tarjeta sobre la cáscara tintada; la página actual es un chip blanco |
11
- | `tasks` | Riel de botones redondos de 56 px; el activo se cuadra (radio 0.3 s `cubic(.38,.49,0,2)`) |
12
- | `melon` | Barra lateral suave con un lavado primario en la página actual |
13
- | `tunnel` | Palabras grandes; la que tiene el ratón y sus vecinas crecen como un dock (1.2 / 1.09 / 1.03) |
10
+ | `card` | La vista es una tarjeta sobre la cáscara tintada; la página actual es un chip blanco |
11
+ | `rail` | Riel de botones redondos de 56 px; el activo se cuadra (radio 0.3 s `cubic(.38,.49,0,2)`) |
12
+ | `wash` | Barra lateral suave con un lavado primario en la página actual |
13
+ | `magnify` | Palabras grandes; la que tiene el ratón y sus vecinas crecen como un dock (1.2 / 1.09 / 1.03) |
14
+
15
+ Los nombres anteriores a la 0.15 (`clasica`, `bonita`, `guapa`, `tasks`, `melon`, `tunnel`) siguen funcionando: el motor los convierte en `sidebar`, `pills`, `card`, `rail`, `wash` y `magnify` (`NAV_STYLE_ALIASES`, `toNavStyle()`); en el marcado escrito usa ya la clase nueva para que se vea bien antes del JS.
14
16
 
15
17
  `accent` pinta la página actual en primary-container. Al cambiar de página el icono se rellena (`mk-nav-icon`, 125 ms, `FILL 0 → 1`, para fuentes de iconos como Material Symbols). Con `reveal`, al arrancar la cáscara se descubre desde el primer botón (`revealFrom`).
16
18
 
@@ -22,7 +24,7 @@ Navegación con siete estilos de escritorio y una sola barra de cristal en el m
22
24
  <header class="mk-shell__header"><h1 class="mk-shell__title">Inicio</h1></header>
23
25
  …
24
26
  </main>
25
- <mk-nav class="mk-nav mk-nav--bonita" nav-style="bonita" storage-key="mi-app-nav">
27
+ <mk-nav class="mk-nav mk-nav--pills" nav-style="pills" storage-key="mi-app-nav">
26
28
  <div class="mk-nav__buttons">
27
29
  <a class="mk-nav__button" href="/" aria-current="page"><svg class="mk-nav__icon">…</svg><span class="mk-nav__name">Inicio</span></a>
28
30
  <a class="mk-nav__button" href="/movimientos"><svg class="mk-nav__icon">…</svg><span class="mk-nav__name">Movimientos</span></a>
@@ -42,7 +44,7 @@ Navegación con siete estilos de escritorio y una sola barra de cristal en el m
42
44
 
43
45
  Escribe la clase `mk-nav--<estilo>` en el marcado para que se vea bien antes de que cargue el JS. Sin custom element: `createNav(document.querySelector('.mk-nav'), opciones)` sobre un `<nav>`.
44
46
 
45
- Partes: `.mk-nav__button--first` (botón de arriba: cuenta, espacio de trabajo), `.mk-nav__buttons`, `.mk-nav__actions` (`.mk-nav__button--action`), `.mk-nav__options` + `.mk-nav__label` (bloque extra que solo muestran clásica, guapa, melon y tunnel), `.mk-nav__icon`, `.mk-nav__name`, `data-mk-hide-mobile` (se esconde en el móvil). Vista: `.mk-shell__view`, `.mk-shell__header`, `.mk-shell__title`, `.mk-shell__content` (cada estilo ajusta su cabecera).
47
+ Partes: `.mk-nav__button--first` (botón de arriba: cuenta, espacio de trabajo), `.mk-nav__buttons`, `.mk-nav__actions` (`.mk-nav__button--action`), `.mk-nav__options` + `.mk-nav__label` (bloque extra que solo muestran sidebar, card, wash y magnify), `.mk-nav__icon`, `.mk-nav__name`, `data-mk-hide-mobile` (se esconde en el móvil). Vista: `.mk-shell__view`, `.mk-shell__header`, `.mk-shell__title`, `.mk-shell__content` (cada estilo ajusta su cabecera).
46
48
 
47
49
  ## React
48
50
 
@@ -52,7 +54,7 @@ const [page, setPage] = useState('home');
52
54
  <div className="mk-shell" style={{ height: '100dvh' }}>
53
55
  <main className="mk-shell__view">…</main>
54
56
  <Nav
55
- navStyle="tasks"
57
+ navStyle="rail"
56
58
  items={[
57
59
  { value: 'home', label: 'Inicio', icon: <HomeIcon /> },
58
60
  { value: 'movements', label: 'Movimientos', icon: <RowsIcon /> },
@@ -76,7 +78,7 @@ const [page, setPage] = useState('home');
76
78
 
77
79
  ## Variables CSS
78
80
 
79
- `--mk-nav-icon-size` (24px; 16px en guapa y melon con acento). Colores de los roles Material (`surface-container-*`, `primary-container`, `tertiary-container`…).
81
+ `--mk-nav-icon-size` (24px; 16px en card y wash con acento). Colores de los roles Material (`surface-container-*`, `primary-container`, `tertiary-container`…).
80
82
 
81
83
  ## Accesibilidad
82
84
 
@@ -1,8 +1,8 @@
1
1
  import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
- import { createNav, type NavInstance, type NavStyle } from './engine';
2
+ import { createNav, type NavInstance, type NavStyle, type NavStyleAlias } from './engine';
3
3
 
4
4
  /**
5
- * <mk-nav class="mk-nav mk-nav--bonita" nav-style="bonita" [accent] [reveal] [storage-key="mi-app-nav"]>
5
+ * <mk-nav class="mk-nav mk-nav--pills" nav-style="pills" [accent] [reveal] [storage-key="mi-app-nav"]>
6
6
  * <div class="mk-nav__buttons"><a class="mk-nav__button" href="/">…</a></div>
7
7
  * </mk-nav>
8
8
  * Emits `mk:navigate` (detail: index, value, item, originalEvent) on click.
@@ -12,7 +12,7 @@ export class MkNavElement extends MkElement<NavInstance> {
12
12
 
13
13
  protected create(options: Record<string, unknown>) {
14
14
  return createNav(this, {
15
- style: options.navStyle as NavStyle | undefined,
15
+ style: options.navStyle as NavStyle | NavStyleAlias | undefined,
16
16
  accent: options.accent as boolean | undefined,
17
17
  reveal: Boolean(options.reveal),
18
18
  storageKey: options.storageKey as string | undefined,
@@ -22,7 +22,7 @@ export class MkNavElement extends MkElement<NavInstance> {
22
22
  // Style and accent change in place: rebuilding would replay the reveal.
23
23
  attributeChangedCallback(name: string, previous: string | null, next: string | null) {
24
24
  if (previous === next || !this.instance) return;
25
- if (name === 'nav-style' && next) this.instance.setStyle(next as NavStyle);
25
+ if (name === 'nav-style' && next) this.instance.setStyle(next as NavStyle | NavStyleAlias);
26
26
  else if (name === 'accent') this.instance.setAccent(next !== null && next !== 'false');
27
27
  else super.attributeChangedCallback(name, previous, next);
28
28
  }
@@ -31,7 +31,7 @@ export class MkNavElement extends MkElement<NavInstance> {
31
31
  this.instance?.select(target);
32
32
  }
33
33
 
34
- setStyle(style: NavStyle) {
34
+ setStyle(style: NavStyle | NavStyleAlias) {
35
35
  this.instance?.setStyle(style);
36
36
  }
37
37
  }