@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.
- package/README.md +93 -83
- package/dist/auto/index.d.ts +22 -3
- package/dist/auto/index.js +41 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-BFC27F2G.js → chunk-IKQ5FODN.js} +2 -2
- package/dist/{chunk-JQMJQSXD.js → chunk-TFNRIZHU.js} +1614 -11
- package/dist/chunk-TFNRIZHU.js.map +1 -0
- package/dist/{chunk-XDWBVFHQ.js → chunk-XAKC2QY7.js} +1202 -3
- package/dist/chunk-XAKC2QY7.js.map +1 -0
- package/dist/css/components/calendar.css +159 -0
- package/dist/css/components/hover-card.css +14 -0
- package/dist/css/components/input-otp.css +10 -0
- package/dist/css/components/pagination.css +11 -0
- package/dist/css/components/quantity.css +16 -0
- package/dist/css/components/slot-picker.css +126 -0
- package/dist/css/components/studio-dock.css +35 -0
- package/dist/css/components/trailer.css +37 -0
- package/dist/css/controls.css +65 -0
- package/dist/elements/index.d.ts +72 -4
- package/dist/elements/index.js +170 -4
- package/dist/elements/index.js.map +1 -1
- package/dist/index.css +1017 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +45 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +105 -6
- package/dist/react/index.js +350 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-B98f8LPs.d.ts → reduced-BmBzKH94.d.ts} +199 -2
- package/dist/{reveal-C0hCDcB_.d.ts → reveal-jsX-uwUz.d.ts} +211 -1
- package/dist/{store-pHiUvxP8.d.ts → store-DYb3tTsZ.d.ts} +1 -1
- package/package.json +1 -1
- package/registry.json +178 -104
- package/skills/motion-kit/SKILL.md +209 -189
- package/src/auto/auto-init.ts +38 -0
- package/src/components/calendar/README.md +82 -0
- package/src/components/calendar/calendar.css +158 -0
- package/src/components/calendar/element.ts +27 -0
- package/src/components/calendar/engine.ts +412 -0
- package/src/components/calendar/react.tsx +69 -0
- package/src/components/hover-card/README.md +16 -0
- package/src/components/hover-card/element.ts +8 -0
- package/src/components/hover-card/engine.ts +69 -0
- package/src/components/hover-card/hover-card.css +13 -0
- package/src/components/hover-card/react.tsx +19 -0
- package/src/components/input-otp/README.md +18 -0
- package/src/components/input-otp/element.ts +14 -0
- package/src/components/input-otp/engine.ts +106 -0
- package/src/components/input-otp/input-otp.css +9 -0
- package/src/components/input-otp/react.tsx +21 -0
- package/src/components/pagination/README.md +17 -0
- package/src/components/pagination/element.ts +14 -0
- package/src/components/pagination/engine.ts +102 -0
- package/src/components/pagination/pagination.css +10 -0
- package/src/components/pagination/react.tsx +22 -0
- package/src/components/quantity/README.md +20 -0
- package/src/components/quantity/element.ts +14 -0
- package/src/components/quantity/engine.ts +85 -0
- package/src/components/quantity/quantity.css +15 -0
- package/src/components/quantity/react.tsx +21 -0
- package/src/components/slot-picker/README.md +68 -0
- package/src/components/slot-picker/element.ts +27 -0
- package/src/components/slot-picker/engine.ts +412 -0
- package/src/components/slot-picker/react.tsx +76 -0
- package/src/components/slot-picker/slot-picker.css +125 -0
- package/src/components/studio-dock/README.md +44 -0
- package/src/components/studio-dock/element.ts +11 -0
- package/src/components/studio-dock/engine.ts +212 -0
- package/src/components/studio-dock/react.tsx +23 -0
- package/src/components/studio-dock/studio-dock.css +34 -0
- package/src/components/trailer/README.md +28 -0
- package/src/components/trailer/element.ts +13 -0
- package/src/components/trailer/engine.ts +189 -0
- package/src/components/trailer/react.tsx +16 -0
- package/src/components/trailer/trailer.css +36 -0
- package/src/core/dates.ts +98 -0
- package/src/css/controls/README.md +14 -0
- package/src/css/controls/status.css +63 -0
- package/src/elements/index.ts +24 -0
- package/src/index.ts +25 -0
- package/src/react/index.ts +21 -0
- package/src/react/use-action-button.ts +6 -0
- package/src/react/use-editorial-reveal.ts +7 -0
- package/src/react/use-film-hero.ts +6 -0
- package/src/react/use-font-cycle.ts +7 -0
- package/src/react/use-image-zoom.ts +5 -0
- package/src/react/use-joined-cta.ts +5 -0
- package/src/react/use-local-clock.ts +5 -0
- package/src/react/use-scroll-dock.ts +5 -0
- package/src/react/use-sticker.ts +5 -0
- package/src/react/use-studio-dock.ts +8 -0
- package/src/react/use-thumbnail-nav.ts +7 -0
- package/src/react/use-trailer.ts +6 -0
- package/src/react/use-weight-hover.ts +5 -0
- package/src/recipes/action-button/README.md +31 -0
- package/src/recipes/action-button/action-button.css +113 -0
- package/src/recipes/action-button/create-action-button.ts +56 -0
- package/src/recipes/editorial-reveal/README.md +35 -0
- package/src/recipes/editorial-reveal/create-editorial-reveal.ts +91 -0
- package/src/recipes/editorial-reveal/editorial-reveal.css +17 -0
- package/src/recipes/film-hero/README.md +23 -0
- package/src/recipes/film-hero/create-film-hero.ts +65 -0
- package/src/recipes/film-hero/film-hero.css +29 -0
- package/src/recipes/font-cycle/README.md +11 -0
- package/src/recipes/font-cycle/create-font-cycle.ts +126 -0
- package/src/recipes/font-cycle/font-cycle.css +36 -0
- package/src/recipes/image-zoom/README.md +16 -0
- package/src/recipes/image-zoom/create-image-zoom.ts +27 -0
- package/src/recipes/image-zoom/image-zoom.css +7 -0
- package/src/recipes/joined-cta/README.md +13 -0
- package/src/recipes/joined-cta/create-joined-cta.ts +22 -0
- package/src/recipes/joined-cta/joined-cta.css +17 -0
- package/src/recipes/local-clock/README.md +31 -0
- package/src/recipes/local-clock/create-local-clock.ts +61 -0
- package/src/recipes/scroll-dock/README.md +46 -0
- package/src/recipes/scroll-dock/create-scroll-dock.ts +72 -0
- package/src/recipes/scroll-dock/scroll-dock.css +70 -0
- package/src/recipes/sticker/README.md +59 -0
- package/src/recipes/sticker/create-sticker.ts +490 -0
- package/src/recipes/sticker/sticker.css +112 -0
- package/src/recipes/thumbnail-nav/README.md +16 -0
- package/src/recipes/thumbnail-nav/create-thumbnail-nav.ts +165 -0
- package/src/recipes/thumbnail-nav/thumbnail-nav.css +108 -0
- package/src/recipes/weight-hover/README.md +37 -0
- package/src/recipes/weight-hover/create-weight-hover.ts +136 -0
- package/src/recipes/weight-hover/weight-hover.css +7 -0
- package/dist/chunk-JQMJQSXD.js.map +0 -1
- package/dist/chunk-XDWBVFHQ.js.map +0 -1
- /package/dist/{chunk-BFC27F2G.js.map → chunk-IKQ5FODN.js.map} +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/* slot-picker — Melon Mind's appointment picker on the --mk-* tokens; parts are .mk-slot-picker__*. */
|
|
2
|
+
.mk-slot-picker,
|
|
3
|
+
mk-slot-picker {
|
|
4
|
+
--mk-slot-picker-radius: var(--mk-radius-md);
|
|
5
|
+
display: grid;
|
|
6
|
+
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
border-radius: var(--mk-radius-xl);
|
|
9
|
+
background: var(--mk-surface);
|
|
10
|
+
color: var(--mk-text);
|
|
11
|
+
}
|
|
12
|
+
.mk-slot-picker__calendar { padding: var(--mk-space-5); }
|
|
13
|
+
.mk-slot-picker__panel {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
gap: var(--mk-space-3);
|
|
17
|
+
min-height: 100%;
|
|
18
|
+
padding: var(--mk-space-5);
|
|
19
|
+
background: var(--mk-surface-2);
|
|
20
|
+
}
|
|
21
|
+
.mk-slot-picker__month { display: flex; align-items: center; gap: var(--mk-space-2); margin-bottom: var(--mk-space-3); }
|
|
22
|
+
.mk-slot-picker__title { margin: 0 auto 0 0; font-size: 1.35rem; font-weight: 400; }
|
|
23
|
+
.mk-slot-picker__nav {
|
|
24
|
+
display: grid;
|
|
25
|
+
place-items: center;
|
|
26
|
+
width: 42px;
|
|
27
|
+
height: 42px;
|
|
28
|
+
border: 0;
|
|
29
|
+
border-radius: 50%;
|
|
30
|
+
background: var(--mk-surface-2);
|
|
31
|
+
color: var(--mk-text);
|
|
32
|
+
font: inherit;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
transition: transform 0.3s var(--mk-ease-nav-move), opacity 0.2s;
|
|
35
|
+
}
|
|
36
|
+
.mk-slot-picker__nav:active { transform: scale(0.9); }
|
|
37
|
+
.mk-slot-picker__nav:disabled { opacity: 0.35; cursor: default; }
|
|
38
|
+
[data-mk-slot-days],
|
|
39
|
+
[data-mk-slot-times] { position: relative; }
|
|
40
|
+
.mk-slot-picker__days { display: flex; flex-direction: column; gap: 6px; }
|
|
41
|
+
.mk-slot-picker__weekdays,
|
|
42
|
+
.mk-slot-picker__week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
|
|
43
|
+
.mk-slot-picker__weekdays span {
|
|
44
|
+
padding: 6px 0;
|
|
45
|
+
border-radius: var(--mk-radius-sm);
|
|
46
|
+
background: color-mix(in srgb, var(--mk-surface-2) 70%, transparent);
|
|
47
|
+
text-align: center;
|
|
48
|
+
font-size: 0.9rem;
|
|
49
|
+
}
|
|
50
|
+
.mk-slot-picker__week > [role='gridcell'] { display: grid; }
|
|
51
|
+
.mk-slot-picker__day {
|
|
52
|
+
aspect-ratio: 1.15;
|
|
53
|
+
border: 0;
|
|
54
|
+
border-radius: var(--mk-slot-picker-radius);
|
|
55
|
+
background: var(--mk-surface-3);
|
|
56
|
+
color: var(--mk-text);
|
|
57
|
+
font: inherit;
|
|
58
|
+
font-size: 0.95rem;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
transition: background 0.25s, color 0.25s, border-radius 0.3s var(--mk-ease-nav-radius), transform 0.3s var(--mk-ease-nav-radius);
|
|
61
|
+
}
|
|
62
|
+
.mk-slot-picker__day:hover:not(:disabled) { background: var(--mk-surface-5); border-radius: var(--mk-radius-full); }
|
|
63
|
+
.mk-slot-picker__day:active:not(:disabled) { transform: scale(0.92); }
|
|
64
|
+
.mk-slot-picker__day:disabled { opacity: 0.35; cursor: default; }
|
|
65
|
+
.mk-slot-picker__day[aria-current='date'] { box-shadow: inset 0 0 0 1.5px var(--mk-outline); }
|
|
66
|
+
[aria-selected='true'] > .mk-slot-picker__day,
|
|
67
|
+
[aria-selected='true'] > .mk-slot-picker__day:hover:not(:disabled) { background: var(--mk-accent); color: var(--mk-on-accent); }
|
|
68
|
+
:is(.mk-slot-picker__day, .mk-slot-picker__time, .mk-slot-picker__nav):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 2px; }
|
|
69
|
+
|
|
70
|
+
.mk-slot-picker__heading {
|
|
71
|
+
margin: 0;
|
|
72
|
+
padding: 8px;
|
|
73
|
+
border-radius: var(--mk-radius-md);
|
|
74
|
+
background: var(--mk-surface);
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
}
|
|
77
|
+
.mk-slot-picker__heading:not([data-mk-shown]) { display: none; }
|
|
78
|
+
.mk-slot-picker__heading[data-mk-shown] { animation: mk-slot-heading-in 0.5s cubic-bezier(0.38, 0.49, 0, 1.4); }
|
|
79
|
+
@keyframes mk-slot-heading-in {
|
|
80
|
+
0% { max-height: 0; min-height: 0; padding-block: 0; opacity: 0; }
|
|
81
|
+
100% { max-height: 56px; min-height: 40px; opacity: 1; }
|
|
82
|
+
}
|
|
83
|
+
.mk-slot-picker__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: 8px; align-content: start; }
|
|
84
|
+
.mk-slot-picker__leaving { position: absolute; left: 0; right: 0; pointer-events: none; }
|
|
85
|
+
.mk-slot-picker__note { grid-column: 1 / -1; margin: 2rem 0; color: var(--mk-muted); text-align: center; }
|
|
86
|
+
.mk-slot-picker__time {
|
|
87
|
+
position: relative;
|
|
88
|
+
isolation: isolate;
|
|
89
|
+
height: 44px;
|
|
90
|
+
border: 0;
|
|
91
|
+
border-radius: var(--mk-radius-sm);
|
|
92
|
+
background: var(--mk-surface-4);
|
|
93
|
+
color: var(--mk-text);
|
|
94
|
+
font: inherit;
|
|
95
|
+
font-variant-numeric: tabular-nums;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
transition: background 0.2s, color 0.2s, transform 0.3s var(--mk-ease-nav-move);
|
|
98
|
+
}
|
|
99
|
+
.mk-slot-picker__time:hover { transform: translateY(-2px); }
|
|
100
|
+
.mk-slot-picker__time[aria-checked='true'] { color: var(--mk-on-accent); }
|
|
101
|
+
.mk-slot-picker__time[aria-checked='true']::before {
|
|
102
|
+
content: '';
|
|
103
|
+
position: absolute;
|
|
104
|
+
inset: 0;
|
|
105
|
+
z-index: -1;
|
|
106
|
+
border-radius: inherit;
|
|
107
|
+
background: var(--mk-accent);
|
|
108
|
+
animation: mk-slot-highlight 0.7s cubic-bezier(0.38, 0.49, 0, 1.1);
|
|
109
|
+
}
|
|
110
|
+
@keyframes mk-slot-highlight {
|
|
111
|
+
0% { transform: scale(0.8); opacity: 0; filter: blur(16px); box-shadow: 0 0 0 1px var(--mk-surface-2); }
|
|
112
|
+
100% { transform: scale(1); opacity: 1; filter: blur(0); }
|
|
113
|
+
}
|
|
114
|
+
.mk-slot-picker__actions { margin-top: auto; display: flex; justify-content: flex-end; }
|
|
115
|
+
|
|
116
|
+
@media (max-width: 680px) {
|
|
117
|
+
.mk-slot-picker,
|
|
118
|
+
mk-slot-picker { grid-template-columns: 1fr; }
|
|
119
|
+
.mk-slot-picker__calendar,
|
|
120
|
+
.mk-slot-picker__panel { padding: var(--mk-space-4); }
|
|
121
|
+
}
|
|
122
|
+
@media (prefers-reduced-motion: reduce) {
|
|
123
|
+
.mk-slot-picker *,
|
|
124
|
+
mk-slot-picker * { transition: none !important; animation: none !important; }
|
|
125
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# studio-dock
|
|
2
|
+
|
|
3
|
+
Navegación de estudio que pasa de una píldora a un panel de tarjetas. Inspirada en el comportamiento público de Mubien; código y gráficos propios, sin sus fuentes, marcas o videos.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<div class="mk-studio-dock" data-mk="studio-dock">
|
|
7
|
+
<div class="mk-studio-dock__bar">
|
|
8
|
+
<a href="/" class="mk-studio-dock__brand">ST</a>
|
|
9
|
+
<span class="mk-studio-dock__label">Estudio</span>
|
|
10
|
+
<button type="button" class="mk-studio-dock__toggle" data-mk-studio-toggle aria-label="Abrir navegación"><span aria-hidden="true">+</span></button>
|
|
11
|
+
</div>
|
|
12
|
+
<section class="mk-studio-dock__panel" data-mk-studio-panel hidden>
|
|
13
|
+
<div data-mk-studio-view="menu">
|
|
14
|
+
<div class="mk-studio-dock__grid">
|
|
15
|
+
<a href="/work" class="mk-studio-dock__tile">Proyectos</a>
|
|
16
|
+
<a href="/about" class="mk-studio-dock__tile">Estudio</a>
|
|
17
|
+
</div>
|
|
18
|
+
<button type="button" data-mk-studio-slide>Contacto</button>
|
|
19
|
+
</div>
|
|
20
|
+
<div data-mk-studio-view="contact" hidden>
|
|
21
|
+
<h2>Conversemos</h2><a href="mailto:hello@example.com">hello@example.com</a>
|
|
22
|
+
<button type="button" data-mk-studio-to="menu">Volver</button>
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
</div>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createStudioDock } from '@yewdiel21/motion-kit';
|
|
30
|
+
import { useStudioDock, StudioDock } from '@yewdiel21/motion-kit/react';
|
|
31
|
+
import '@yewdiel21/motion-kit/css';
|
|
32
|
+
const dock = createStudioDock(root, { duration: 1.3, ease: 'back.out(0.52)' });
|
|
33
|
+
dock.open('contact'); dock.selectPage(1); dock.close(); dock.destroy();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`StudioDock` es un shell React con los mismos slots y `instanceRef`; `useStudioDock(ref, options, deps)` permite usar tu markup. Web component: `defineStudioDock()` registra `<mk-studio-dock>`. El evento `mk:studio-dock` y `onChange` entregan `{ open, view, page }`.
|
|
37
|
+
|
|
38
|
+
Slots opcionales: `[data-mk-studio-to="menu|contact|reel"]`, `[data-mk-studio-close]`, `[data-mk-studio-pages]`, `[data-mk-studio-page]`, botones `[data-mk-studio-page-to="0"]` y `<time data-mk-studio-timezone="America/Mazatlan">`. Las páginas se ocultan con `hidden`; el paginado tiene flechas izquierda/derecha y botones numerados. Los relojes usan `Intl` y actualizan cada minuto; no consultan ningún servidor.
|
|
39
|
+
|
|
40
|
+
El botón de contacto funciona con clic, Enter y Espacio. Dentro de `.mk-studio-dock__slide-track` también acepta arrastre y abre al superar 70 % del recorrido. Las vistas reciben foco, Escape cierra, clic fuera cierra sin robar foco al destino y el cierre explícito devuelve el foco anterior. `trapFocus` y `focusOnOpen` son `true` por defecto y se pueden desactivar. El panel es un diálogo contenido, sin bloqueo del body ni cambios en otros componentes. `aria-expanded` refleja el estado y el panel cerrado queda fuera del árbol de accesibilidad.
|
|
41
|
+
|
|
42
|
+
El panel se revela/colapsa por `clip-path` y desplazamiento de 16 px; duración configurable de 1.3 s, rebote `.52`. Con movimiento reducido se salta el recorrido. `destroy()` es idempotente: revierte estilos/atributos, listeners, temporizadores y animaciones. Los videos aportados por el consumidor se pausan al salir de su vista. No se envía ningún formulario desde este motor.
|
|
43
|
+
|
|
44
|
+
Por defecto permanece dentro del layout. Usa `.mk-studio-dock--fixed` o `<StudioDock fixed>` para fijarlo abajo de la ventana. Al abrirse, el panel mide el espacio que queda en la ventana: su alto se limita a ese hueco (con scroll interno) y, si arriba no cabe y abajo hay más sitio, se abre bajo la barra (`data-mk-studio-below`). `--mk-studio-dock-offset` (76 px) es la distancia del panel al borde del dock. Variables de color: `--mk-surface`, `--mk-surface-2`, `--mk-accent`, `--mk-on-accent`, `--mk-outline-soft`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createStudioDock, type StudioDockInstance, type StudioDockView } from './engine';
|
|
3
|
+
export class MkStudioDockElement extends MkElement<StudioDockInstance> {
|
|
4
|
+
static options: Record<string, OptionType> = { duration: 'number', ease: 'string', focusOnOpen: 'boolean', trapFocus: 'boolean' };
|
|
5
|
+
protected create(options: Record<string, unknown>) { return createStudioDock(this, options); }
|
|
6
|
+
open(view?: StudioDockView) { this.instance?.open(view); }
|
|
7
|
+
close() { this.instance?.close(); }
|
|
8
|
+
toggle() { this.instance?.toggle(); }
|
|
9
|
+
selectPage(index: number) { this.instance?.selectPage(index); }
|
|
10
|
+
}
|
|
11
|
+
export const defineStudioDock = () => defineElement('mk-studio-dock', MkStudioDockElement);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { 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
|
+
|
|
6
|
+
export type StudioDockView = 'menu' | 'contact' | 'reel';
|
|
7
|
+
export interface StudioDockState { open: boolean; view: StudioDockView; page: number; }
|
|
8
|
+
export interface StudioDockOptions {
|
|
9
|
+
/** Seconds. Reference sheet: 1.3 s, outBack(.52). */
|
|
10
|
+
duration?: number;
|
|
11
|
+
ease?: string;
|
|
12
|
+
/** Move focus into a newly opened view. Defaults to true. */
|
|
13
|
+
focusOnOpen?: boolean;
|
|
14
|
+
/** Contain Tab within the open panel and its toggle. Defaults to true. */
|
|
15
|
+
trapFocus?: boolean;
|
|
16
|
+
onChange?: (state: StudioDockState) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface StudioDockInstance extends Instance {
|
|
19
|
+
open(view?: StudioDockView): void;
|
|
20
|
+
close(): void;
|
|
21
|
+
toggle(): void;
|
|
22
|
+
selectPage(index: number): void;
|
|
23
|
+
readonly state: StudioDockState;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let nextId = 0;
|
|
27
|
+
/** Panel distance from the dock's edge (bar + gap); --mk-studio-dock-offset overrides it. */
|
|
28
|
+
const PANEL_OFFSET = 76;
|
|
29
|
+
const VIEWPORT_GAP = 12;
|
|
30
|
+
const MIN_SPACE = 320;
|
|
31
|
+
const FOCUSABLE = 'a[href],button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),video[controls],[tabindex]:not([tabindex="-1"])';
|
|
32
|
+
|
|
33
|
+
/** A contained studio sheet. Markup remains useful without JavaScript; fixed positioning is CSS opt-in. */
|
|
34
|
+
export function createStudioDock(root: HTMLElement | null, options: StudioDockOptions = {}): StudioDockInstance {
|
|
35
|
+
const toggleButton = root?.querySelector<HTMLButtonElement>('[data-mk-studio-toggle]');
|
|
36
|
+
const panel = root?.querySelector<HTMLElement>('[data-mk-studio-panel]');
|
|
37
|
+
if (!root || !toggleButton || !panel) return inert({ open() {}, close() {}, toggle() {}, selectPage() {}, state: { open: false, view: 'menu' as const, page: 0 } });
|
|
38
|
+
registerMotion();
|
|
39
|
+
const scope = createScope();
|
|
40
|
+
const context = gsap.context(() => {}, root);
|
|
41
|
+
scope.add(() => context.revert());
|
|
42
|
+
const views = [...panel.querySelectorAll<HTMLElement>('[data-mk-studio-view]')];
|
|
43
|
+
const pages = [...panel.querySelectorAll<HTMLElement>('[data-mk-studio-page]')];
|
|
44
|
+
const pageButtons = [...root.querySelectorAll<HTMLElement>('[data-mk-studio-page-to]')];
|
|
45
|
+
const slide = root.querySelector<HTMLElement>('[data-mk-studio-slide]');
|
|
46
|
+
const tracked = [root, panel, ...(slide ? [slide] : [])];
|
|
47
|
+
tracked.forEach(el => { const style = el.getAttribute('style'); scope.add(() => style === null ? el.removeAttribute('style') : el.setAttribute('style', style)); });
|
|
48
|
+
scope.attr(panel, 'id', panel.id || `mk-studio-panel-${++nextId}`);
|
|
49
|
+
scope.attr(panel, 'role', panel.getAttribute('role') || 'dialog');
|
|
50
|
+
scope.attr(panel, 'aria-label', panel.getAttribute('aria-label') || 'Studio navigation');
|
|
51
|
+
scope.attr(panel, 'tabindex', '-1');
|
|
52
|
+
scope.attr(toggleButton, 'aria-controls', panel.id);
|
|
53
|
+
scope.attr(toggleButton, 'aria-expanded', 'false');
|
|
54
|
+
scope.attr(panel, 'hidden', '');
|
|
55
|
+
scope.attr(panel, 'aria-hidden', 'true');
|
|
56
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
57
|
+
let state: StudioDockState = { open: false, view: 'menu', page: 0 };
|
|
58
|
+
let previousFocus: HTMLElement | null = null;
|
|
59
|
+
let animation: gsap.core.Tween | undefined;
|
|
60
|
+
let slideStart: number | null = null;
|
|
61
|
+
let slideDistance = 0;
|
|
62
|
+
let slideMoved = false;
|
|
63
|
+
let suppressSlideClick = false;
|
|
64
|
+
|
|
65
|
+
// The sheet opens above the bar. Cap it to the room actually left in the viewport
|
|
66
|
+
// (it scrolls inside) and open it below the bar when above is too tight.
|
|
67
|
+
const place = (decide = false) => {
|
|
68
|
+
const view = root.ownerDocument.defaultView;
|
|
69
|
+
if (!state.open || !view) return;
|
|
70
|
+
const rect = root.getBoundingClientRect();
|
|
71
|
+
const offset = parseFloat(view.getComputedStyle(root).getPropertyValue('--mk-studio-dock-offset')) || PANEL_OFFSET;
|
|
72
|
+
const above = rect.bottom - offset - VIEWPORT_GAP;
|
|
73
|
+
const below = view.innerHeight - rect.top - offset - VIEWPORT_GAP;
|
|
74
|
+
// The direction is chosen on open only, so scrolling never flips an open sheet.
|
|
75
|
+
if (decide) scope.attr(root, 'data-mk-studio-below', above < Math.min(panel.scrollHeight || MIN_SPACE, MIN_SPACE) && below > above ? '' : null);
|
|
76
|
+
const flip = root.hasAttribute('data-mk-studio-below');
|
|
77
|
+
root.style.setProperty('--mk-studio-dock-space', `${Math.max(MIN_SPACE / 2, Math.floor(flip ? below : above))}px`);
|
|
78
|
+
};
|
|
79
|
+
const hidden = () => root.hasAttribute('data-mk-studio-below')
|
|
80
|
+
? { clipPath: 'inset(0% 0% 100% 0% round 18px)', y: -16, opacity: 0 }
|
|
81
|
+
: { clipPath: 'inset(100% 0% 0% 0% round 18px)', y: 16, opacity: 0 };
|
|
82
|
+
const notify = () => {
|
|
83
|
+
const value = { ...state };
|
|
84
|
+
options.onChange?.(value);
|
|
85
|
+
root.dispatchEvent(new CustomEvent('mk:studio-dock', { bubbles: true, detail: value }));
|
|
86
|
+
};
|
|
87
|
+
const isTabbable = (el: HTMLElement) => el.tabIndex >= 0 && !el.closest('[hidden], [inert]') && el.getAttribute('aria-disabled') !== 'true';
|
|
88
|
+
const visibleFocus = () => [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabbable);
|
|
89
|
+
const focusView = () => { if (options.focusOnOpen !== false) (visibleFocus()[0] ?? panel).focus({ preventScroll: true }); };
|
|
90
|
+
const setView = (view: StudioDockView) => {
|
|
91
|
+
state.view = views.length && !views.some(el => el.dataset.mkStudioView === view) ? 'menu' : view;
|
|
92
|
+
views.forEach(el => scope.attr(el, 'hidden', el.dataset.mkStudioView === state.view ? null : ''));
|
|
93
|
+
scope.attr(root, 'data-mk-studio-active', state.view);
|
|
94
|
+
panel.querySelectorAll<HTMLVideoElement>('video').forEach(video => { if (state.view !== 'reel') video.pause(); });
|
|
95
|
+
};
|
|
96
|
+
const selectPage = (index: number, announce = true) => {
|
|
97
|
+
if (scope.disposed || !pages.length || !Number.isFinite(index)) return;
|
|
98
|
+
state.page = ((Math.round(index) % pages.length) + pages.length) % pages.length;
|
|
99
|
+
pages.forEach((el, i) => scope.attr(el, 'hidden', i === state.page ? null : ''));
|
|
100
|
+
pageButtons.forEach(el => scope.attr(el, 'aria-current', Number(el.dataset.mkStudioPageTo) === state.page ? 'true' : null));
|
|
101
|
+
if (announce) notify();
|
|
102
|
+
};
|
|
103
|
+
const open = (view: StudioDockView = 'menu') => {
|
|
104
|
+
if (scope.disposed) return;
|
|
105
|
+
const wasOpen = state.open;
|
|
106
|
+
if (wasOpen && state.view === view) return;
|
|
107
|
+
if (!wasOpen) {
|
|
108
|
+
const active = root.ownerDocument.activeElement;
|
|
109
|
+
previousFocus = active instanceof HTMLElement && active !== root.ownerDocument.body && !panel.contains(active) ? active : toggleButton;
|
|
110
|
+
}
|
|
111
|
+
animation?.kill();
|
|
112
|
+
state.open = true;
|
|
113
|
+
scope.attr(panel, 'hidden', null);
|
|
114
|
+
scope.attr(panel, 'inert', null);
|
|
115
|
+
scope.attr(panel, 'aria-hidden', 'false');
|
|
116
|
+
scope.attr(root, 'data-mk-studio-open', '');
|
|
117
|
+
scope.attr(toggleButton, 'aria-expanded', 'true');
|
|
118
|
+
setView(view);
|
|
119
|
+
place(!wasOpen);
|
|
120
|
+
if (!reduced()) context.add(() => {
|
|
121
|
+
animation = gsap.fromTo(panel, hidden(), { clipPath: 'inset(0% 0% 0% 0% round 18px)', y: 0, opacity: 1, duration: Math.max(0, options.duration ?? 1.3), ease: options.ease ?? 'back.out(0.52)', overwrite: true });
|
|
122
|
+
});
|
|
123
|
+
else context.add(() => { gsap.set(panel, { clipPath: 'inset(0% round 18px)', y: 0, opacity: 1 }); });
|
|
124
|
+
focusView();
|
|
125
|
+
notify();
|
|
126
|
+
};
|
|
127
|
+
const close = (restoreFocus = true) => {
|
|
128
|
+
if (scope.disposed || !state.open) return;
|
|
129
|
+
animation?.kill();
|
|
130
|
+
state.open = false;
|
|
131
|
+
scope.attr(toggleButton, 'aria-expanded', 'false');
|
|
132
|
+
scope.attr(root, 'data-mk-studio-open', null);
|
|
133
|
+
// Stop focus immediately while the visual sheet collapses.
|
|
134
|
+
scope.attr(panel, 'inert', '');
|
|
135
|
+
scope.attr(panel, 'aria-hidden', 'true');
|
|
136
|
+
if (reduced()) scope.attr(panel, 'hidden', '');
|
|
137
|
+
else context.add(() => {
|
|
138
|
+
animation = gsap.to(panel, { ...hidden(), duration: Math.max(0, options.duration ?? 1.3), ease: options.ease ?? 'back.out(0.52)', overwrite: true, onComplete: () => { if (!scope.disposed && !state.open) scope.attr(panel, 'hidden', ''); } });
|
|
139
|
+
});
|
|
140
|
+
panel.querySelectorAll<HTMLVideoElement>('video').forEach(video => video.pause());
|
|
141
|
+
if (restoreFocus) (previousFocus?.isConnected ? previousFocus : toggleButton).focus({ preventScroll: true });
|
|
142
|
+
notify();
|
|
143
|
+
};
|
|
144
|
+
scope.on(root, 'click', event => {
|
|
145
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
146
|
+
if (!target) return;
|
|
147
|
+
if (target.closest('[data-mk-studio-toggle]') === toggleButton) state.open ? close() : open();
|
|
148
|
+
else if (target.closest('[data-mk-studio-close]')) close();
|
|
149
|
+
else if (target.closest('[data-mk-studio-slide]')) {
|
|
150
|
+
if (suppressSlideClick) { event.preventDefault(); suppressSlideClick = false; }
|
|
151
|
+
else open('contact');
|
|
152
|
+
} else {
|
|
153
|
+
const view = target.closest<HTMLElement>('[data-mk-studio-to]')?.dataset.mkStudioTo;
|
|
154
|
+
if (view === 'menu' || view === 'contact' || view === 'reel') open(view);
|
|
155
|
+
const page = target.closest<HTMLElement>('[data-mk-studio-page-to]');
|
|
156
|
+
if (page) selectPage(Number(page.dataset.mkStudioPageTo));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (root.ownerDocument.defaultView) scope.on(root.ownerDocument.defaultView, 'resize', () => place());
|
|
160
|
+
scope.on(root.ownerDocument, 'scroll', () => place(), { passive: true, capture: true });
|
|
161
|
+
scope.on(root.ownerDocument, 'pointerdown', event => {
|
|
162
|
+
if (state.open && event.target instanceof Node && !root.contains(event.target)) close(false);
|
|
163
|
+
});
|
|
164
|
+
scope.on(root.ownerDocument, 'keydown', event => {
|
|
165
|
+
if (!state.open || event.defaultPrevented) return;
|
|
166
|
+
if (event.key === 'Escape') { event.preventDefault(); close(); return; }
|
|
167
|
+
if (!(event.target instanceof Node) || !root.contains(event.target)) return;
|
|
168
|
+
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && (event.target as Element).closest('[data-mk-studio-pages]')) {
|
|
169
|
+
event.preventDefault(); selectPage(state.page + (event.key === 'ArrowRight' ? 1 : -1));
|
|
170
|
+
pageButtons.find(el => Number(el.dataset.mkStudioPageTo) === state.page)?.focus();
|
|
171
|
+
}
|
|
172
|
+
if (event.key === 'Tab' && options.trapFocus !== false) {
|
|
173
|
+
// querySelectorAll keeps the authored DOM order, including a toggle before OR after the panel.
|
|
174
|
+
const elements = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(el => (el === toggleButton || panel.contains(el)) && isTabbable(el));
|
|
175
|
+
if (!elements.length) { event.preventDefault(); panel.focus(); return; }
|
|
176
|
+
const first = elements[0]!, last = elements.at(-1)!;
|
|
177
|
+
if (event.shiftKey && (root.ownerDocument.activeElement === first || root.ownerDocument.activeElement === panel)) { event.preventDefault(); last.focus(); }
|
|
178
|
+
else if (!event.shiftKey && root.ownerDocument.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
if (slide) {
|
|
182
|
+
scope.on(slide, 'pointerdown', event => { if (event.button !== 0) return; slideStart = event.clientX; slideDistance = 0; slideMoved = false; });
|
|
183
|
+
scope.on(root.ownerDocument, 'pointermove', event => {
|
|
184
|
+
if (slideStart === null) return;
|
|
185
|
+
const width = Math.max(0, (slide.parentElement?.clientWidth ?? 0) - slide.offsetWidth);
|
|
186
|
+
slideMoved ||= Math.abs(event.clientX - slideStart) > 5;
|
|
187
|
+
slideDistance = Math.max(0, Math.min(width, event.clientX - slideStart));
|
|
188
|
+
if (!reduced()) slide.style.setProperty('--mk-studio-drag', `${slideDistance}px`);
|
|
189
|
+
});
|
|
190
|
+
const release = (cancel = false) => {
|
|
191
|
+
if (slideStart === null) return;
|
|
192
|
+
const width = Math.max(0, (slide.parentElement?.clientWidth ?? 0) - slide.offsetWidth);
|
|
193
|
+
suppressSlideClick = slideMoved;
|
|
194
|
+
slideStart = null;
|
|
195
|
+
slide.style.removeProperty('--mk-studio-drag');
|
|
196
|
+
if (!cancel && width > 0 && slideDistance / width >= .7) open('contact');
|
|
197
|
+
scope.timeout(() => { suppressSlideClick = false; }, 150);
|
|
198
|
+
};
|
|
199
|
+
scope.on(root.ownerDocument, 'pointerup', () => release());
|
|
200
|
+
scope.on(root.ownerDocument, 'pointercancel', () => release(true));
|
|
201
|
+
}
|
|
202
|
+
const clocks = [...root.querySelectorAll<HTMLElement>('[data-mk-studio-timezone]')];
|
|
203
|
+
const clockOriginals = clocks.map(el => ({ el, text: el.textContent, style: el.getAttribute('style') }));
|
|
204
|
+
const tick = () => clocks.forEach(el => {
|
|
205
|
+
try { el.textContent = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', timeZone: el.dataset.mkStudioTimezone }).format(new Date()); } catch { /* Invalid caller time zone leaves the original label intact. */ }
|
|
206
|
+
});
|
|
207
|
+
if (clocks.length) { tick(); const timer = setInterval(tick, 60_000); scope.add(() => clearInterval(timer)); }
|
|
208
|
+
scope.add(() => clockOriginals.forEach(({ el, text, style }) => { el.textContent = text; if (style === null) el.removeAttribute('style'); else el.setAttribute('style', style); }));
|
|
209
|
+
setView('menu');
|
|
210
|
+
selectPage(0, false);
|
|
211
|
+
return { open, close, toggle: () => state.open ? close() : open(), selectPage, get state() { return { ...state }; }, destroy() { if (scope.disposed) return; const focused = root.contains(root.ownerDocument.activeElement); animation?.kill(); panel.querySelectorAll<HTMLVideoElement>('video').forEach(video => video.pause()); scope.dispose(); if (focused) toggleButton.focus({ preventScroll: true }); } };
|
|
212
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createStudioDock, type StudioDockInstance, type StudioDockOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface StudioDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, StudioDockOptions {
|
|
7
|
+
/** Default stays inside its positioned demo/app panel. */
|
|
8
|
+
fixed?: boolean;
|
|
9
|
+
instanceRef?: { current: StudioDockInstance | null };
|
|
10
|
+
}
|
|
11
|
+
/** Composable light-DOM shell; use the data-mk-studio-* slots shown in the README. */
|
|
12
|
+
export function StudioDock({ duration, ease, focusOnOpen, trapFocus, onChange, fixed = false, instanceRef, className, children, ...props }: StudioDockProps) {
|
|
13
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
14
|
+
const change = useLatest(onChange);
|
|
15
|
+
useIsomorphicLayoutEffect(() => {
|
|
16
|
+
const instance = createStudioDock(ref.current, { duration, ease, focusOnOpen, trapFocus, onChange: state => change.current?.(state) });
|
|
17
|
+
if (instanceRef) instanceRef.current = instance;
|
|
18
|
+
return () => { instance.destroy(); if (instanceRef) instanceRef.current = null; };
|
|
19
|
+
}, [duration, ease, focusOnOpen, trapFocus]);
|
|
20
|
+
return <div {...props} ref={ref} className={['mk-studio-dock', fixed && 'mk-studio-dock--fixed', className].filter(Boolean).join(' ')}>{children}</div>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { useStudioDock } from '../../react/use-studio-dock';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
.mk-studio-dock { position: relative; isolation: isolate; width: min(343px, 100%); color: var(--mk-text); font: inherit; }
|
|
2
|
+
.mk-studio-dock--fixed { position: fixed; z-index: 70; inset-block-end: 24px; inset-inline-start: 50%; translate: -50% 0; width: min(343px, calc(100vw - 32px)); }
|
|
3
|
+
.mk-studio-dock__bar { display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 12px; border-radius: 18px; background: var(--mk-surface-2); }
|
|
4
|
+
.mk-studio-dock__brand, .mk-studio-dock__toggle { display: grid; place-items: center; inline-size: 40px; block-size: 40px; flex: none; border: 0; border-radius: 10px; background: var(--mk-surface); color: inherit; text-decoration: none; font: inherit; }
|
|
5
|
+
.mk-studio-dock__label { flex: 1; font-size: .9rem; }
|
|
6
|
+
.mk-studio-dock__toggle { cursor: pointer; }
|
|
7
|
+
.mk-studio-dock__toggle span { transition: transform .4s; }
|
|
8
|
+
.mk-studio-dock[data-mk-studio-open] .mk-studio-dock__toggle span { transform: rotate(45deg); }
|
|
9
|
+
.mk-studio-dock__panel { position: absolute; z-index: 2; inset-inline: 0; inset-block-end: var(--mk-studio-dock-offset, 76px); max-height: min(660px, 75dvh, var(--mk-studio-dock-space, 660px)); overflow: auto; overscroll-behavior: contain; padding: 12px; border: 1px solid var(--mk-outline-soft); border-radius: 18px; background: var(--mk-surface-2); box-shadow: 0 16px 60px #0002; }
|
|
10
|
+
/* Not enough room above the bar: the engine opens the sheet below it. */
|
|
11
|
+
.mk-studio-dock[data-mk-studio-below] .mk-studio-dock__panel { inset-block: var(--mk-studio-dock-offset, 76px) auto; }
|
|
12
|
+
.mk-studio-dock [hidden] { display: none !important; }
|
|
13
|
+
.mk-studio-dock__grid, .mk-studio-dock__page { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
14
|
+
.mk-studio-dock__tile { min-height: 130px; display: flex; flex-direction: column; justify-content: flex-end; gap: 4px; padding: 12px; border-radius: 10px; background: var(--mk-surface); color: inherit; text-decoration: none; transition: transform .4s cubic-bezier(.2, 1.4, .4, 1); }
|
|
15
|
+
.mk-studio-dock__tile small { color: var(--mk-muted); }
|
|
16
|
+
.mk-studio-dock__tile:is(:hover, :focus-visible) { transform: scale(.94); }
|
|
17
|
+
.mk-studio-dock__reel { display: flex; justify-content: space-between; align-items: end; min-height: 154px; margin-block: 12px; padding: 16px; border-radius: 10px; background: var(--mk-accent); color: var(--mk-on-accent); }
|
|
18
|
+
.mk-studio-dock__reel button { border: 1px solid currentColor; border-radius: 50%; padding: 14px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
|
19
|
+
.mk-studio-dock__slide-track { position: relative; display: flex; align-items: center; height: 64px; padding: 4px; margin-block: 12px; border-radius: 48px; background: var(--mk-surface-3, var(--mk-surface)); }
|
|
20
|
+
.mk-studio-dock__slide-track > span { position: absolute; inset-inline: 60px 12px; text-align: center; font-size: .75rem; pointer-events: none; }
|
|
21
|
+
.mk-studio-dock [data-mk-studio-slide] { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border: 0; border-radius: 50%; background: var(--mk-surface); color: inherit; font-size: 20px; cursor: pointer; touch-action: pan-y; transform: translateX(var(--mk-studio-drag, 0px)); }
|
|
22
|
+
.mk-studio-dock__clock { position: relative; aspect-ratio: 1; display: grid; place-content: center; gap: 4px; text-align: center; border: 10px solid var(--mk-surface); border-radius: 50%; background: var(--mk-surface-2); }
|
|
23
|
+
.mk-studio-dock__clock::after { content: ''; position: absolute; inset: 8px; border: 1px solid var(--mk-outline-soft); border-radius: 50%; }
|
|
24
|
+
.mk-studio-dock__clock small { color: var(--mk-muted); }
|
|
25
|
+
.mk-studio-dock__pages { margin-block-start: 12px; }
|
|
26
|
+
.mk-studio-dock__pagination { display: flex; justify-content: center; gap: 8px; margin-block-start: 12px; }
|
|
27
|
+
.mk-studio-dock__pagination button { width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; background: var(--mk-surface); color: inherit; font: inherit; cursor: pointer; }
|
|
28
|
+
.mk-studio-dock__pagination [aria-current] { background: var(--mk-accent); color: var(--mk-on-accent); }
|
|
29
|
+
.mk-studio-dock__view { display: grid; gap: 16px; padding: 12px 0; }
|
|
30
|
+
.mk-studio-dock__view input, .mk-studio-dock__view textarea { width: 100%; padding: 12px; border: 1px solid var(--mk-outline-soft); border-radius: 8px; background: var(--mk-surface); color: inherit; font: inherit; }
|
|
31
|
+
.mk-studio-dock__view label { display: grid; gap: 6px; }
|
|
32
|
+
.mk-studio-dock__view video { width: 100%; border-radius: 10px; }
|
|
33
|
+
.mk-studio-dock :is(button,a,input,textarea):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
|
|
34
|
+
@media (prefers-reduced-motion: reduce) { .mk-studio-dock *, .mk-studio-dock *::after { transition: none !important; animation: none !important; } }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# trailer
|
|
2
|
+
|
|
3
|
+
Modal de video que compone el motor `dialog`: top layer, bloqueo del fondo, Escape y retorno de foco. Medios escritos por el consumidor; no inyecta YouTube, rastreadores ni videos remotos.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<button data-mk-open="preview">Ver trailer</button>
|
|
7
|
+
<dialog class="mk-trailer" id="preview">
|
|
8
|
+
<header class="mk-trailer__header">
|
|
9
|
+
<h2 data-mk-part="title">Trailer</h2>
|
|
10
|
+
<button data-mk-part="close">Cerrar</button>
|
|
11
|
+
</header>
|
|
12
|
+
<video controls playsinline preload="metadata" src="/trailer.webm">
|
|
13
|
+
<track kind="captions" src="/captions.vtt" srclang="es" label="Español" />
|
|
14
|
+
</video>
|
|
15
|
+
</dialog>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`createTrailer(dialog, { triggers:true, autoplay:false, closeOnBackdrop:true, onOpen, onClose })` devuelve `open(origin?)`, `close()`, `toggle(origin?)`, `play():Promise<boolean>`, `pause()`, `seek(seconds)`, `setVolume(0..1)`, `isOpen`, `destroy()`.
|
|
19
|
+
|
|
20
|
+
React: `<Trailer id="preview" options={…}>…</Trailer>` o `useTrailer(ref, options, deps?)` sobre un `<dialog>`. Custom element: `<mk-trailer><dialog id="preview">…</dialog></mk-trailer>`, `defineTrailer()`. La estructura permanece en light DOM. Los callbacks/options de los hooks se leen al crear; usar `deps` cuando cambien.
|
|
21
|
+
|
|
22
|
+
Controles opcionales: botones `data-mk-trailer-play`, `mute`, `fullscreen`, `captions`; inputs range `data-mk-trailer-seek` y `volume`; output `data-mk-trailer-time`; párrafo `data-mk-trailer-status`. Sólo oculta los controles nativos si existen Play/Mute/Seek y, cuando hay tracks, un botón CC. `aria-label`, `aria-pressed`, duración, posición y mensajes de error se sincronizan. Los sliders conservan teclado nativo; fuera de ellos Space/K alterna, flechas buscan±5s y M cambia mute. Los subtítulos usan el primer track. Pantalla completa depende del soporte del navegador y de un gesto del usuario.
|
|
23
|
+
|
|
24
|
+
Autoplay está desactivado. Cerrar o destruir pausa el video y cancela solicitudes de play pendientes. `play()` absorbe rechazos del navegador y anuncia el error. `destroy()` devuelve los controles/atributos originales y retira listeners. La fuente puede fallar sin bloquear el cierre.
|
|
25
|
+
|
|
26
|
+
Pantalla completa se solicita sobre `[data-mk-trailer-stage]` (un wrapper normal alrededor de video y controles), o sobre el video si falta ese wrapper. Nunca sobre `<dialog>`, que la API no admite. Cerrar/destruir sale de la pantalla completa que pertenece a esta instancia.
|
|
27
|
+
|
|
28
|
+
El modal usa el morph accesible existente de motion-kit. **No copia** la apertura por bandas ni la disolución procedural de A24; el modelo de interacción y los controles se reconstruyeron para ser reutilizables. La demo usa SVG, captions y WebM silencioso originales de 6 s, sin assets de las referencias.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createTrailer, type TrailerInstance, type TrailerOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
/** Wrap a consumer-authored native <dialog> containing <video>. */
|
|
5
|
+
export class MkTrailerElement extends MkElement<TrailerInstance> {
|
|
6
|
+
static options: Record<string, OptionType> = { autoplay: 'boolean', triggers: 'string', closeOnBackdrop: 'boolean' };
|
|
7
|
+
protected create(options: Record<string, unknown>) {
|
|
8
|
+
return createTrailer(this.querySelector('dialog'), options as TrailerOptions);
|
|
9
|
+
}
|
|
10
|
+
open(origin?: HTMLElement | null) { this.instance?.open(origin); }
|
|
11
|
+
close() { this.instance?.close(); }
|
|
12
|
+
}
|
|
13
|
+
export const defineTrailer = () => defineElement('mk-trailer', MkTrailerElement);
|