@yewdiel21/motion-kit 0.9.1 → 0.10.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 (129) hide show
  1. package/README.md +93 -83
  2. package/dist/auto/index.d.ts +22 -3
  3. package/dist/auto/index.js +41 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-BFC27F2G.js → chunk-IKQ5FODN.js} +2 -2
  6. package/dist/{chunk-JQMJQSXD.js → chunk-TFNRIZHU.js} +1614 -11
  7. package/dist/chunk-TFNRIZHU.js.map +1 -0
  8. package/dist/{chunk-XDWBVFHQ.js → chunk-XAKC2QY7.js} +1202 -3
  9. package/dist/chunk-XAKC2QY7.js.map +1 -0
  10. package/dist/css/components/calendar.css +159 -0
  11. package/dist/css/components/hover-card.css +14 -0
  12. package/dist/css/components/input-otp.css +10 -0
  13. package/dist/css/components/pagination.css +11 -0
  14. package/dist/css/components/quantity.css +16 -0
  15. package/dist/css/components/slot-picker.css +126 -0
  16. package/dist/css/components/studio-dock.css +35 -0
  17. package/dist/css/components/trailer.css +37 -0
  18. package/dist/css/controls.css +65 -0
  19. package/dist/elements/index.d.ts +72 -4
  20. package/dist/elements/index.js +170 -4
  21. package/dist/elements/index.js.map +1 -1
  22. package/dist/index.css +1017 -0
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +45 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.d.ts +105 -6
  27. package/dist/react/index.js +350 -3
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/{reduced-B98f8LPs.d.ts → reduced-BmBzKH94.d.ts} +199 -2
  30. package/dist/{reveal-C0hCDcB_.d.ts → reveal-jsX-uwUz.d.ts} +211 -1
  31. package/dist/{store-pHiUvxP8.d.ts → store-DYb3tTsZ.d.ts} +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +178 -104
  34. package/skills/motion-kit/SKILL.md +209 -189
  35. package/src/auto/auto-init.ts +38 -0
  36. package/src/components/calendar/README.md +82 -0
  37. package/src/components/calendar/calendar.css +158 -0
  38. package/src/components/calendar/element.ts +27 -0
  39. package/src/components/calendar/engine.ts +412 -0
  40. package/src/components/calendar/react.tsx +69 -0
  41. package/src/components/hover-card/README.md +16 -0
  42. package/src/components/hover-card/element.ts +8 -0
  43. package/src/components/hover-card/engine.ts +69 -0
  44. package/src/components/hover-card/hover-card.css +13 -0
  45. package/src/components/hover-card/react.tsx +19 -0
  46. package/src/components/input-otp/README.md +18 -0
  47. package/src/components/input-otp/element.ts +14 -0
  48. package/src/components/input-otp/engine.ts +106 -0
  49. package/src/components/input-otp/input-otp.css +9 -0
  50. package/src/components/input-otp/react.tsx +21 -0
  51. package/src/components/pagination/README.md +17 -0
  52. package/src/components/pagination/element.ts +14 -0
  53. package/src/components/pagination/engine.ts +102 -0
  54. package/src/components/pagination/pagination.css +10 -0
  55. package/src/components/pagination/react.tsx +22 -0
  56. package/src/components/quantity/README.md +20 -0
  57. package/src/components/quantity/element.ts +14 -0
  58. package/src/components/quantity/engine.ts +85 -0
  59. package/src/components/quantity/quantity.css +15 -0
  60. package/src/components/quantity/react.tsx +21 -0
  61. package/src/components/slot-picker/README.md +68 -0
  62. package/src/components/slot-picker/element.ts +27 -0
  63. package/src/components/slot-picker/engine.ts +412 -0
  64. package/src/components/slot-picker/react.tsx +76 -0
  65. package/src/components/slot-picker/slot-picker.css +125 -0
  66. package/src/components/studio-dock/README.md +44 -0
  67. package/src/components/studio-dock/element.ts +11 -0
  68. package/src/components/studio-dock/engine.ts +212 -0
  69. package/src/components/studio-dock/react.tsx +23 -0
  70. package/src/components/studio-dock/studio-dock.css +34 -0
  71. package/src/components/trailer/README.md +28 -0
  72. package/src/components/trailer/element.ts +13 -0
  73. package/src/components/trailer/engine.ts +189 -0
  74. package/src/components/trailer/react.tsx +16 -0
  75. package/src/components/trailer/trailer.css +36 -0
  76. package/src/core/dates.ts +98 -0
  77. package/src/css/controls/README.md +14 -0
  78. package/src/css/controls/status.css +63 -0
  79. package/src/elements/index.ts +24 -0
  80. package/src/index.ts +25 -0
  81. package/src/react/index.ts +21 -0
  82. package/src/react/use-action-button.ts +6 -0
  83. package/src/react/use-editorial-reveal.ts +7 -0
  84. package/src/react/use-film-hero.ts +6 -0
  85. package/src/react/use-font-cycle.ts +7 -0
  86. package/src/react/use-image-zoom.ts +5 -0
  87. package/src/react/use-joined-cta.ts +5 -0
  88. package/src/react/use-local-clock.ts +5 -0
  89. package/src/react/use-scroll-dock.ts +5 -0
  90. package/src/react/use-sticker.ts +5 -0
  91. package/src/react/use-studio-dock.ts +8 -0
  92. package/src/react/use-thumbnail-nav.ts +7 -0
  93. package/src/react/use-trailer.ts +6 -0
  94. package/src/react/use-weight-hover.ts +5 -0
  95. package/src/recipes/action-button/README.md +31 -0
  96. package/src/recipes/action-button/action-button.css +113 -0
  97. package/src/recipes/action-button/create-action-button.ts +56 -0
  98. package/src/recipes/editorial-reveal/README.md +35 -0
  99. package/src/recipes/editorial-reveal/create-editorial-reveal.ts +91 -0
  100. package/src/recipes/editorial-reveal/editorial-reveal.css +17 -0
  101. package/src/recipes/film-hero/README.md +23 -0
  102. package/src/recipes/film-hero/create-film-hero.ts +65 -0
  103. package/src/recipes/film-hero/film-hero.css +29 -0
  104. package/src/recipes/font-cycle/README.md +11 -0
  105. package/src/recipes/font-cycle/create-font-cycle.ts +126 -0
  106. package/src/recipes/font-cycle/font-cycle.css +36 -0
  107. package/src/recipes/image-zoom/README.md +16 -0
  108. package/src/recipes/image-zoom/create-image-zoom.ts +27 -0
  109. package/src/recipes/image-zoom/image-zoom.css +7 -0
  110. package/src/recipes/joined-cta/README.md +13 -0
  111. package/src/recipes/joined-cta/create-joined-cta.ts +22 -0
  112. package/src/recipes/joined-cta/joined-cta.css +17 -0
  113. package/src/recipes/local-clock/README.md +31 -0
  114. package/src/recipes/local-clock/create-local-clock.ts +61 -0
  115. package/src/recipes/scroll-dock/README.md +46 -0
  116. package/src/recipes/scroll-dock/create-scroll-dock.ts +72 -0
  117. package/src/recipes/scroll-dock/scroll-dock.css +70 -0
  118. package/src/recipes/sticker/README.md +59 -0
  119. package/src/recipes/sticker/create-sticker.ts +490 -0
  120. package/src/recipes/sticker/sticker.css +112 -0
  121. package/src/recipes/thumbnail-nav/README.md +16 -0
  122. package/src/recipes/thumbnail-nav/create-thumbnail-nav.ts +165 -0
  123. package/src/recipes/thumbnail-nav/thumbnail-nav.css +108 -0
  124. package/src/recipes/weight-hover/README.md +37 -0
  125. package/src/recipes/weight-hover/create-weight-hover.ts +136 -0
  126. package/src/recipes/weight-hover/weight-hover.css +7 -0
  127. package/dist/chunk-JQMJQSXD.js.map +0 -1
  128. package/dist/chunk-XDWBVFHQ.js.map +0 -1
  129. /package/dist/{chunk-BFC27F2G.js.map → chunk-IKQ5FODN.js.map} +0 -0
@@ -0,0 +1,68 @@
1
+ # slot-picker
2
+
3
+ El selector de citas de Melon Mind («Agendar cita»): a la izquierda un mes compacto y a la derecha los horarios del día elegido. Sirve para reservas, citas o mesas.
4
+
5
+ - **Cambiar de día:** los horarios salen 30 px hacia un lado y entran 30 px desde el otro (0.25 s `ease-out`), en la dirección del cambio. El original usa una View Transition `time-slot`.
6
+ - **Primera lista:** entra creciendo (escala .7, blur 32 → 0, 0.3 s `cubic-bezier(.38,.49,0,1.1)`), 20 ms entre horarios.
7
+ - **Encabezado del día:** se abre de 0 a 56 px en 0.5 s `cubic-bezier(.38,.49,0,1.4)`.
8
+ - **Horario elegido:** se ilumina en 0.7 s (escala .8 → 1, blur 16 → 0).
9
+ - **Cambiar de mes:** los días se deslizan igual que los horarios.
10
+
11
+ Origen: REA de la app de Melon Mind (`appt-creator.css`, `schedulePicker.css`, `CreateApptManager.js`).
12
+
13
+ ## HTML / Astro
14
+
15
+ ```html
16
+ <mk-slot-picker class="mk-slot-picker" locale="es" min="2026-10-23" closed-days="[0]"
17
+ slots='["09:00","10:00","11:30","16:00","17:30"]'>
18
+ <div class="mk-slot-picker__calendar">
19
+ <div class="mk-slot-picker__month">
20
+ <h3 class="mk-slot-picker__title" data-mk-slot-title></h3>
21
+ <button type="button" class="mk-slot-picker__nav" data-mk-slot-prev aria-label="Mes anterior">←</button>
22
+ <button type="button" class="mk-slot-picker__nav" data-mk-slot-next aria-label="Mes siguiente">→</button>
23
+ </div>
24
+ <div data-mk-slot-days></div>
25
+ </div>
26
+ <div class="mk-slot-picker__panel">
27
+ <p class="mk-slot-picker__heading" data-mk-slot-heading></p>
28
+ <div data-mk-slot-times></div>
29
+ <input type="hidden" name="cita" data-mk-slot-value>
30
+ </div>
31
+ </mk-slot-picker>
32
+ ```
33
+
34
+ El input oculto `[data-mk-slot-value]` lleva `YYYY-MM-DDTHH:mm` y emite `input`/`change`, así que funciona dentro de un `<form>`. Si cambias el atributo `slots`, la lista se actualiza sin perder el día. Sin custom element: `createSlotPicker(root, opciones)`.
35
+
36
+ ## React
37
+
38
+ ```tsx
39
+ <SlotPicker
40
+ name="cita"
41
+ min={new Date()}
42
+ closedDays={[0]}
43
+ slots={(day) => fetch(`/api/horarios?dia=${dayKey(day)}`).then((r) => r.json())}
44
+ onChange={({ value }) => setCita(value)}
45
+ actions={<button className="mk-button">Agendar</button>}
46
+ />
47
+ ```
48
+
49
+ ## Opciones
50
+
51
+ | Opción | Atributo | Por defecto | Qué hace |
52
+ |---|---|---|---|
53
+ | `slots` | `slots` (JSON) | `[]` | Lista igual todos los días, mapa `{"2026-10-23": [...]}` o función `(día) => lista \| Promise<lista>`. Horas `HH:mm` |
54
+ | `date` | `date` | hoy | Mes inicial (y día elegido si no hay `value`) |
55
+ | `value` | `value` | — | `"2026-10-23T10:30"` o `{ date, time }` |
56
+ | `min`, `max` | `min`, `max` | — | Días fuera de rango deshabilitados; flechas de mes limitadas |
57
+ | `closedDays` | `closed-days` (JSON) | `[]` | Días de la semana cerrados (0 = domingo) |
58
+ | `isDisabled(date)` | — | — | Regla propia |
59
+ | `firstDay` | `first-day` | `0` | Primer día de la semana |
60
+ | `locale` | `locale` | `'es'` | Nombres de mes y día |
61
+ | `labels` | — | español | `placeholder`, `empty`, `loading`, `heading(date)` |
62
+ | `onChange({date, time, key, value})`, `onDateChange(date)` | — | — | Callbacks |
63
+
64
+ API: `selectDate(d)`, `selectTime(t | null)`, `setSlots(fuente)`, `date`, `time`, `value`, `destroy()`. `mk:change` lleva `{date, time, key, value}`. Si los horarios llegan por promesa, mientras cargan se muestra `loading` con `aria-busy`, y una respuesta vieja nunca pisa a la más reciente.
65
+
66
+ ## Accesibilidad
67
+
68
+ Los días forman una rejilla ARIA con foco móvil: flechas, Inicio/Fin y RePág/AvPág para cambiar de mes. Los horarios son un `radiogroup`: las flechas mueven la selección, y el grupo está etiquetado por el encabezado del día. Los días deshabilitados son botones `disabled`. Con movimiento reducido no hay deslizamiento, entradas ni resaltado animado.
@@ -0,0 +1,27 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createSlotPicker, type SlotPickerInstance, type SlotSource } from './engine';
3
+
4
+ /** Light DOM: supply the [data-mk-slot-days] and [data-mk-slot-times] markup shown in the README. */
5
+ export class MkSlotPickerElement extends MkElement<SlotPickerInstance> {
6
+ static options: Record<string, OptionType> = {
7
+ date: 'string', value: 'string', slots: 'json', min: 'string', max: 'string', closedDays: 'json', firstDay: 'number', locale: 'string',
8
+ };
9
+
10
+ protected create(options: Record<string, unknown>) {
11
+ return createSlotPicker(this, options);
12
+ }
13
+
14
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
15
+ if (previous === next || !this.isConnected || !this.instance) return;
16
+ if (name === 'slots') {
17
+ this.instance.setSlots((this.options.slots as SlotSource | undefined) ?? []);
18
+ return;
19
+ }
20
+ // Keep the visitor's pick unless the value itself changed.
21
+ const value = this.instance.value ?? (this.instance.date ? this.instance.date : undefined);
22
+ this.instance.destroy();
23
+ this.instance = this.create({ ...(name !== 'value' && value ? (typeof value === 'string' ? { value } : { date: value }) : {}), ...this.options });
24
+ }
25
+ }
26
+
27
+ export const defineSlotPicker = () => defineElement('mk-slot-picker', MkSlotPickerElement);
@@ -0,0 +1,412 @@
1
+ import { CustomEase, gsap, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import {
6
+ addDays, addMonths, capitalize, clampFirstDay, dayKey, monthWeeks, safeFormat, sameDay, sameMonth,
7
+ fromJson, startOfWeek, toDate, weekdayNames, type DateInput,
8
+ } from '../../core/dates';
9
+ import { flowIn } from '../../motion/flow';
10
+
11
+ /*
12
+ * Slot picker (Melon Mind "Agendar cita"): a compact month on one side and the
13
+ * day's times on the other. From the reference: changing day slides the times
14
+ * out −30 px and the new ones in from +30 px, .25 s ease-out (view transition
15
+ * "time-slot", schedulePicker.css); the first list grows in (scale .7, blur 32,
16
+ * .3 s cubic-bezier(.38,.49,0,1.1)); the day heading opens 0 → 56 px in .5 s
17
+ * cubic-bezier(.38,.49,0,1.4) and a picked time lights up in .7 s
18
+ * (animation-highlight-1). Month changes slide the days the same way.
19
+ */
20
+
21
+ export type SlotSource = string[] | Record<string, string[]> | ((date: Date) => string[] | Promise<string[]>);
22
+
23
+ export interface SlotValue { date: Date; time: string; key: string; value: string }
24
+
25
+ export interface SlotPickerLabels {
26
+ placeholder?: string;
27
+ empty?: string;
28
+ loading?: string;
29
+ heading?: (date: Date) => string;
30
+ }
31
+
32
+ export interface SlotPickerOptions {
33
+ /** Month shown first (and the selected day when `value` is not given). */
34
+ date?: DateInput;
35
+ /** Initial value: "2026-10-23T10:30" or { date, time }. */
36
+ value?: string | { date: DateInput; time: string } | null;
37
+ slots?: SlotSource;
38
+ min?: DateInput;
39
+ max?: DateInput;
40
+ /** Weekdays never available (0 = Sunday). */
41
+ closedDays?: number[];
42
+ isDisabled?: (date: Date) => boolean;
43
+ firstDay?: number;
44
+ locale?: string;
45
+ labels?: SlotPickerLabels;
46
+ onChange?: (value: SlotValue) => void;
47
+ onDateChange?: (date: Date) => void;
48
+ }
49
+
50
+ export interface SlotPickerInstance extends Instance {
51
+ selectDate(date: DateInput): void;
52
+ selectTime(time: string | null): void;
53
+ setSlots(slots: SlotSource): void;
54
+ readonly date: Date | null;
55
+ readonly time: string | null;
56
+ readonly value: string | null;
57
+ }
58
+
59
+ const SLIDE = 30;
60
+ const SLIDE_DURATION = 0.25;
61
+ let slotEaseReady = false;
62
+
63
+ export function createSlotPicker(root: HTMLElement | null, options: SlotPickerOptions = {}): SlotPickerInstance {
64
+ const daysHost = root?.querySelector<HTMLElement>('[data-mk-slot-days]');
65
+ const timesHost = root?.querySelector<HTMLElement>('[data-mk-slot-times]');
66
+ if (!root || !daysHost || !timesHost) {
67
+ return inert({ selectDate() {}, selectTime() {}, setSlots() {}, date: null, time: null, value: null });
68
+ }
69
+ registerMotion();
70
+ if (!slotEaseReady && typeof window !== 'undefined') {
71
+ CustomEase.create('mk-slot-in', '.38,.49,0,1.1');
72
+ slotEaseReady = true;
73
+ }
74
+ const scope = createScope();
75
+ const ctx = gsap.context(() => {});
76
+ scope.add(() => ctx.revert());
77
+ const locale = options.locale ?? 'es';
78
+ const firstDay = clampFirstDay(options.firstDay);
79
+ const labels = {
80
+ placeholder: options.labels?.placeholder ?? 'Elige un día para ver los horarios.',
81
+ empty: options.labels?.empty ?? 'No hay horarios este día.',
82
+ loading: options.labels?.loading ?? 'Cargando horarios…',
83
+ heading: options.labels?.heading ?? ((d: Date) => capitalize(safeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long' }).format(d))),
84
+ };
85
+ const min = options.min !== undefined ? toDate(options.min) : null;
86
+ const max = options.max !== undefined ? toDate(options.max) : null;
87
+ const closedList = fromJson<number[]>(options.closedDays);
88
+ const closed = new Set((Array.isArray(closedList) ? closedList : []).filter((n) => Number.isInteger(n)));
89
+ let slots: SlotSource = fromJson<SlotSource>(options.slots) ?? [];
90
+
91
+ const initial = (() => {
92
+ const v = options.value;
93
+ if (typeof v === 'string') {
94
+ const match = /^(\d{4}-\d{2}-\d{2})(?:T(\d{1,2}:\d{2}))?/.exec(v);
95
+ return match ? { date: toDate(match[1]), time: match[2] ?? null } : null;
96
+ }
97
+ return v ? { date: toDate(v.date), time: v.time ?? null } : null;
98
+ })();
99
+ let month = initial?.date ?? toDate(options.date);
100
+ let date: Date | null = initial?.date ?? (options.date !== undefined ? toDate(options.date) : null);
101
+ let time: string | null = initial?.time ?? null;
102
+ let focusDay = date ?? month;
103
+ let times: string[] = [];
104
+ let request = 0;
105
+
106
+ const title = root.querySelector<HTMLElement>('[data-mk-slot-title]');
107
+ const heading = root.querySelector<HTMLElement>('[data-mk-slot-heading]');
108
+ const prev = root.querySelector<HTMLButtonElement>('[data-mk-slot-prev]');
109
+ const next = root.querySelector<HTMLButtonElement>('[data-mk-slot-next]');
110
+ const output = root.querySelector<HTMLInputElement>('[data-mk-slot-value]');
111
+ const originals = [daysHost, timesHost, title, heading].filter(Boolean).map((el) => [el!, [...el!.childNodes]] as const);
112
+ scope.add(() => originals.forEach(([el, nodes]) => el.replaceChildren(...nodes)));
113
+ if (output) {
114
+ const before = output.value;
115
+ scope.add(() => { output.value = before; });
116
+ }
117
+ scope.attr(root, 'data-mk-ready', '');
118
+ scope.attr(daysHost, 'role', 'grid');
119
+ scope.attr(timesHost, 'role', 'radiogroup');
120
+ if (heading) {
121
+ if (!heading.id) scope.attr(heading, 'id', `mk-slot-${Math.random().toString(36).slice(2, 8)}`);
122
+ scope.attr(timesHost, 'aria-labelledby', heading.id);
123
+ }
124
+ if (title) {
125
+ if (!title.id) scope.attr(title, 'id', `mk-slot-month-${Math.random().toString(36).slice(2, 8)}`);
126
+ scope.attr(daysHost, 'aria-labelledby', title.id);
127
+ }
128
+
129
+ const monthTitle = safeFormat(locale, { month: 'long', year: 'numeric' });
130
+ const fullDay = safeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
131
+ const disabled = (d: Date) => (min && d < min) || (max && d > max) || closed.has(d.getDay()) || !!options.isDisabled?.(d);
132
+
133
+ /* ── Days ───────────────────────────────────────────── */
134
+ let daysBody: HTMLElement | null = null;
135
+ const leaving = new Set<HTMLElement>();
136
+ scope.add(() => leaving.forEach((el) => el.remove()));
137
+ const buildDays = () => {
138
+ const wrap = document.createElement('div');
139
+ wrap.className = 'mk-slot-picker__days';
140
+ wrap.setAttribute('role', 'rowgroup');
141
+ const head = document.createElement('div');
142
+ head.className = 'mk-slot-picker__weekdays';
143
+ head.setAttribute('role', 'row');
144
+ for (const name of weekdayNames(locale, firstDay, 'short')) {
145
+ const cell = document.createElement('span');
146
+ cell.setAttribute('role', 'columnheader');
147
+ cell.textContent = name.slice(0, 2);
148
+ head.append(cell);
149
+ }
150
+ wrap.append(head);
151
+ const focusKey = dayKey(focusDay);
152
+ for (const week of monthWeeks(month, firstDay)) {
153
+ const row = document.createElement('div');
154
+ row.className = 'mk-slot-picker__week';
155
+ row.setAttribute('role', 'row');
156
+ for (const d of week) {
157
+ const cell = document.createElement('span');
158
+ cell.setAttribute('role', 'gridcell');
159
+ if (!sameMonth(d, month)) {
160
+ row.append(cell);
161
+ continue;
162
+ }
163
+ const button = document.createElement('button');
164
+ button.type = 'button';
165
+ button.className = 'mk-slot-picker__day';
166
+ button.dataset.mkSlotDay = dayKey(d);
167
+ button.textContent = String(d.getDate());
168
+ button.tabIndex = dayKey(d) === focusKey ? 0 : -1;
169
+ button.setAttribute('aria-label', capitalize(fullDay.format(d)));
170
+ if (disabled(d)) button.disabled = true;
171
+ if (sameDay(d, new Date())) button.setAttribute('aria-current', 'date');
172
+ cell.setAttribute('aria-selected', String(sameDay(d, date)));
173
+ cell.append(button);
174
+ row.append(cell);
175
+ }
176
+ wrap.append(row);
177
+ }
178
+ return wrap;
179
+ };
180
+ const renderDays = (direction: number) => {
181
+ const fresh = buildDays();
182
+ if (title) title.textContent = capitalize(monthTitle.format(month));
183
+ // No earlier month when its last day is before `min`; no later one when its first day is after `max`.
184
+ if (prev) scope.attr(prev, 'disabled', min && new Date(month.getFullYear(), month.getMonth(), 0) < min ? '' : null);
185
+ if (next) scope.attr(next, 'disabled', max && new Date(month.getFullYear(), month.getMonth() + 1, 1) > max ? '' : null);
186
+ if (daysBody && direction && !reduced()) {
187
+ const old = daysBody;
188
+ old.style.top = `${old.offsetTop}px`;
189
+ old.classList.add('mk-slot-picker__leaving');
190
+ old.setAttribute('aria-hidden', 'true');
191
+ leaving.add(old);
192
+ daysHost.append(fresh);
193
+ ctx.add(() => {
194
+ gsap.to(old, { x: -SLIDE * direction, opacity: 0, duration: SLIDE_DURATION, ease: 'power1.out', onComplete: () => { old.remove(); leaving.delete(old); } });
195
+ gsap.fromTo(fresh, { x: SLIDE * direction, opacity: 0 }, { x: 0, opacity: 1, duration: SLIDE_DURATION, ease: 'power1.out', clearProps: 'transform,opacity' });
196
+ });
197
+ } else if (daysBody) daysBody.replaceWith(fresh);
198
+ else daysHost.replaceChildren(fresh);
199
+ daysBody = fresh;
200
+ };
201
+ const dayButton = (d: Date) => daysHost.querySelector<HTMLButtonElement>(`.mk-slot-picker__days:not(.mk-slot-picker__leaving) [data-mk-slot-day="${dayKey(d)}"]`);
202
+ const moveFocus = (to: Date, focus: boolean) => {
203
+ focusDay = to;
204
+ if (!sameMonth(to, month)) {
205
+ const direction = to > month ? 1 : -1;
206
+ month = to;
207
+ renderDays(direction);
208
+ } else daysHost.querySelectorAll<HTMLButtonElement>('[data-mk-slot-day]').forEach((b) => (b.tabIndex = b.dataset.mkSlotDay === dayKey(to) ? 0 : -1));
209
+ if (focus) dayButton(to)?.focus();
210
+ };
211
+
212
+ /* ── Times ──────────────────────────────────────────── */
213
+ let timesBody: HTMLElement | null = null;
214
+ let firstList = true;
215
+ const buildTimes = (state: 'placeholder' | 'loading' | 'ready') => {
216
+ const wrap = document.createElement('div');
217
+ wrap.className = 'mk-slot-picker__list';
218
+ if (state !== 'ready' || !times.length) {
219
+ const note = document.createElement('p');
220
+ note.className = 'mk-slot-picker__note';
221
+ note.textContent = state === 'placeholder' ? labels.placeholder : state === 'loading' ? labels.loading : labels.empty;
222
+ wrap.append(note);
223
+ return wrap;
224
+ }
225
+ const focusTime = time && times.includes(time) ? time : times[0];
226
+ for (const t of times) {
227
+ const button = document.createElement('button');
228
+ button.type = 'button';
229
+ button.className = 'mk-slot-picker__time';
230
+ button.setAttribute('role', 'radio');
231
+ button.dataset.mkSlotTime = t;
232
+ button.textContent = t;
233
+ button.setAttribute('aria-checked', String(t === time));
234
+ button.tabIndex = t === focusTime ? 0 : -1;
235
+ wrap.append(button);
236
+ }
237
+ return wrap;
238
+ };
239
+ const renderTimes = (state: 'placeholder' | 'loading' | 'ready', direction: number) => {
240
+ const fresh = buildTimes(state);
241
+ scope.attr(timesHost, 'aria-busy', state === 'loading' ? 'true' : null);
242
+ const motion = !reduced();
243
+ if (timesBody && direction && motion) {
244
+ const old = timesBody;
245
+ old.style.top = `${old.offsetTop}px`;
246
+ old.classList.add('mk-slot-picker__leaving');
247
+ old.setAttribute('aria-hidden', 'true');
248
+ old.querySelectorAll('button').forEach((b) => (b.tabIndex = -1));
249
+ leaving.add(old);
250
+ timesHost.append(fresh);
251
+ ctx.add(() => {
252
+ gsap.to(old, { x: -SLIDE * direction, opacity: 0, duration: SLIDE_DURATION, ease: 'power1.out', onComplete: () => { old.remove(); leaving.delete(old); } });
253
+ gsap.fromTo(fresh, { x: SLIDE * direction, opacity: 0 }, { x: 0, opacity: 1, duration: SLIDE_DURATION, ease: 'power1.out', clearProps: 'transform,opacity' });
254
+ });
255
+ } else if (timesBody) timesBody.replaceWith(fresh);
256
+ else timesHost.replaceChildren(fresh);
257
+ timesBody = fresh;
258
+ if (state === 'ready' && times.length && motion && firstList) {
259
+ firstList = false;
260
+ ctx.add(() => flowIn(fresh, { target: '.mk-slot-picker__time', style: 2, startScale: 0.7, blur: 32, duration: 0.3, stagger: 0.02, ease: 'mk-slot-in' }));
261
+ }
262
+ };
263
+ const resolve = (d: Date): string[] | Promise<string[]> => {
264
+ const source = slots;
265
+ if (Array.isArray(source)) return source;
266
+ if (typeof source === 'function') return source(d);
267
+ return source?.[dayKey(d)] ?? [];
268
+ };
269
+ const clean = (list: unknown) => (Array.isArray(list) ? [...new Set(list.filter((t): t is string => typeof t === 'string' && /^\d{1,2}:\d{2}$/.test(t)))] : []);
270
+ const load = (direction: number) => {
271
+ if (!date) {
272
+ times = [];
273
+ renderTimes('placeholder', 0);
274
+ return;
275
+ }
276
+ const mine = ++request;
277
+ let result: string[] | Promise<string[]>;
278
+ try {
279
+ result = resolve(date);
280
+ } catch {
281
+ result = [];
282
+ }
283
+ if (result && typeof (result as Promise<string[]>).then === 'function') {
284
+ renderTimes('loading', direction);
285
+ (result as Promise<string[]>).then(
286
+ (list) => { if (mine === request && !scope.disposed) { times = clean(list); renderTimes('ready', 0); } },
287
+ () => { if (mine === request && !scope.disposed) { times = []; renderTimes('ready', 0); } },
288
+ );
289
+ } else {
290
+ times = clean(result);
291
+ if (time && !times.includes(time)) time = null;
292
+ renderTimes('ready', direction);
293
+ }
294
+ };
295
+
296
+ const syncHeading = () => {
297
+ if (!heading) return;
298
+ if (date) {
299
+ heading.textContent = labels.heading(date);
300
+ if (!heading.hasAttribute('data-mk-shown')) scope.attr(heading, 'data-mk-shown', '');
301
+ }
302
+ };
303
+ const emit = () => {
304
+ const key = date ? dayKey(date) : '';
305
+ const value = date && time ? `${key}T${time}` : null;
306
+ if (output) {
307
+ output.value = value ?? '';
308
+ output.dispatchEvent(new Event('input', { bubbles: true }));
309
+ output.dispatchEvent(new Event('change', { bubbles: true }));
310
+ }
311
+ if (date && time && value) {
312
+ const detail: SlotValue = { date, time, key, value };
313
+ options.onChange?.(detail);
314
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail }));
315
+ }
316
+ };
317
+
318
+ const selectDate = (input: DateInput, notify = true) => {
319
+ if (scope.disposed) return;
320
+ const d = toDate(input);
321
+ if (disabled(d)) return;
322
+ const direction = date ? Math.sign(d.getTime() - date.getTime()) : 0;
323
+ if (sameDay(d, date)) return;
324
+ date = d;
325
+ time = null;
326
+ focusDay = d;
327
+ if (!sameMonth(d, month)) {
328
+ const dir = d > month ? 1 : -1;
329
+ month = d;
330
+ renderDays(dir);
331
+ }
332
+ daysHost.querySelectorAll<HTMLElement>('[role="gridcell"]').forEach((cell) => {
333
+ const b = cell.querySelector<HTMLButtonElement>('[data-mk-slot-day]');
334
+ if (b) {
335
+ cell.setAttribute('aria-selected', String(b.dataset.mkSlotDay === dayKey(d)));
336
+ b.tabIndex = b.dataset.mkSlotDay === dayKey(d) ? 0 : -1;
337
+ }
338
+ });
339
+ syncHeading();
340
+ load(direction);
341
+ if (notify) {
342
+ options.onDateChange?.(d);
343
+ emit();
344
+ }
345
+ };
346
+ const selectTime = (t: string | null, notify = true) => {
347
+ if (scope.disposed || !date) return;
348
+ if (t !== null && !times.includes(t)) return;
349
+ time = t;
350
+ timesHost.querySelectorAll<HTMLButtonElement>('[data-mk-slot-time]').forEach((b) => {
351
+ b.setAttribute('aria-checked', String(b.dataset.mkSlotTime === t));
352
+ b.tabIndex = (t ? b.dataset.mkSlotTime === t : b === timesHost.querySelector('[data-mk-slot-time]')) ? 0 : -1;
353
+ });
354
+ if (notify) emit();
355
+ };
356
+
357
+ scope.on(root, 'click', (e) => {
358
+ const target = (e.target as Element).closest<HTMLButtonElement>('button');
359
+ if (!target || target.disabled || !root.contains(target)) return;
360
+ if (target === prev) moveFocus(addMonths(month, -1), false);
361
+ else if (target === next) moveFocus(addMonths(month, 1), false);
362
+ else if (target.dataset.mkSlotDay) selectDate(target.dataset.mkSlotDay);
363
+ else if (target.dataset.mkSlotTime) selectTime(target.dataset.mkSlotTime);
364
+ });
365
+ scope.on(daysHost, 'keydown', (e) => {
366
+ const event = e as KeyboardEvent;
367
+ const key = (event.target as HTMLElement).dataset?.mkSlotDay;
368
+ if (!key) return;
369
+ const d = toDate(key);
370
+ const to = ({
371
+ ArrowLeft: () => addDays(d, -1), ArrowRight: () => addDays(d, 1), ArrowUp: () => addDays(d, -7), ArrowDown: () => addDays(d, 7),
372
+ Home: () => startOfWeek(d, firstDay), End: () => addDays(startOfWeek(d, firstDay), 6),
373
+ PageUp: () => addMonths(d, -1), PageDown: () => addMonths(d, 1),
374
+ } as Record<string, () => Date>)[event.key]?.();
375
+ if (!to) return;
376
+ event.preventDefault();
377
+ moveFocus(to, true);
378
+ });
379
+ scope.on(timesHost, 'keydown', (e) => {
380
+ const event = e as KeyboardEvent;
381
+ const current = (event.target as HTMLElement).dataset?.mkSlotTime;
382
+ if (!current) return;
383
+ const i = times.indexOf(current);
384
+ const to = ({ ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: times.length - 1 } as Record<string, number>)[event.key];
385
+ if (to === undefined) return;
386
+ event.preventDefault();
387
+ const t = times[(to + times.length) % times.length];
388
+ selectTime(t);
389
+ timesHost.querySelector<HTMLButtonElement>(`[data-mk-slot-time="${t}"]`)?.focus();
390
+ });
391
+
392
+ renderDays(0);
393
+ if (date) {
394
+ syncHeading();
395
+ load(0);
396
+ if (time && !times.includes(time)) time = null;
397
+ } else renderTimes('placeholder', 0);
398
+
399
+ return {
400
+ selectDate: (d) => selectDate(d),
401
+ selectTime: (t) => selectTime(t),
402
+ setSlots(source) {
403
+ if (scope.disposed) return;
404
+ slots = fromJson<SlotSource>(source) ?? [];
405
+ load(0);
406
+ },
407
+ get date() { return date; },
408
+ get time() { return time; },
409
+ get value() { return date && time ? `${dayKey(date)}T${time}` : null; },
410
+ destroy: () => scope.dispose(),
411
+ };
412
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useRef, type HTMLAttributes, type ReactNode } from 'react';
4
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
5
+ import { createSlotPicker, type SlotPickerInstance, type SlotPickerOptions } from './engine';
6
+
7
+ export interface SlotPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, SlotPickerOptions {
8
+ /** Form field name: a hidden input carries "YYYY-MM-DDTHH:mm". */
9
+ name?: string;
10
+ labels?: SlotPickerOptions['labels'] & { previous?: string; next?: string };
11
+ /** Rendered under the times (e.g. a submit button). */
12
+ actions?: ReactNode;
13
+ }
14
+
15
+ export function SlotPicker({
16
+ date, value, slots, min, max, closedDays, isDisabled, firstDay, locale, labels, onChange, onDateChange,
17
+ name, actions, className, ...props
18
+ }: SlotPickerProps) {
19
+ const ref = useRef<HTMLDivElement>(null);
20
+ const instance = useRef<SlotPickerInstance | null>(null);
21
+ const kept = useRef<string | Date | null>(null);
22
+ const change = useLatest(onChange);
23
+ const dateChange = useLatest(onDateChange);
24
+ const disabledFn = useLatest(isDisabled);
25
+ // A function source is read through a ref (inline functions do not reload);
26
+ // lists and maps reload when their content changes.
27
+ const source = useLatest(slots);
28
+ const slotsKey = typeof slots === 'function' ? 'function' : JSON.stringify(slots ?? []);
29
+ const stable = () => {
30
+ const current = source.current;
31
+ return typeof current === 'function' ? (d: Date) => (source.current as (day: Date) => string[] | Promise<string[]>)(d) : current ?? [];
32
+ };
33
+ useIsomorphicLayoutEffect(() => {
34
+ const keep = kept.current;
35
+ const created = createSlotPicker(ref.current, {
36
+ date: date ?? (keep instanceof Date ? keep : undefined),
37
+ value: value !== undefined ? value : typeof keep === 'string' ? keep : undefined,
38
+ slots: stable(), min, max, closedDays, firstDay, locale, labels,
39
+ isDisabled: (d) => disabledFn.current?.(d) ?? false,
40
+ onChange: (v) => change.current?.(v),
41
+ onDateChange: (d) => dateChange.current?.(d),
42
+ });
43
+ instance.current = created;
44
+ return () => {
45
+ kept.current = created.value ?? created.date;
46
+ created.destroy();
47
+ instance.current = null;
48
+ };
49
+ // `slots` updates in place; the rest recreates the picker.
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [date, value, min, max, firstDay, locale, JSON.stringify(closedDays ?? [])]);
52
+ const firstSlots = useRef(true);
53
+ useIsomorphicLayoutEffect(() => {
54
+ if (firstSlots.current) { firstSlots.current = false; return; }
55
+ instance.current?.setSlots(stable());
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [slotsKey]);
58
+ return (
59
+ <div {...props} ref={ref} className={['mk-slot-picker', className].filter(Boolean).join(' ')}>
60
+ <div className="mk-slot-picker__calendar">
61
+ <div className="mk-slot-picker__month">
62
+ <h3 className="mk-slot-picker__title" data-mk-slot-title />
63
+ <button type="button" className="mk-slot-picker__nav" data-mk-slot-prev aria-label={labels?.previous ?? 'Mes anterior'}>←</button>
64
+ <button type="button" className="mk-slot-picker__nav" data-mk-slot-next aria-label={labels?.next ?? 'Mes siguiente'}>→</button>
65
+ </div>
66
+ <div data-mk-slot-days />
67
+ </div>
68
+ <div className="mk-slot-picker__panel">
69
+ <p className="mk-slot-picker__heading" data-mk-slot-heading />
70
+ <div data-mk-slot-times />
71
+ {name && <input type="hidden" name={name} data-mk-slot-value />}
72
+ {actions && <div className="mk-slot-picker__actions">{actions}</div>}
73
+ </div>
74
+ </div>
75
+ );
76
+ }