@yewdiel21/motion-kit 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +44 -0
  2. package/dist/auto/index.d.ts +23 -3
  3. package/dist/auto/index.js +43 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
  6. package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
  7. package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
  8. package/dist/chunk-Q7IW4FV3.js.map +1 -0
  9. package/dist/css/components/badge-button.css +16 -0
  10. package/dist/css/components/bento-grid.css +13 -0
  11. package/dist/css/components/drilldown-menu.css +134 -0
  12. package/dist/css/components/equalizer-slider.css +26 -0
  13. package/dist/css/components/handwriting-text.css +15 -0
  14. package/dist/css/components/magnetize-button.css +18 -0
  15. package/dist/css/components/morphing-page-dots.css +13 -0
  16. package/dist/css/components/not-found.css +22 -0
  17. package/dist/css/components/notification-stack.css +42 -0
  18. package/dist/css/components/particle-button.css +14 -0
  19. package/dist/css/components/radio-group.css +29 -0
  20. package/dist/css/components/rating-scale.css +23 -0
  21. package/dist/css/components/scroll-preview-dock.css +18 -0
  22. package/dist/css/components/select.css +16 -0
  23. package/dist/css/components/signature-pad.css +131 -0
  24. package/dist/css/components/stepper.css +36 -0
  25. package/dist/css/components/sticky-header.css +10 -0
  26. package/dist/css/components/temperature-slider.css +29 -0
  27. package/dist/css/components/voice-message-bubble.css +67 -0
  28. package/dist/css/components/waveform-player.css +51 -0
  29. package/dist/css/components/wheel-picker.css +18 -0
  30. package/dist/css/tokens.css +4 -0
  31. package/dist/elements/index.d.ts +292 -4
  32. package/dist/elements/index.js +856 -4
  33. package/dist/elements/index.js.map +1 -1
  34. package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
  35. package/dist/index.css +713 -0
  36. package/dist/index.d.ts +23 -23
  37. package/dist/index.js +43 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/react/index.d.ts +151 -7
  40. package/dist/react/index.js +893 -3
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
  43. package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
  44. package/package.json +110 -110
  45. package/registry.json +244 -0
  46. package/src/auto/auto-init.ts +339 -293
  47. package/src/components/badge-button/README.md +28 -0
  48. package/src/components/badge-button/badge-button.css +15 -0
  49. package/src/components/badge-button/element.ts +16 -0
  50. package/src/components/badge-button/engine.ts +85 -0
  51. package/src/components/badge-button/react.tsx +31 -0
  52. package/src/components/bento-grid/README.md +26 -0
  53. package/src/components/bento-grid/bento-grid.css +12 -0
  54. package/src/components/bento-grid/element.ts +22 -0
  55. package/src/components/bento-grid/engine.ts +153 -0
  56. package/src/components/bento-grid/react.tsx +25 -0
  57. package/src/components/drilldown-menu/README.md +163 -0
  58. package/src/components/drilldown-menu/drilldown-menu.css +133 -0
  59. package/src/components/drilldown-menu/element.ts +59 -0
  60. package/src/components/drilldown-menu/engine.ts +378 -0
  61. package/src/components/drilldown-menu/react.tsx +76 -0
  62. package/src/components/equalizer-slider/README.md +43 -0
  63. package/src/components/equalizer-slider/element.ts +41 -0
  64. package/src/components/equalizer-slider/engine.ts +32 -0
  65. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  66. package/src/components/equalizer-slider/react.tsx +60 -0
  67. package/src/components/equalizer-slider/shared.ts +161 -0
  68. package/src/components/handwriting-text/README.md +29 -0
  69. package/src/components/handwriting-text/element.ts +14 -0
  70. package/src/components/handwriting-text/engine.ts +108 -0
  71. package/src/components/handwriting-text/glyphs.ts +71 -0
  72. package/src/components/handwriting-text/handwriting-text.css +14 -0
  73. package/src/components/handwriting-text/react.tsx +23 -0
  74. package/src/components/magnetize-button/README.md +26 -0
  75. package/src/components/magnetize-button/element.ts +13 -0
  76. package/src/components/magnetize-button/engine.ts +84 -0
  77. package/src/components/magnetize-button/magnetize-button.css +17 -0
  78. package/src/components/magnetize-button/react.tsx +22 -0
  79. package/src/components/morphing-page-dots/README.md +28 -0
  80. package/src/components/morphing-page-dots/element.ts +16 -0
  81. package/src/components/morphing-page-dots/engine.ts +89 -0
  82. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  83. package/src/components/morphing-page-dots/react.tsx +32 -0
  84. package/src/components/not-found/README.md +26 -0
  85. package/src/components/not-found/element.ts +15 -0
  86. package/src/components/not-found/engine.ts +57 -0
  87. package/src/components/not-found/not-found.css +21 -0
  88. package/src/components/not-found/react.tsx +23 -0
  89. package/src/components/notification-stack/README.md +24 -0
  90. package/src/components/notification-stack/element.ts +26 -0
  91. package/src/components/notification-stack/engine.ts +329 -0
  92. package/src/components/notification-stack/notification-stack.css +41 -0
  93. package/src/components/notification-stack/react.tsx +28 -0
  94. package/src/components/particle-button/README.md +26 -0
  95. package/src/components/particle-button/element.ts +13 -0
  96. package/src/components/particle-button/engine.ts +52 -0
  97. package/src/components/particle-button/particle-button.css +13 -0
  98. package/src/components/particle-button/react.tsx +22 -0
  99. package/src/components/radio-group/README.md +43 -0
  100. package/src/components/radio-group/element.ts +41 -0
  101. package/src/components/radio-group/engine.ts +11 -0
  102. package/src/components/radio-group/radio-group.css +28 -0
  103. package/src/components/radio-group/react.tsx +60 -0
  104. package/src/components/radio-group/shared.ts +118 -0
  105. package/src/components/rating-scale/README.md +43 -0
  106. package/src/components/rating-scale/element.ts +41 -0
  107. package/src/components/rating-scale/engine.ts +63 -0
  108. package/src/components/rating-scale/rating-scale.css +22 -0
  109. package/src/components/rating-scale/react.tsx +60 -0
  110. package/src/components/scroll-preview-dock/README.md +24 -0
  111. package/src/components/scroll-preview-dock/element.ts +20 -0
  112. package/src/components/scroll-preview-dock/engine.ts +228 -0
  113. package/src/components/scroll-preview-dock/react.tsx +26 -0
  114. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  115. package/src/components/select/README.md +23 -9
  116. package/src/components/select/select.css +16 -0
  117. package/src/components/signature-pad/README.md +97 -0
  118. package/src/components/signature-pad/element.ts +57 -0
  119. package/src/components/signature-pad/engine.ts +550 -0
  120. package/src/components/signature-pad/react.tsx +56 -0
  121. package/src/components/signature-pad/signature-pad.css +130 -0
  122. package/src/components/stepper/README.md +46 -0
  123. package/src/components/stepper/element.ts +42 -0
  124. package/src/components/stepper/engine.ts +103 -0
  125. package/src/components/stepper/react.tsx +60 -0
  126. package/src/components/stepper/stepper.css +35 -0
  127. package/src/components/sticky-header/README.md +30 -0
  128. package/src/components/sticky-header/element.ts +19 -0
  129. package/src/components/sticky-header/engine.ts +95 -0
  130. package/src/components/sticky-header/react.tsx +25 -0
  131. package/src/components/sticky-header/sticky-header.css +9 -0
  132. package/src/components/temperature-slider/README.md +42 -0
  133. package/src/components/temperature-slider/element.ts +53 -0
  134. package/src/components/temperature-slider/engine.ts +44 -0
  135. package/src/components/temperature-slider/react.tsx +66 -0
  136. package/src/components/temperature-slider/temperature-slider.css +29 -0
  137. package/src/components/voice-message-bubble/README.md +31 -0
  138. package/src/components/voice-message-bubble/element.ts +20 -0
  139. package/src/components/voice-message-bubble/engine.ts +79 -0
  140. package/src/components/voice-message-bubble/react.tsx +27 -0
  141. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
  142. package/src/components/waveform-player/README.md +30 -0
  143. package/src/components/waveform-player/element.ts +21 -0
  144. package/src/components/waveform-player/engine.ts +344 -0
  145. package/src/components/waveform-player/react.tsx +27 -0
  146. package/src/components/waveform-player/waveform-player.css +50 -0
  147. package/src/components/wheel-picker/README.md +35 -0
  148. package/src/components/wheel-picker/element.ts +25 -0
  149. package/src/components/wheel-picker/engine.ts +239 -0
  150. package/src/components/wheel-picker/react.tsx +32 -0
  151. package/src/components/wheel-picker/wheel-picker.css +17 -0
  152. package/src/css/tokens.css +4 -0
  153. package/src/elements/index.ts +318 -258
  154. package/src/index.ts +21 -0
  155. package/src/react/index.ts +192 -171
  156. package/dist/chunk-SDQ776LG.js.map +0 -1
  157. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
  158. /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
@@ -0,0 +1,130 @@
1
+ /* Normalized SVG ink survives layout changes. Only the drawing area captures touch. */
2
+ .mk-signature-pad,
3
+ mk-signature-pad {
4
+ --mk-signature-height: clamp(12rem, 32vw, 17rem);
5
+ display: block;
6
+ min-width: 0;
7
+ overflow: hidden;
8
+ border: 1px solid var(--mk-outline-soft);
9
+ border-radius: 24px;
10
+ background: var(--mk-surface-1);
11
+ color: var(--mk-text);
12
+ font-family: var(--mk-font);
13
+ }
14
+ [data-mk-signature-header] { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .5rem; padding: 1rem 1.25rem; font-size: .8125rem; font-weight: 500; }
15
+ [data-mk-signature-header] [data-mk-signature-status] { display: inline-flex; gap: .4rem; align-items: center; }
16
+ [data-mk-signature-status]::before { content: ''; width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--mk-muted); transition: background .2s; }
17
+ :is(.mk-signature-pad, mk-signature-pad):not([data-mk-empty]) [data-mk-signature-status]::before { background: var(--mk-accent); animation: mk-signature-dot .45s var(--mk-ease-swatch); }
18
+ @keyframes mk-signature-dot { from { scale: .2; } }
19
+
20
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-surface] {
21
+ position: relative;
22
+ height: var(--mk-signature-height);
23
+ min-height: 8rem;
24
+ overflow: hidden;
25
+ background: var(--mk-signature-paper, var(--mk-surface-2));
26
+ margin-inline: 8px;
27
+ border-radius: var(--mk-radius-md);
28
+ cursor: crosshair;
29
+ touch-action: none;
30
+ user-select: none;
31
+ -webkit-user-select: none;
32
+ }
33
+ [data-mk-signature-cursor] { position: absolute; top: 0; left: 0; width: 16px; height: 16px; border: 1px solid currentColor; border-radius: 50%; opacity: 0; transform: translate(-50%, -50%) scale(.5); pointer-events: none; transition: opacity .12s, width .3s var(--mk-ease-click), height .3s var(--mk-ease-click), transform .3s var(--mk-ease-click); }
34
+ [data-mk-pen-visible]:not([data-mk-disabled]):not([data-mk-readonly]) [data-mk-signature-cursor] { opacity: .45; transform: translate(-50%, -50%) scale(1); }
35
+ [data-mk-drawing] [data-mk-signature-cursor] { width: 8px; height: 8px; background: currentColor; }
36
+
37
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-surface]::after {
38
+ position: absolute;
39
+ right: 1.5rem;
40
+ bottom: 2rem;
41
+ left: 1.5rem;
42
+ border-bottom: 1px dashed var(--mk-outline-soft);
43
+ content: '';
44
+ pointer-events: none;
45
+ transition: border-color .25s;
46
+ }
47
+ /* The line you sign on wakes while the pen is down. */
48
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-drawing] [data-mk-signature-surface]::after { border-bottom-color: color-mix(in srgb, var(--mk-text) 40%, transparent); }
49
+
50
+ :is(.mk-signature-pad, mk-signature-pad) :is([data-mk-signature-drawing], [data-mk-signature-ghost]) {
51
+ position: absolute;
52
+ inset: 0;
53
+ display: block;
54
+ width: 100%;
55
+ height: 100%;
56
+ overflow: hidden;
57
+ }
58
+
59
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-placeholder] {
60
+ position: absolute;
61
+ inset: 0;
62
+ display: grid;
63
+ place-items: center;
64
+ color: var(--mk-muted);
65
+ font-size: 1.5rem;
66
+ font-style: italic;
67
+ font-weight: 400;
68
+ pointer-events: none;
69
+ /* Returning waits for leaving ink, so the prompt never sits on top of it. */
70
+ transition: opacity .3s ease .3s, filter .3s ease .3s, scale .45s var(--mk-ease-swatch) .3s;
71
+ }
72
+
73
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-placeholder][hidden] { display: grid; opacity: 0; filter: blur(6px); scale: .96; transition-duration: .16s; transition-delay: 0s; }
74
+
75
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-footer] {
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ gap: 0.75rem;
81
+ padding: 0.875rem 1rem 0;
82
+ }
83
+
84
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-toolbar] {
85
+ display: flex;
86
+ flex-wrap: wrap;
87
+ gap: 0.375rem;
88
+ }
89
+
90
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action] {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ gap: .4rem;
95
+ min-height: 2.75rem;
96
+ padding: 0.5rem 0.875rem;
97
+ border: 0;
98
+ border-radius: var(--mk-radius-sm);
99
+ background: var(--mk-surface-3);
100
+ color: var(--mk-text);
101
+ font: inherit;
102
+ font-size: 0.8125rem;
103
+ font-weight: 500;
104
+ cursor: pointer;
105
+ transition: background .15s, border-radius .3s var(--mk-ease-playful), opacity .2s;
106
+ }
107
+ [data-mk-signature-action] svg { width: 17px; height: 17px; }
108
+
109
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:hover:enabled { background: var(--mk-surface-4); border-radius: var(--mk-radius-full); }
110
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 2px; }
111
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:disabled { opacity: 0.4; cursor: default; }
112
+
113
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-status],
114
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-hint] {
115
+ color: var(--mk-muted);
116
+ font-size: 0.75rem;
117
+ line-height: 1.5;
118
+ }
119
+
120
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-status] { font-variant-numeric: tabular-nums; }
121
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-hint] { margin: 0; padding: 0.75rem 1rem 1rem; }
122
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-readonly] [data-mk-signature-surface],
123
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-disabled] [data-mk-signature-surface] { cursor: default; touch-action: auto; }
124
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-disabled] [data-mk-signature-surface] { opacity: 0.55; }
125
+ @media (prefers-reduced-motion: reduce) {
126
+ [data-mk-signature-cursor], [data-mk-signature-action], [data-mk-signature-placeholder], [data-mk-signature-surface]::after { transition: none !important; }
127
+ [data-mk-signature-status]::before { animation: none !important; }
128
+ }
129
+
130
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-ghost] { pointer-events: none; }
@@ -0,0 +1,46 @@
1
+ # Stepper
2
+
3
+ Navegación de progreso para workflows, con marcadores horizontales, verticales, compactos o puntos. El paso actual cambia su radio y la marca entra con pop; las filas llegan con blur escalonado. El contenido de cada paso pertenece a la aplicación, distinto del motor de paneles steps.
4
+
5
+ ```ts
6
+ import { createStepper } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/stepper';
8
+
9
+ const control = createStepper(document.querySelector('.mk-stepper'), {
10
+ items: [{label: 'Cuenta'}, {label: 'Perfil'}, {label: 'Listo'}],
11
+ value: 0, label: 'Alta', onChange: renderCurrentStep,
12
+ });
13
+ control.next();
14
+ control.previous();
15
+ control.setValue(2);
16
+ ```
17
+
18
+ ## Opciones y contrato
19
+
20
+ `items: { label, description?, disabled?, completed?, error? }[]`, `variant: horizontal | vertical | compact | dots` (horizontal), `value` / `defaultValue` (índice desde 0), `linear`, `disabled`, `label`, `name`, `onChange(index)`.
21
+
22
+ Los botones tienen type=button y solo uno lleva aria-current=step. linear permite entrar al siguiente paso habilitado y volver a los visitados. next/previous omiten pasos disabled. setValue permite a la app navegar de forma explícita; las opciones disabled bloquean los gestos del usuario, no las actualizaciones imperativas. name añade un input oculto con el índice. La lista vacía indica que no hay pasos.
23
+
24
+ La instancia expone `value: number`, `setValue(value, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:change` con `detail: { value }`.
25
+
26
+ ## React y HTML
27
+
28
+ ```tsx
29
+ import { Stepper } from '@yewdiel21/motion-kit/react';
30
+
31
+ <Stepper items={steps} value={step} onChange={setStep} variant="compact" />
32
+ <section>{panels[step]}</section>
33
+ ```
34
+
35
+ `value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
36
+
37
+ ```html
38
+ <mk-stepper class="mk-stepper" items='[{"label":"Cuenta"},{"label":"Perfil"},{"label":"Listo"}]' value="0" variant="horizontal"></mk-stepper>
39
+ ```
40
+
41
+ Registra con `defineStepper()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`, `next` y `previous`.
42
+
43
+ ## Accesibilidad y lifecycle
44
+
45
+ Los botones mantienen Tab, Enter y Espacio; cada estado se describe en el nombre accesible. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
46
+
@@ -0,0 +1,42 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createStepper, type StepperInstance } from './engine';
3
+
4
+ /** Light DOM; attribute changes keep the current imperative value and reset baseline. */
5
+ export class MkStepperElement extends MkElement<StepperInstance> {
6
+ static options: Record<string, OptionType> = {"items":"json","value":"number","label":"string","name":"string","variant":"string","linear":"boolean","disabled":"boolean"};
7
+ private baseline: number | undefined;
8
+ private saved: number | undefined;
9
+ protected create(options: Record<string, unknown>) {
10
+ const created = createStepper(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | undefined, defaultValue: this.baseline });
11
+ if (this.baseline === undefined) this.baseline = created.value;
12
+ this.saved = undefined;
13
+ return created;
14
+ }
15
+ disconnectedCallback() {
16
+ this.saved = this.instance?.value;
17
+ super.disconnectedCallback();
18
+ }
19
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
20
+ if (previous === next) return;
21
+ if (name === 'value' && (!this.instance || !this.isConnected)) {
22
+ this.saved = next === null ? this.baseline ?? 0 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 0;
23
+ return;
24
+ }
25
+ if (!this.instance || !this.isConnected) return;
26
+ if (name === 'value') {
27
+ const parsed = next === null ? this.baseline ?? 0 : Number(next);
28
+ this.instance.setValue(parsed, false);
29
+ return;
30
+ }
31
+ this.saved = this.instance.value;
32
+ super.attributeChangedCallback(name, previous, next);
33
+ }
34
+ get value(): number { return this.instance?.value ?? this.saved ?? 0; }
35
+ set value(next: number) { this.setValue(next); }
36
+ setValue(value: number, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
37
+ reset(notify = false) { this.instance?.reset(notify); }
38
+ next(notify = true) { this.instance?.next(notify); }
39
+ previous(notify = true) { this.instance?.previous(notify); }
40
+ }
41
+ export const defineStepper = () => defineElement('mk-stepper', MkStepperElement);
42
+
@@ -0,0 +1,103 @@
1
+ import { 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, rollDigits } from '../../motion/micro';
6
+
7
+ export interface StepperItem { label: string; description?: string; disabled?: boolean; completed?: boolean; error?: boolean }
8
+ export interface StepperOptions {
9
+ items?: StepperItem[]; value?: number; defaultValue?: number; label?: string; name?: string;
10
+ variant?: 'horizontal' | 'vertical' | 'compact' | 'dots';
11
+ /** Only the next unfinished step can be entered with the navigation buttons. */
12
+ linear?: boolean; disabled?: boolean;
13
+ onChange?: (value: number) => void;
14
+ }
15
+ export interface StepperInstance extends Instance {
16
+ readonly value: number;
17
+ setValue(value: number, notify?: boolean): void;
18
+ next(notify?: boolean): void; previous(notify?: boolean): void; reset(notify?: boolean): void;
19
+ }
20
+ export function createStepper(root: HTMLElement | null, options: StepperOptions = {}): StepperInstance {
21
+ if (!root) return inert({ value: 0, setValue() {}, next() {}, previous() {}, reset() {} });
22
+ registerMotion();
23
+ const scope = createScope();
24
+ const effects = new Set<gsap.core.Animation>();
25
+ const keep = (effect: gsap.core.Animation | null) => {
26
+ for (const prior of effects) if (prior.totalProgress() === 1) effects.delete(prior);
27
+ if (effect) effects.add(effect);
28
+ };
29
+ scope.add(() => { effects.forEach((effect) => effect.kill()); effects.clear(); });
30
+ const items = (Array.isArray(options.items) ? options.items : []).filter((item) => item && typeof item.label === 'string');
31
+ const clamp = (n: number) => Math.max(0, Math.min(Math.max(0, items.length - 1), Number.isFinite(n) ? Math.round(n) : 0));
32
+ const initial = clamp(options.defaultValue ?? options.value ?? 0);
33
+ let value = clamp(options.value ?? initial); let furthest = value;
34
+ let pending: { value: number; furthest: number } | null = null;
35
+ const nav = document.createElement('nav'); nav.dataset.mkStepperBody = ''; nav.setAttribute('aria-label', options.label ?? 'Progreso');
36
+ const list = document.createElement('ol'); list.dataset.mkStepperList = ''; nav.append(list);
37
+ const caption = document.createElement('span'); caption.dataset.mkStepperCaption = ''; caption.setAttribute('aria-live', 'polite'); nav.append(caption);
38
+ root.append(nav); scope.add(() => nav.remove());
39
+ const buttons: HTMLButtonElement[] = []; const rows: HTMLLIElement[] = []; const marks: HTMLElement[] = [];
40
+ items.forEach((item, index) => {
41
+ const row = document.createElement('li'); row.dataset.mkStepperItem = '';
42
+ const button = document.createElement('button'); button.type = 'button'; button.dataset.mkStepperStep = String(index);
43
+ const marker = document.createElement('span'); marker.dataset.mkStepperMarker = ''; marker.setAttribute('aria-hidden', 'true');
44
+ const copy = document.createElement('span'); copy.dataset.mkStepperCopy = '';
45
+ const title = document.createElement('span'); title.dataset.mkStepperLabel = ''; title.textContent = item.label; copy.append(title);
46
+ if (item.description) { const description = document.createElement('span'); description.dataset.mkStepperDescription = ''; description.textContent = item.description; copy.append(description); }
47
+ button.append(marker, copy); row.append(button); list.append(row); buttons.push(button); rows.push(row); marks.push(marker);
48
+ scope.on(button, 'click', () => { if (!button.disabled && !button.matches(':disabled')) choose(index, true); });
49
+ });
50
+ const hidden = options.name ? document.createElement('input') : null;
51
+ if (hidden) { hidden.type = 'hidden'; hidden.name = options.name!; hidden.disabled = Boolean(options.disabled); nav.append(hidden); }
52
+ scope.attr(root, 'data-mk-ready', ''); scope.attr(root, 'data-mk-variant', options.variant ?? 'horizontal');
53
+ scope.attr(root, 'data-mk-disabled', options.disabled ? '' : null);
54
+ const paint = (animate: boolean, before: number) => {
55
+ const nextEnabled = items.findIndex((item, index) => index > furthest && !item.disabled);
56
+ const availableThrough = Math.max(furthest, nextEnabled);
57
+ items.forEach((item, index) => {
58
+ const selected = index === value; const complete = item.completed || index < value;
59
+ rows[index]!.toggleAttribute('data-mk-current', selected);
60
+ rows[index]!.toggleAttribute('data-mk-complete', Boolean(complete));
61
+ rows[index]!.toggleAttribute('data-mk-error', Boolean(item.error));
62
+ const button = buttons[index]!;
63
+ button.disabled = Boolean(options.disabled || item.disabled || options.linear && index > availableThrough);
64
+ if (selected) button.setAttribute('aria-current', 'step'); else button.removeAttribute('aria-current');
65
+ button.setAttribute('aria-label', `${index + 1}. ${item.label}${item.error ? ', requiere atención' : complete ? ', completado' : ''}`);
66
+ marks[index]!.textContent = item.error ? '!' : complete ? '✓' : String(index + 1);
67
+ });
68
+ if (hidden) hidden.value = String(value);
69
+ const text = items.length ? `${value + 1} / ${items.length} · ${items[value]?.label ?? ''}` : 'No hay pasos';
70
+ if (animate && !reduced()) keep(rollDigits(caption, text, value >= before ? 1 : -1, 0.3)); else caption.textContent = text;
71
+ };
72
+ const choose = (next: number, notify = false) => {
73
+ if (scope.disposed) return;
74
+ if (notify) pending = { value, furthest };
75
+ const before = value; value = clamp(next); furthest = Math.max(furthest, value); paint(before !== value, before);
76
+ if (before === value) return;
77
+ if (!reduced() && buttons[value]) {
78
+ keep(press(buttons[value]));
79
+ keep(gsap.fromTo(marks[value]!, { scale: 0.7, filter: 'blur(4px)' }, { scale: 1, filter: 'blur(0px)', duration: 0.4, ease: EASE.pop, overwrite: 'auto', clearProps: 'transform,filter' }));
80
+ }
81
+ if (notify) { const emitted = value; options.onChange?.(emitted); if (!scope.disposed) root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value: emitted } })); }
82
+ };
83
+ const move = (direction: number, notify = true) => {
84
+ if (options.disabled) return;
85
+ for (let i = value + direction; i >= 0 && i < items.length; i += direction) if (!items[i]?.disabled) { choose(i, notify); break; }
86
+ };
87
+ const form = root.closest('form');
88
+ if (form) scope.on(form, 'reset', (event) => scope.timeout(() => { if (!scope.disposed && !event.defaultPrevented) { furthest = initial; choose(initial); } }, 0));
89
+ paint(false, value); keep(blurIn(rows, { from: 'soft', stagger: 0.04, duration: 0.35 }));
90
+ return {
91
+ get value() { return value; },
92
+ setValue(next, notify = false) {
93
+ const target = clamp(next);
94
+ // A consumer can veto a notified navigation by restoring its prior value.
95
+ // Roll the visit history back with it, before unlocking any later step.
96
+ if (!notify && pending && target === pending.value) furthest = pending.furthest;
97
+ pending = null;
98
+ choose(target, notify);
99
+ },
100
+ next: (notify = true) => move(1, notify), previous: (notify = true) => move(-1, notify),
101
+ reset: (notify = false) => { pending = null; furthest = initial; choose(initial, notify); }, destroy: () => scope.dispose(),
102
+ };
103
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createStepper, type StepperOptions, type StepperInstance } from './engine';
5
+
6
+ export interface StepperProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<StepperOptions, 'value' | 'defaultValue'> {
7
+ value?: number;
8
+ defaultValue?: number;
9
+ instanceRef?: MutableRefObject<StepperInstance | null>;
10
+ /** The engine owns this component's DOM. */
11
+ children?: never;
12
+ dangerouslySetInnerHTML?: never;
13
+ }
14
+ export function Stepper({ value, defaultValue, items, label, name, variant, linear, disabled, onChange, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: StepperProps) {
15
+ const root = useRef<HTMLDivElement>(null);
16
+ const engine = useRef<StepperInstance | null>(null);
17
+ const latestValue = useLatest(value);
18
+ const change = useLatest(onChange);
19
+
20
+ const baseline = useRef(value !== undefined ? value : defaultValue);
21
+ const current = useRef(baseline.current);
22
+ const reconcile = () => queueMicrotask(() => {
23
+ if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
24
+ });
25
+ useIsomorphicLayoutEffect(() => {
26
+ const created = createStepper(root.current, {
27
+ items, label, name, variant, linear, disabled, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
28
+ onChange: (next) => { change.current?.(next); reconcile(); },
29
+
30
+ });
31
+ engine.current = created;
32
+ const form = root.current?.closest('form');
33
+ const pending = new Set<ReturnType<typeof setTimeout>>();
34
+ const reset = (event: Event) => {
35
+ const timer = setTimeout(() => {
36
+ pending.delete(timer);
37
+ if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
38
+ }, 0);
39
+ pending.add(timer);
40
+ };
41
+ form?.addEventListener('reset', reset);
42
+ return () => {
43
+ form?.removeEventListener('reset', reset);
44
+ pending.forEach((timer) => clearTimeout(timer));
45
+ current.current = created.value; created.destroy(); engine.current = null;
46
+ };
47
+ // Callbacks and controlled value are read through refs.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [items, label, name, variant, linear, disabled]);
50
+ useIsomorphicLayoutEffect(() => {
51
+ if (value !== undefined) engine.current?.setValue(value, false);
52
+ });
53
+ useIsomorphicLayoutEffect(() => {
54
+ if (!instanceRef) return;
55
+ instanceRef.current = engine.current;
56
+ return () => { instanceRef.current = null; };
57
+ }, [instanceRef, items, label, name, variant, linear, disabled]);
58
+ return <div {...props} ref={root} className={['mk-stepper', className].filter(Boolean).join(' ')} />;
59
+ }
60
+
@@ -0,0 +1,35 @@
1
+ :is(.mk-stepper, mk-stepper) { display: block; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.35 var(--mk-font); }
2
+ [data-mk-stepper-body] { display: grid; gap: 1rem; }
3
+ [data-mk-stepper-list] { display: flex; align-items: stretch; gap: .5rem; list-style: none; margin: 0; padding: 0; }
4
+ [data-mk-stepper-item] { position: relative; flex: 1; min-width: 0; }
5
+ [data-mk-stepper-step] { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: .75rem; width: 100%; min-height: 44px; padding: .75rem; border: 0; border-radius: 16px; background: transparent; color: var(--mk-muted); font: inherit; text-align: start; cursor: pointer; transition: background .25s, border-radius .4s var(--mk-ease-swatch), color .2s; -webkit-tap-highlight-color: transparent; }
6
+ [data-mk-stepper-marker] { display: grid; place-items: center; flex: none; box-sizing: border-box; width: 36px; height: 36px; border-radius: 12px; background: var(--mk-surface-2); color: var(--mk-muted); font-size: .8125rem; transition: background .25s, color .25s, width .4s var(--mk-ease-swatch), border-radius .4s var(--mk-ease-swatch); }
7
+ [data-mk-stepper-copy] { display: grid; gap: .25rem; min-width: 0; }
8
+ [data-mk-stepper-description] { font-size: .75rem; color: var(--mk-muted); font-weight: 400; }
9
+ [data-mk-stepper-item][data-mk-current] [data-mk-stepper-step] { background: var(--mk-surface-2); color: var(--mk-text); border-radius: 24px; }
10
+ [data-mk-stepper-item][data-mk-current] [data-mk-stepper-marker] { background: var(--mk-text); color: var(--mk-bg); border-radius: 64px; }
11
+ [data-mk-stepper-item][data-mk-complete] [data-mk-stepper-marker] { background: var(--mk-surface-3); color: var(--mk-text); border-radius: 64px; }
12
+ [data-mk-stepper-item][data-mk-error] [data-mk-stepper-marker] { background: var(--mk-error-soft); color: var(--mk-error); }
13
+ [data-mk-stepper-step]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
14
+ [data-mk-stepper-step]:disabled { opacity: .4; cursor: not-allowed; }
15
+ [data-mk-stepper-caption] { min-height: 1.3em; color: var(--mk-muted); font-size: .75rem; text-align: center; font-variant-numeric: tabular-nums; }
16
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='vertical'] [data-mk-stepper-list] { flex-direction: column; gap: .375rem; }
17
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='vertical'] [data-mk-stepper-step] { flex-direction: row; align-items: center; }
18
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='vertical'] [data-mk-stepper-caption] { text-align: start; padding-inline: .75rem; }
19
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-step] { flex-direction: row; align-items: center; padding: .375rem; border-radius: 64px; }
20
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-item] { flex: 0 1 auto; }
21
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-item][data-mk-current] { flex: 1; }
22
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-marker] { border-radius: 64px; }
23
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-copy] { display: none; }
24
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-current] [data-mk-stepper-copy] { display: grid; padding-inline-end: .75rem; }
25
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='compact'] [data-mk-stepper-description] { display: none; }
26
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-stepper-list] { justify-content: center; gap: .25rem; }
27
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-stepper-item] { flex: none; }
28
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-stepper-step] { width: 44px; height: 44px; padding: 0; display: grid; place-items: center; background: transparent; }
29
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-stepper-marker] { width: 8px; height: 8px; border-radius: 64px; font-size: 0; }
30
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-current] [data-mk-stepper-marker] { width: 28px; }
31
+ :is(.mk-stepper, mk-stepper)[data-mk-variant='dots'] [data-mk-stepper-copy] { display: none; }
32
+ @media (hover: hover) { [data-mk-stepper-step]:not(:disabled):hover { background: var(--mk-surface-2); border-radius: 24px; color: var(--mk-text); } }
33
+ @media (max-width: 420px) { :is(.mk-stepper, mk-stepper)[data-mk-variant='horizontal'] [data-mk-stepper-step] { padding: .5rem; font-size: .75rem; } :is(.mk-stepper, mk-stepper)[data-mk-variant='horizontal'] [data-mk-stepper-description] { display: none; } }
34
+ @media (prefers-reduced-motion: reduce) { [data-mk-stepper-step], [data-mk-stepper-marker] { transition: none; } }
35
+ @media (forced-colors: active) { [data-mk-current] [data-mk-stepper-marker] { outline: 2px solid Highlight; } }
@@ -0,0 +1,30 @@
1
+ # Sticky header
2
+
3
+ An authored sticky surface continuously compacts inside a stable expanded-height slot. The reserved slot prevents content from jumping when its geometry changes.
4
+
5
+ ```html
6
+ <div id="scroller" style="height:320px;overflow:auto">
7
+ <header id="header">
8
+ <div data-mk-sticky-surface>
9
+ <div><h2 data-mk-sticky-title>Mi espacio</h2><p data-mk-sticky-subtitle>Todo lo que estás creando.</p></div>
10
+ <button type="button">Nuevo</button>
11
+ </div>
12
+ </header>
13
+ …content…
14
+ </div>
15
+ ```
16
+
17
+ ```ts
18
+ const header = createStickyHeader(document.querySelector('#header'), {
19
+ scroller: document.querySelector('#scroller'), expandedHeight: 104, compactHeight: 64,
20
+ });
21
+ header.destroy();
22
+ ```
23
+
24
+ Exports: `createStickyHeader`, `StickyHeaderOptions`, `StickyHeaderInstance`; React `StickyHeader`, `StickyHeaderProps`; elements `MkStickyHeaderElement`, `defineStickyHeader` (`mk-sticky-header`). CSS: `sticky-header.css` plus kit tokens/theme/fonts.
25
+
26
+ Options: `scroller` (element/selector, otherwise window), `expandedHeight` (104px), `compactHeight` (64px), `threshold` (8px), `range` (96px of scroll), `top` (sticky inset, 0px), `disabled` (holds expanded geometry; native child controls remain usable), `onChange(compact)`. Geometry follows scroll progress through the supplied range; `compact` changes at half range. The title shrinks and subtitle folds into a blur as radius, padding, height and tonal surface change.
27
+
28
+ Instance: `update()`, `setOptions(partial)`, readonly `compact`/`progress`, `destroy()`. `mk:change` carries `{compact,progress}` at compact-state crossings. Native links/buttons and focus order stay authored. Reduced motion applies geometry immediately. Destroy restores exact authored styles/attributes and removes listeners, animation frames, observers and animations.
29
+
30
+ React intentionally accepts children and renders the required surface wrapper; mark title/subtitle parts inside them. In Vanilla/elements, author `[data-mk-sticky-surface]` explicitly. The element accepts scalar option attributes and exposes `setOptions`, `update`, `compact` and `progress`; changing attributes preserves its instance.
@@ -0,0 +1,19 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createStickyHeader, type StickyHeaderInstance, type StickyHeaderOptions } from './engine';
3
+
4
+ export class MkStickyHeaderElement extends MkElement<StickyHeaderInstance> {
5
+ static options: Record<string, OptionType> = {"scroller":"string","expandedHeight":"number","compactHeight":"number","threshold":"number","range":"number","top":"number","disabled":"boolean"};
6
+ protected create(options: Record<string, unknown>) { return createStickyHeader(this, options); }
7
+ setOptions(options: Partial<StickyHeaderOptions>) { this.instance?.setOptions(options); }
8
+
9
+ update() { this.instance?.update(); }
10
+ get compact() { return this.instance?.compact ?? false; }
11
+ get progress() { return this.instance?.progress ?? 0; }
12
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
13
+ if (previous === next || !this.isConnected || !this.instance) return;
14
+ const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
15
+ const defaults: Record<string, unknown> = {"scroller":null,"expandedHeight":104,"compactHeight":64,"threshold":8,"range":96,"top":0,"disabled":false};
16
+ this.instance.setOptions({ [option]: this.options[option] ?? defaults[option] });
17
+ }
18
+ }
19
+ export const defineStickyHeader = () => defineElement('mk-sticky-header', MkStickyHeaderElement);
@@ -0,0 +1,95 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { createScope } from '../../core/scope';
3
+ import { reduced } from '../../core/reduced';
4
+ import { inert, warnOnce, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
6
+
7
+ export interface StickyHeaderOptions {
8
+ /** Own scrolling container; window by default. */
9
+ scroller?: HTMLElement | string | null;
10
+ expandedHeight?: number;
11
+ compactHeight?: number;
12
+ /** Start compacting after this scroll position, in pixels. */
13
+ threshold?: number;
14
+ /** Pixels of scroll over which geometry changes. */
15
+ range?: number;
16
+ top?: number;
17
+ disabled?: boolean;
18
+ onChange?: (compact: boolean) => void;
19
+ }
20
+ export interface StickyHeaderInstance extends Instance {
21
+ update(): void;
22
+ setOptions(options: Partial<StickyHeaderOptions>): void;
23
+ readonly compact: boolean;
24
+ readonly progress: number;
25
+ }
26
+
27
+ /** The authored surface changes geometry inside a stable, expanded-height sticky slot. */
28
+ export function createStickyHeader(root: HTMLElement | null, options: StickyHeaderOptions = {}): StickyHeaderInstance {
29
+ const surface = root?.querySelector<HTMLElement>('[data-mk-sticky-surface]');
30
+ if (!root || !surface) { if (root) warnOnce('sticky-header: needs an authored [data-mk-sticky-surface]'); return inert({ update() {}, setOptions() {}, compact: false, progress: 0 }); }
31
+ registerMotion();
32
+ const scope = createScope();
33
+ let opts = { ...options };
34
+ let scroller: HTMLElement | null = null;
35
+ let unbind = () => {};
36
+ let frame = 0;
37
+ let progress = -1;
38
+ let compact = false;
39
+ const title = surface.querySelector<HTMLElement>('[data-mk-sticky-title]');
40
+ const subtitle = surface.querySelector<HTMLElement>('[data-mk-sticky-subtitle]');
41
+ const animations = new Set<gsap.core.Animation>();
42
+ const track = (animation: gsap.core.Animation | null) => { if (animation) animations.add(animation); };
43
+ for (const node of [root, surface, title, subtitle]) if (node) scope.attr(node, 'style', node.getAttribute('style'));
44
+ scope.attr(root, 'class', `${root.className} mk-sticky-header`.trim());
45
+ scope.attr(root, 'data-mk-ready', '');
46
+ const dimensions = () => {
47
+ const large = Number.isFinite(opts.expandedHeight) ? Math.max(48, opts.expandedHeight!) : 104;
48
+ const small = Number.isFinite(opts.compactHeight) ? Math.max(40, Math.min(large, opts.compactHeight!)) : Math.min(64, large);
49
+ root.style.setProperty('--mk-sticky-expanded-height', `${large}px`);
50
+ root.style.setProperty('--mk-sticky-top', `${Math.max(0, opts.top ?? 0)}px`);
51
+ return { large, small };
52
+ };
53
+ const update = () => {
54
+ frame = 0;
55
+ if (scope.disposed) return;
56
+ const { large, small } = dimensions();
57
+ const position = scroller ? scroller.scrollTop : window.scrollY;
58
+ const next = opts.disabled ? 0 : Math.max(0, Math.min(1, (position - Math.max(0, opts.threshold ?? 8)) / Math.max(1, opts.range ?? 96)));
59
+ if (next === progress) return;
60
+ const initial = progress < 0;
61
+ progress = next;
62
+ const isCompact = next >= .5;
63
+ scope.attr(root, 'data-mk-compact', String(isCompact));
64
+ scope.attr(root, 'data-mk-disabled', opts.disabled ? '' : null);
65
+ const duration = reduced() || initial ? 0 : .22;
66
+ track(gsap.to(surface, { height: large + (small - large) * next, borderRadius: 24 - 8 * next, paddingLeft: 24 - 8 * next, paddingRight: 24 - 8 * next, duration, ease: EASE.swatch, overwrite: 'auto' }));
67
+ if (title) track(gsap.to(title, { scale: 1 - .16 * next, y: -2 * next, transformOrigin: 'left center', duration, ease: EASE.swatch, overwrite: 'auto' }));
68
+ if (subtitle) track(gsap.to(subtitle, { opacity: 1 - next, y: -8 * next, height: `${(1 - next) * 1.5}em`, filter: `blur(${next * 4}px)`, duration, ease: EASE.signature, overwrite: 'auto' }));
69
+ if (isCompact !== compact) {
70
+ compact = isCompact;
71
+ opts.onChange?.(compact);
72
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { compact, progress } }));
73
+ }
74
+ };
75
+ const queue = () => { if (!frame) frame = requestAnimationFrame(update); };
76
+ const bind = () => {
77
+ unbind();
78
+ scroller = typeof opts.scroller === 'string' ? (() => { try { return root.closest<HTMLElement>(opts.scroller) ?? document.querySelector<HTMLElement>(opts.scroller); } catch { return null; } })() : opts.scroller ?? null;
79
+ const target = scroller ?? window;
80
+ target.addEventListener('scroll', queue, { passive: true });
81
+ unbind = () => target.removeEventListener('scroll', queue);
82
+ };
83
+ scope.on(window, 'resize', queue, { passive: true });
84
+ scope.on(surface, 'click', (event) => {
85
+ const control = (event.target as Element).closest<HTMLElement>('button, a');
86
+ if (control && !control.matches(':disabled, [aria-disabled="true"]')) { scope.attr(control, 'style', control.getAttribute('style')); track(press(control)); }
87
+ });
88
+ if (typeof ResizeObserver === 'function') { const observer = scope.observe(new ResizeObserver(queue)); observer.observe(root); }
89
+ bind(); update();
90
+ return {
91
+ update, get compact() { return compact; }, get progress() { return Math.max(0, progress); },
92
+ setOptions(next) { if (scope.disposed) return; opts = { ...opts, ...next }; if ('scroller' in next) bind(); progress = -1; update(); },
93
+ destroy() { if (scope.disposed) return; cancelAnimationFrame(frame); unbind(); animations.forEach((animation) => animation.kill()); gsap.killTweensOf([surface, title, subtitle].filter((node): node is HTMLElement => !!node)); scope.dispose(); },
94
+ };
95
+ }
@@ -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 { createStickyHeader, type StickyHeaderOptions, type StickyHeaderInstance } from './engine';
5
+
6
+ export interface StickyHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, StickyHeaderOptions {
7
+ instanceRef?: MutableRefObject<StickyHeaderInstance | null>;
8
+
9
+ }
10
+ export function StickyHeader({ scroller, expandedHeight, compactHeight, threshold, range, top, disabled, onChange, instanceRef, className, children, ...props }: StickyHeaderProps) {
11
+ const root = useRef<HTMLDivElement>(null);
12
+ const instance = useRef<StickyHeaderInstance | null>(null);
13
+ const onChangeRef = useLatest(onChange);
14
+ useIsomorphicLayoutEffect(() => {
15
+ const created = createStickyHeader(root.current, { scroller, expandedHeight, compactHeight, threshold, range, top, disabled, 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({ scroller, expandedHeight, compactHeight, threshold, range, top, disabled });
22
+ }, [scroller, expandedHeight, compactHeight, threshold, range, top, disabled]);
23
+ useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
24
+ return <div {...props} ref={root} className={['mk-sticky-header', className].filter(Boolean).join(' ')}><div data-mk-sticky-surface="" className="mk-sticky-header__surface">{children}</div></div>;
25
+ }
@@ -0,0 +1,9 @@
1
+ .mk-sticky-header, mk-sticky-header { display: block; position: sticky; top: var(--mk-sticky-top, 0px); z-index: 5; height: var(--mk-sticky-expanded-height, 104px); min-height: var(--mk-sticky-expanded-height, 104px); pointer-events: none; color: var(--mk-text); font-family: var(--mk-font); }
2
+ .mk-sticky-header [data-mk-sticky-surface], mk-sticky-header [data-mk-sticky-surface] { box-sizing: border-box; position: absolute; inset: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 104px; padding: 16px 24px; border-radius: 24px; pointer-events: auto; background: var(--mk-surface); transition: background .3s, box-shadow .3s; }
3
+ :is(.mk-sticky-header,mk-sticky-header)[data-mk-compact=true] [data-mk-sticky-surface] { background: var(--mk-surface-2); box-shadow: 0 10px 24px -20px var(--mk-shadow-color, rgb(0 0 0 / .4)); }
4
+ :is(.mk-sticky-header,mk-sticky-header) [data-mk-sticky-title] { display: block; margin: 0; font-size: clamp(20px, 3.5vw, 28px); font-weight: 600; line-height: 1.2; letter-spacing: -.035em; }
5
+ :is(.mk-sticky-header,mk-sticky-header) [data-mk-sticky-subtitle] { display: block; margin: 4px 0 0; height: 1.5em; overflow: hidden; color: var(--mk-muted); font-size: 12px; line-height: 1.5; }
6
+ :is(.mk-sticky-header,mk-sticky-header) :is(button,a):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
7
+ :is(.mk-sticky-header,mk-sticky-header) button:disabled { opacity: .4; cursor: not-allowed; }
8
+ @media (max-width: 480px) { :is(.mk-sticky-header,mk-sticky-header) [data-mk-sticky-surface] { gap: 12px; } }
9
+ @media (prefers-reduced-motion: reduce) { :is(.mk-sticky-header,mk-sticky-header) * { transition: none !important; } }