@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,165 @@
|
|
|
1
|
+
import { gsap, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { wrap } from '../../core/math';
|
|
5
|
+
import { inert, type Instance } from '../../core/types';
|
|
6
|
+
|
|
7
|
+
export interface ThumbnailNavOptions {
|
|
8
|
+
index?: number;
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Seconds for expansion and panel entry. */
|
|
11
|
+
duration?: number;
|
|
12
|
+
onSelect?: (index: number, item: HTMLElement) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ThumbnailNavInstance extends Instance {
|
|
16
|
+
open(): void;
|
|
17
|
+
close(): void;
|
|
18
|
+
toggle(): void;
|
|
19
|
+
select(index: number): void;
|
|
20
|
+
readonly index: number;
|
|
21
|
+
readonly expanded: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let panelSequence = 0;
|
|
25
|
+
|
|
26
|
+
/** A category disclosure with stacked previews, real links and complete keyboard navigation. */
|
|
27
|
+
export function createThumbnailNav(root: HTMLElement | null, options: ThumbnailNavOptions = {}): ThumbnailNavInstance {
|
|
28
|
+
const items = root ? [...root.querySelectorAll<HTMLElement>('[data-mk-thumbnail-item]')] : [];
|
|
29
|
+
if (!root || !items.length || typeof document === 'undefined') return inert({ open() {}, close() {}, toggle() {}, select() {}, index: 0, expanded: false });
|
|
30
|
+
registerMotion();
|
|
31
|
+
const scope = createScope();
|
|
32
|
+
const original = [...root.childNodes];
|
|
33
|
+
const duration = Math.max(0, options.duration ?? 0.5);
|
|
34
|
+
const disabled = (item: HTMLElement) => item.matches(':disabled, [aria-disabled="true"]');
|
|
35
|
+
const name = (item: HTMLElement) => item.dataset.mkLabel ?? item.textContent?.trim() ?? '';
|
|
36
|
+
let index = 0;
|
|
37
|
+
let expanded = false;
|
|
38
|
+
let animation: gsap.core.Tween | undefined;
|
|
39
|
+
const button = document.createElement('button');
|
|
40
|
+
button.type = 'button';
|
|
41
|
+
button.className = 'mk-thumbnail-nav__toggle';
|
|
42
|
+
button.setAttribute('aria-expanded', 'false');
|
|
43
|
+
const preview = document.createElement('span');
|
|
44
|
+
preview.className = 'mk-thumbnail-nav__preview';
|
|
45
|
+
preview.setAttribute('aria-hidden', 'true');
|
|
46
|
+
const copy = document.createElement('span');
|
|
47
|
+
copy.className = 'mk-thumbnail-nav__copy';
|
|
48
|
+
const kicker = document.createElement('span');
|
|
49
|
+
kicker.className = 'mk-thumbnail-nav__kicker';
|
|
50
|
+
kicker.textContent = options.label ?? root.dataset.mkLabel ?? 'Categoría';
|
|
51
|
+
const label = document.createElement('span');
|
|
52
|
+
label.className = 'mk-thumbnail-nav__label';
|
|
53
|
+
copy.append(kicker, label);
|
|
54
|
+
const arrow = document.createElement('span');
|
|
55
|
+
arrow.className = 'mk-thumbnail-nav__chevron';
|
|
56
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
57
|
+
arrow.textContent = '⌄';
|
|
58
|
+
button.append(preview, copy, arrow);
|
|
59
|
+
const panel = document.createElement('div');
|
|
60
|
+
panel.className = 'mk-thumbnail-nav__panel';
|
|
61
|
+
panel.id = `mk-thumbnail-panel-${++panelSequence}`;
|
|
62
|
+
while (document.getElementById(panel.id)) panel.id = `mk-thumbnail-panel-${++panelSequence}`;
|
|
63
|
+
panel.hidden = true;
|
|
64
|
+
panel.inert = true;
|
|
65
|
+
button.setAttribute('aria-controls', panel.id);
|
|
66
|
+
panel.append(...original);
|
|
67
|
+
root.replaceChildren(button, panel);
|
|
68
|
+
scope.add(() => root.replaceChildren(...original));
|
|
69
|
+
scope.attr(root, 'class', `${root.className} mk-thumbnail-nav`.trim());
|
|
70
|
+
scope.attr(root, 'style', root.getAttribute('style'));
|
|
71
|
+
root.style.setProperty('--mk-thumbnail-nav-duration', `${duration}s`);
|
|
72
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
73
|
+
|
|
74
|
+
const updatePreview = () => {
|
|
75
|
+
label.textContent = name(items[index]!);
|
|
76
|
+
button.setAttribute('aria-label', `${kicker.textContent}: ${label.textContent}`);
|
|
77
|
+
preview.replaceChildren();
|
|
78
|
+
for (let offset = 0; offset < Math.min(3, items.length); offset++) {
|
|
79
|
+
const item = items[wrap(index + offset, items.length)]!;
|
|
80
|
+
const thumb = document.createElement('span');
|
|
81
|
+
thumb.className = 'mk-thumbnail-nav__thumb';
|
|
82
|
+
thumb.style.setProperty('--mk-thumbnail-order', String(offset));
|
|
83
|
+
const source = item.querySelector('img');
|
|
84
|
+
if (source) {
|
|
85
|
+
const image = source.cloneNode(false) as HTMLImageElement;
|
|
86
|
+
image.removeAttribute('id');
|
|
87
|
+
image.alt = '';
|
|
88
|
+
thumb.append(image);
|
|
89
|
+
} else thumb.textContent = name(item).slice(0, 1);
|
|
90
|
+
preview.append(thumb);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const focusItem = (candidate: number, direction = 1) => {
|
|
94
|
+
for (let attempt = 0; attempt < items.length; attempt++) {
|
|
95
|
+
const current = items[wrap(candidate, items.length)]!;
|
|
96
|
+
if (!disabled(current)) { current.focus(); return; }
|
|
97
|
+
candidate += direction;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const close = (restoreFocus = panel.contains(document.activeElement)) => {
|
|
101
|
+
if (scope.disposed || !expanded) return;
|
|
102
|
+
expanded = false;
|
|
103
|
+
button.setAttribute('aria-expanded', 'false');
|
|
104
|
+
scope.attr(root, 'data-mk-expanded', null);
|
|
105
|
+
panel.inert = true;
|
|
106
|
+
animation?.kill();
|
|
107
|
+
if (restoreFocus) button.focus();
|
|
108
|
+
if (reduced() || duration === 0) panel.hidden = true;
|
|
109
|
+
else animation = gsap.to(panel, { autoAlpha: 0, y: 10, scale: 0.98, duration: duration * 0.6, ease: 'power3.out', overwrite: true, onComplete: () => { panel.hidden = true; } });
|
|
110
|
+
};
|
|
111
|
+
const open = () => {
|
|
112
|
+
if (scope.disposed || expanded) return;
|
|
113
|
+
expanded = true;
|
|
114
|
+
panel.hidden = false;
|
|
115
|
+
panel.inert = false;
|
|
116
|
+
button.setAttribute('aria-expanded', 'true');
|
|
117
|
+
scope.attr(root, 'data-mk-expanded', '');
|
|
118
|
+
animation?.kill();
|
|
119
|
+
if (reduced() || duration === 0) gsap.set(panel, { autoAlpha: 1, y: 0, scale: 1 });
|
|
120
|
+
else animation = gsap.fromTo(panel, { autoAlpha: 0, y: 12, scale: 0.96 }, { autoAlpha: 1, y: 0, scale: 1, duration, ease: 'power3.out', overwrite: true });
|
|
121
|
+
};
|
|
122
|
+
const select = (next: number, notify = true) => {
|
|
123
|
+
if (scope.disposed || !Number.isFinite(next)) return false;
|
|
124
|
+
const candidate = wrap(Math.round(next), items.length);
|
|
125
|
+
const item = items[candidate]!;
|
|
126
|
+
if (disabled(item)) return false;
|
|
127
|
+
if (notify && !root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, cancelable: true, detail: { index: candidate, item } }))) return false;
|
|
128
|
+
index = candidate;
|
|
129
|
+
items.forEach((entry, position) => scope.attr(entry, 'aria-current', position === index ? 'page' : null));
|
|
130
|
+
updatePreview();
|
|
131
|
+
if (notify) options.onSelect?.(index, item);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
scope.on(button, 'click', () => { if (expanded) close(); else open(); });
|
|
135
|
+
scope.on(root, 'click', (event) => {
|
|
136
|
+
const item = (event.target as Element).closest<HTMLElement>('[data-mk-thumbnail-item]');
|
|
137
|
+
if (!item || !items.includes(item)) return;
|
|
138
|
+
if (!select(items.indexOf(item))) { event.preventDefault(); return; }
|
|
139
|
+
close(true);
|
|
140
|
+
});
|
|
141
|
+
scope.on(root, 'keydown', (event) => {
|
|
142
|
+
if (event.key === 'Escape' && expanded) { event.preventDefault(); event.stopPropagation(); close(true); return; }
|
|
143
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
144
|
+
const onTrigger = document.activeElement === button;
|
|
145
|
+
const direction = event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : event.key === 'ArrowUp' || event.key === 'ArrowLeft' ? -1 : 0;
|
|
146
|
+
if ((!direction && event.key !== 'Home' && event.key !== 'End') || (!onTrigger && current < 0)) return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
open();
|
|
149
|
+
const candidate = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : onTrigger ? index : current + direction;
|
|
150
|
+
focusItem(candidate, event.key === 'End' ? -1 : direction || 1);
|
|
151
|
+
});
|
|
152
|
+
scope.on(root, 'focusout', (event) => {
|
|
153
|
+
if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return;
|
|
154
|
+
scope.timeout(() => { if (!scope.disposed && !root.contains(document.activeElement)) close(false); }, 0);
|
|
155
|
+
});
|
|
156
|
+
scope.on(document, 'pointerdown', (event) => { if (event.target instanceof Node && !root.contains(event.target)) close(false); });
|
|
157
|
+
scope.add(() => animation?.kill());
|
|
158
|
+
const initial = options.index ?? Math.max(0, items.findIndex((item) => item.getAttribute('aria-current') === 'page'));
|
|
159
|
+
const enabled = items.findIndex((item) => !disabled(item));
|
|
160
|
+
if (!select(initial, false)) {
|
|
161
|
+
if (enabled >= 0) select(enabled, false);
|
|
162
|
+
else { updatePreview(); button.disabled = true; }
|
|
163
|
+
}
|
|
164
|
+
return { open, close: () => close(), toggle: () => { if (expanded) close(); else open(); }, select: (next) => { select(next); }, get index() { return index; }, get expanded() { return expanded; }, destroy: () => scope.dispose() };
|
|
165
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
.mk-thumbnail-nav {
|
|
2
|
+
--mk-thumbnail-nav-surface: #181716e6;
|
|
3
|
+
--mk-thumbnail-nav-color: #fff;
|
|
4
|
+
--mk-thumbnail-nav-duration: .5s;
|
|
5
|
+
--mk-thumbnail-nav-ease: cubic-bezier(.165,.84,.44,1);
|
|
6
|
+
position: relative;
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: end;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
inline-size: min(100%, 18.0625rem);
|
|
11
|
+
min-block-size: 4.25rem;
|
|
12
|
+
margin-inline: auto;
|
|
13
|
+
color: var(--mk-thumbnail-nav-color);
|
|
14
|
+
font-family: var(--mk-font, sans-serif);
|
|
15
|
+
isolation: isolate;
|
|
16
|
+
}
|
|
17
|
+
.mk-thumbnail-nav--fixed { position: fixed; z-index: 20; inset: auto auto max(1.25rem, env(safe-area-inset-bottom)) 50%; transform: translateX(-50%); }
|
|
18
|
+
.mk-thumbnail-nav__toggle {
|
|
19
|
+
position: relative;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
flex: none;
|
|
23
|
+
gap: .5rem;
|
|
24
|
+
inline-size: 13.875rem;
|
|
25
|
+
max-inline-size: 100%;
|
|
26
|
+
min-block-size: 3.3125rem;
|
|
27
|
+
padding: .40625rem .75rem;
|
|
28
|
+
border: 0;
|
|
29
|
+
border-radius: 3.3125rem;
|
|
30
|
+
background: var(--mk-thumbnail-nav-surface);
|
|
31
|
+
color: inherit;
|
|
32
|
+
font: inherit;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
-webkit-backdrop-filter: blur(8px);
|
|
35
|
+
backdrop-filter: blur(8px);
|
|
36
|
+
transition: inline-size var(--mk-thumbnail-nav-duration) var(--mk-thumbnail-nav-ease), min-block-size var(--mk-thumbnail-nav-duration) var(--mk-thumbnail-nav-ease), border-radius var(--mk-thumbnail-nav-duration) var(--mk-thumbnail-nav-ease);
|
|
37
|
+
}
|
|
38
|
+
.mk-thumbnail-nav[data-mk-expanded] .mk-thumbnail-nav__toggle { inline-size: 18.0625rem; min-block-size: 4.25rem; border-radius: 4.25rem; }
|
|
39
|
+
.mk-thumbnail-nav__preview { position: relative; display: block; flex: 0 0 4.25rem; block-size: 2.5rem; }
|
|
40
|
+
.mk-thumbnail-nav__thumb {
|
|
41
|
+
position: absolute;
|
|
42
|
+
inset: 0 auto auto 0;
|
|
43
|
+
display: grid;
|
|
44
|
+
place-items: center;
|
|
45
|
+
inline-size: 2.5rem;
|
|
46
|
+
block-size: 2.5rem;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
border-radius: 100%;
|
|
49
|
+
background: var(--mk-accent, #dda063);
|
|
50
|
+
color: var(--mk-on-accent, #181716);
|
|
51
|
+
font-size: .75rem;
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
z-index: calc(3 - var(--mk-thumbnail-order));
|
|
54
|
+
transform-origin: left center;
|
|
55
|
+
transform: translateX(calc((2 - var(--mk-thumbnail-order)) * .625rem)) scale(.725);
|
|
56
|
+
transition: transform var(--mk-thumbnail-nav-duration) var(--mk-thumbnail-nav-ease);
|
|
57
|
+
}
|
|
58
|
+
.mk-thumbnail-nav[data-mk-expanded] .mk-thumbnail-nav__thumb { transform: translateX(calc((2 - var(--mk-thumbnail-order)) * .5rem)) scale(1); }
|
|
59
|
+
.mk-thumbnail-nav__thumb img { inline-size: 100%; block-size: 100%; object-fit: cover; }
|
|
60
|
+
.mk-thumbnail-nav__copy { display: grid; flex: 1; min-inline-size: 0; gap: .125rem; text-align: start; }
|
|
61
|
+
.mk-thumbnail-nav__kicker { display: none; font-size: .6875rem; opacity: .6; line-height: 1.1; }
|
|
62
|
+
.mk-thumbnail-nav[data-mk-expanded] .mk-thumbnail-nav__kicker { display: block; }
|
|
63
|
+
.mk-thumbnail-nav__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; font-weight: 600; line-height: 1.3; }
|
|
64
|
+
.mk-thumbnail-nav__chevron { display: grid; flex: 0 0 1.8125rem; place-items: center; block-size: 1.8125rem; border-radius: 50%; background: #ffffff1a; line-height: 1; transition: transform var(--mk-thumbnail-nav-duration) var(--mk-thumbnail-nav-ease); }
|
|
65
|
+
.mk-thumbnail-nav[data-mk-expanded] .mk-thumbnail-nav__chevron { transform: rotate(180deg); }
|
|
66
|
+
.mk-thumbnail-nav__panel {
|
|
67
|
+
position: absolute;
|
|
68
|
+
z-index: 2;
|
|
69
|
+
inset: auto 0 calc(100% + .625rem);
|
|
70
|
+
display: grid;
|
|
71
|
+
gap: .25rem;
|
|
72
|
+
max-block-size: min(20rem, 55vh);
|
|
73
|
+
overflow: auto;
|
|
74
|
+
padding: .5rem;
|
|
75
|
+
border: 1px solid #ffffff1a;
|
|
76
|
+
border-radius: 1.125rem;
|
|
77
|
+
background: var(--mk-thumbnail-nav-surface);
|
|
78
|
+
color: inherit;
|
|
79
|
+
box-shadow: 0 1rem 3rem #0003;
|
|
80
|
+
-webkit-backdrop-filter: blur(8px);
|
|
81
|
+
backdrop-filter: blur(8px);
|
|
82
|
+
transform-origin: center bottom;
|
|
83
|
+
}
|
|
84
|
+
.mk-thumbnail-nav__panel[hidden] { display: none; }
|
|
85
|
+
.mk-thumbnail-nav__panel :is(ul, ol) { margin: 0; padding: 0; list-style: none; }
|
|
86
|
+
.mk-thumbnail-nav__panel [data-mk-thumbnail-item] {
|
|
87
|
+
box-sizing: border-box;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: .75rem;
|
|
91
|
+
inline-size: 100%;
|
|
92
|
+
min-block-size: 3rem;
|
|
93
|
+
padding: .5rem .625rem;
|
|
94
|
+
border: 0;
|
|
95
|
+
border-radius: .75rem;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: inherit;
|
|
98
|
+
font: inherit;
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
.mk-thumbnail-nav__panel [data-mk-thumbnail-item]:is(:hover, [aria-current='page']) { background: #ffffff1a; }
|
|
103
|
+
.mk-thumbnail-nav__panel [data-mk-thumbnail-item]:is(:disabled, [aria-disabled='true']) { opacity: .4; cursor: default; }
|
|
104
|
+
.mk-thumbnail-nav__panel [data-mk-thumbnail-item] img { inline-size: 2rem; block-size: 2rem; object-fit: cover; border-radius: 50%; }
|
|
105
|
+
.mk-thumbnail-nav__toggle:focus-visible, .mk-thumbnail-nav__panel [data-mk-thumbnail-item]:focus-visible { outline: 2px solid var(--mk-accent, #dda063); outline-offset: 2px; }
|
|
106
|
+
@media (prefers-reduced-motion: reduce) {
|
|
107
|
+
.mk-thumbnail-nav *, .mk-thumbnail-nav *::before, .mk-thumbnail-nav *::after { transition: none !important; }
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# weight-hover
|
|
2
|
+
|
|
3
|
+
Cada letra adelgaza al acercarse el puntero, como el titular y el «SAY HELLO» de DOVV Studio. El valor del eje sale de `reposo + (cerca − reposo) · smoothstep(1 − distancia / radio)`. Cada frame recorre un 10 % de lo que falta, y el bucle se detiene cuando ninguna letra se mueve más de media unidad. Valores de la referencia: reposo 900, cerca 200 y radio 400 px (380 en el contacto).
|
|
4
|
+
|
|
5
|
+
Necesita una fuente variable con el eje (por defecto `wght`), por ejemplo Archivo, Inter o Roboto Flex; con una fuente estática no hay efecto. Funciona con cualquier texto y `<br>`: las letras visibles son `aria-hidden` y una copia `.mk-sr-only` conserva el texto completo para lectores de pantalla.
|
|
6
|
+
|
|
7
|
+
## Vanilla / Astro
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<h2 class="mk-weight-hover" data-mk="weight-hover" data-mk-rest="900" data-mk-near="200" data-mk-radius="400"
|
|
11
|
+
style="font-family: 'Archivo', sans-serif">WE BUILD SITES<br>THAT MOVE PEOPLE</h2>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Con `autoInit()` las opciones van como `data-mk-<opción>`. Sin `autoInit`: `createWeightHover(el, opciones)`, que devuelve `pointAt({x, y} | null)` y `destroy()`.
|
|
15
|
+
|
|
16
|
+
## React
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
const ref = useRef<HTMLHeadingElement>(null);
|
|
20
|
+
useWeightHover(ref, { rest: 900, near: 200, radius: 400 });
|
|
21
|
+
return <h2 ref={ref} className="mk-weight-hover">SAY HELLO</h2>;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Opciones
|
|
25
|
+
|
|
26
|
+
| Opción | Por defecto | Qué hace |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `rest` | `900` | Valor del eje lejos del puntero |
|
|
29
|
+
| `near` | `200` | Valor bajo el puntero |
|
|
30
|
+
| `radius` | `400` | Alcance en px |
|
|
31
|
+
| `axis` | `'wght'` | Eje variable (4 letras) |
|
|
32
|
+
| `lerp` | `0.1` | Fracción del camino por frame |
|
|
33
|
+
| `anyPointer` | `false` | Reaccionar también a táctil y lápiz; la referencia sólo usa ratón |
|
|
34
|
+
|
|
35
|
+
## Accesibilidad
|
|
36
|
+
|
|
37
|
+
El texto no cambia, sólo su grosor. Con movimiento reducido no parte el texto ni anima. `destroy()` restaura los nodos originales.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { reduced } from '../../core/reduced';
|
|
2
|
+
import { createScope } from '../../core/scope';
|
|
3
|
+
import { inert, type Instance } from '../../core/types';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Weight hover (DOVV Studio hero and "SAY HELLO"): every letter drops its
|
|
7
|
+
* variable-font weight as the pointer gets close. Target = rest + (near − rest)
|
|
8
|
+
* · smoothstep(1 − distance / radius); each frame moves 10 % of the way and the
|
|
9
|
+
* loop stops once no letter moves more than half a unit. Mouse only, off with
|
|
10
|
+
* reduced motion. Needs a font with the axis (default "wght").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface WeightHoverOptions {
|
|
14
|
+
/** Axis value far from the pointer. */
|
|
15
|
+
rest?: number;
|
|
16
|
+
/** Axis value right under the pointer. */
|
|
17
|
+
near?: number;
|
|
18
|
+
/** Reach of the effect, px. */
|
|
19
|
+
radius?: number;
|
|
20
|
+
/** Variable-font axis tag. */
|
|
21
|
+
axis?: string;
|
|
22
|
+
/** Share of the remaining distance covered per frame (0–1). */
|
|
23
|
+
lerp?: number;
|
|
24
|
+
/** React to touch and pen too (the reference reacts to the mouse only). */
|
|
25
|
+
anyPointer?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WeightHoverInstance extends Instance {
|
|
29
|
+
/** Point the effect at viewport coordinates (null releases it). */
|
|
30
|
+
pointAt(point: { x: number; y: number } | null): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Letter { span: HTMLElement; value: number; x: number; y: number }
|
|
34
|
+
|
|
35
|
+
const FAR = -1e5;
|
|
36
|
+
|
|
37
|
+
export function createWeightHover(root: HTMLElement | null, options: WeightHoverOptions = {}): WeightHoverInstance {
|
|
38
|
+
if (!root) return inert({ pointAt() {} });
|
|
39
|
+
const scope = createScope();
|
|
40
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
41
|
+
if (reduced()) return { pointAt() {}, destroy: () => scope.dispose() };
|
|
42
|
+
|
|
43
|
+
const num = (value: number | undefined, fallback: number) => (Number.isFinite(value) ? value! : fallback);
|
|
44
|
+
const rest = num(options.rest, 900);
|
|
45
|
+
const near = num(options.near, 200);
|
|
46
|
+
const radius = Math.max(1, num(options.radius, 400));
|
|
47
|
+
const lerp = Math.min(1, Math.max(0.01, num(options.lerp, 0.1)));
|
|
48
|
+
const axis = /^[A-Za-z]{4}$/.test(options.axis ?? '') ? options.axis! : 'wght';
|
|
49
|
+
const setting = (value: number) => `"${axis}" ${value.toFixed(1)}`;
|
|
50
|
+
|
|
51
|
+
// Visual letters are aria-hidden; one screen-reader copy keeps the text whole.
|
|
52
|
+
const original = [...root.childNodes];
|
|
53
|
+
const copy = document.createElement('span');
|
|
54
|
+
copy.className = 'mk-sr-only';
|
|
55
|
+
const spoken = root.cloneNode(true) as HTMLElement;
|
|
56
|
+
spoken.querySelectorAll('br').forEach((br) => br.replaceWith(' '));
|
|
57
|
+
copy.textContent = spoken.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
|
58
|
+
const visual = document.createElement('span');
|
|
59
|
+
visual.className = 'mk-weight-hover__visual';
|
|
60
|
+
visual.setAttribute('aria-hidden', 'true');
|
|
61
|
+
visual.append(...original.map((node) => node.cloneNode(true)));
|
|
62
|
+
const letters: Letter[] = [];
|
|
63
|
+
const walker = document.createTreeWalker(visual, NodeFilter.SHOW_TEXT);
|
|
64
|
+
const texts: Text[] = [];
|
|
65
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) texts.push(node as Text);
|
|
66
|
+
for (const text of texts) {
|
|
67
|
+
const fragment = document.createDocumentFragment();
|
|
68
|
+
for (const char of text.data) {
|
|
69
|
+
if (/\s/.test(char)) {
|
|
70
|
+
fragment.append(char);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const span = document.createElement('span');
|
|
74
|
+
span.dataset.mkWeightChar = '';
|
|
75
|
+
span.textContent = char;
|
|
76
|
+
span.style.fontVariationSettings = setting(rest);
|
|
77
|
+
fragment.append(span);
|
|
78
|
+
letters.push({ span, value: rest, x: 0, y: 0 });
|
|
79
|
+
}
|
|
80
|
+
text.replaceWith(fragment);
|
|
81
|
+
}
|
|
82
|
+
root.replaceChildren(copy, visual);
|
|
83
|
+
scope.add(() => root.replaceChildren(...original));
|
|
84
|
+
|
|
85
|
+
// Letter centres relative to the root, re-measured when layout or fonts change.
|
|
86
|
+
const measure = () => {
|
|
87
|
+
const box = root.getBoundingClientRect();
|
|
88
|
+
for (const letter of letters) {
|
|
89
|
+
const r = letter.span.getBoundingClientRect();
|
|
90
|
+
letter.x = r.left + r.width / 2 - box.left;
|
|
91
|
+
letter.y = r.top + r.height / 2 - box.top;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
measure();
|
|
95
|
+
if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(measure)).observe(root);
|
|
96
|
+
document.fonts?.ready.then(() => { if (!scope.disposed) measure(); });
|
|
97
|
+
|
|
98
|
+
const pointer = { x: FAR, y: FAR };
|
|
99
|
+
let frame = 0;
|
|
100
|
+
const tick = () => {
|
|
101
|
+
frame = 0;
|
|
102
|
+
if (scope.disposed) return;
|
|
103
|
+
const box = root.getBoundingClientRect();
|
|
104
|
+
let moving = false;
|
|
105
|
+
for (const letter of letters) {
|
|
106
|
+
const distance = Math.hypot(pointer.x - (box.left + letter.x), pointer.y - (box.top + letter.y));
|
|
107
|
+
const d = Math.max(0, 1 - distance / radius);
|
|
108
|
+
const target = rest + (near - rest) * d * d * (3 - 2 * d);
|
|
109
|
+
letter.value += (target - letter.value) * lerp;
|
|
110
|
+
if (Math.abs(target - letter.value) > 0.5) moving = true;
|
|
111
|
+
else letter.value = target;
|
|
112
|
+
letter.span.style.fontVariationSettings = setting(letter.value);
|
|
113
|
+
}
|
|
114
|
+
if (moving) frame = requestAnimationFrame(tick);
|
|
115
|
+
};
|
|
116
|
+
const wake = () => { if (!frame && !scope.disposed) frame = requestAnimationFrame(tick); };
|
|
117
|
+
scope.add(() => cancelAnimationFrame(frame));
|
|
118
|
+
scope.on(window, 'pointermove', (event) => {
|
|
119
|
+
const e = event as PointerEvent;
|
|
120
|
+
if (!options.anyPointer && e.pointerType && e.pointerType !== 'mouse') return;
|
|
121
|
+
pointer.x = e.clientX;
|
|
122
|
+
pointer.y = e.clientY;
|
|
123
|
+
wake();
|
|
124
|
+
}, { passive: true });
|
|
125
|
+
scope.on(document, 'pointerleave', () => { pointer.x = pointer.y = FAR; wake(); });
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
pointAt(point) {
|
|
129
|
+
if (scope.disposed) return;
|
|
130
|
+
pointer.x = point ? point.x : FAR;
|
|
131
|
+
pointer.y = point ? point.y : FAR;
|
|
132
|
+
wake();
|
|
133
|
+
},
|
|
134
|
+
destroy: () => scope.dispose(),
|
|
135
|
+
};
|
|
136
|
+
}
|