@yewdiel21/motion-kit 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/auto/index.d.ts +23 -3
- package/dist/auto/index.js +43 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
- package/dist/chunk-Q7IW4FV3.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/drilldown-menu.css +134 -0
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +16 -0
- package/dist/css/components/signature-pad.css +131 -0
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +67 -0
- package/dist/css/components/waveform-player.css +51 -0
- package/dist/css/components/wheel-picker.css +18 -0
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +292 -4
- package/dist/elements/index.js +856 -4
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
- package/dist/index.css +713 -0
- package/dist/index.d.ts +23 -23
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +151 -7
- package/dist/react/index.js +893 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +244 -0
- package/src/auto/auto-init.ts +339 -293
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +133 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +378 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/select.css +16 -0
- package/src/components/signature-pad/README.md +97 -0
- package/src/components/signature-pad/element.ts +57 -0
- package/src/components/signature-pad/engine.ts +550 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +130 -0
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +79 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
- package/src/components/waveform-player/README.md +30 -0
- package/src/components/waveform-player/element.ts +21 -0
- package/src/components/waveform-player/engine.ts +344 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +50 -0
- package/src/components/wheel-picker/README.md +35 -0
- package/src/components/wheel-picker/element.ts +25 -0
- package/src/components/wheel-picker/engine.ts +239 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +17 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +318 -258
- package/src/index.ts +21 -0
- package/src/react/index.ts +192 -171
- package/dist/chunk-SDQ776LG.js.map +0 -1
- /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createScope } from '../../core/scope';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { inert, type Instance } from '../../core/types';
|
|
4
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
5
|
+
|
|
6
|
+
export interface WheelPickerItem { value: string; label: string; disabled?: boolean }
|
|
7
|
+
export type WheelPickerEntry = string | WheelPickerItem;
|
|
8
|
+
export interface WheelPickerOptions {
|
|
9
|
+
items?: WheelPickerEntry[];
|
|
10
|
+
value?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Odd number of visible rows, 3…9 (default 5). */
|
|
15
|
+
visibleItems?: number;
|
|
16
|
+
/** Height per row in px, at least 44 (default 44). */
|
|
17
|
+
itemHeight?: number;
|
|
18
|
+
onInput?: (value: string) => void;
|
|
19
|
+
onChange?: (value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface WheelPickerInstance extends Instance {
|
|
22
|
+
setValue(value: string, notify?: boolean): void;
|
|
23
|
+
setItems(items: WheelPickerEntry[], notify?: boolean): void;
|
|
24
|
+
readonly value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let sequence = 0;
|
|
28
|
+
function normalize(items: WheelPickerEntry[]) {
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
return items.flatMap(item => {
|
|
31
|
+
const row = typeof item === 'string' ? { value: item, label: item } : item;
|
|
32
|
+
if (!row || typeof row !== 'object' || row.value == null) return [];
|
|
33
|
+
const value = String(row.value);
|
|
34
|
+
if (seen.has(value)) return [];
|
|
35
|
+
seen.add(value);
|
|
36
|
+
return [{ value, label: String(row.label ?? value), disabled: Boolean(row.disabled) }];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Native scrolling and inertia with a centred snap row, roving selection and a real form value. */
|
|
41
|
+
export function createWheelPicker(root: HTMLElement | null, options: WheelPickerOptions = {}): WheelPickerInstance {
|
|
42
|
+
if (!root) return inert({ setValue() {}, setItems() {}, value: '' });
|
|
43
|
+
registerMotion();
|
|
44
|
+
const scope = createScope(), uid = `mk-wheel-${++sequence}`;
|
|
45
|
+
let items = normalize(Array.isArray(options.items) ? options.items : []);
|
|
46
|
+
const rawRows = Number.isFinite(options.visibleItems) ? Math.round(options.visibleItems!) : 5;
|
|
47
|
+
const rows = Math.min(9, Math.max(3, rawRows % 2 ? rawRows : rawRows + 1));
|
|
48
|
+
const height = Number.isFinite(options.itemHeight) ? Math.max(44, Math.min(96, options.itemHeight!)) : 44;
|
|
49
|
+
const disabled = Boolean(options.disabled);
|
|
50
|
+
const originalStyle = root.getAttribute('style');
|
|
51
|
+
root.style.setProperty('--mk-wheel-row', `${height}px`);
|
|
52
|
+
root.style.setProperty('--mk-wheel-rows', String(rows));
|
|
53
|
+
scope.add(() => { if (originalStyle === null) root.removeAttribute('style'); else root.setAttribute('style', originalStyle); });
|
|
54
|
+
scope.attr(root, 'data-mk-ready', ''); scope.attr(root, 'data-mk-disabled', disabled ? '' : null);
|
|
55
|
+
const label = document.createElement('span'); label.id = `${uid}-label`; label.dataset.mkWheelLabel = ''; label.textContent = options.label ?? root.getAttribute('aria-label') ?? 'Seleccionar';
|
|
56
|
+
const frame = document.createElement('div'); frame.dataset.mkWheelFrame = '';
|
|
57
|
+
const highlight = document.createElement('span'); highlight.dataset.mkWheelHighlight = ''; highlight.setAttribute('aria-hidden', 'true');
|
|
58
|
+
const viewport = document.createElement('div'); viewport.dataset.mkWheelViewport = ''; viewport.setAttribute('role', 'listbox'); viewport.setAttribute('aria-labelledby', label.id); viewport.setAttribute('aria-orientation', 'vertical'); viewport.setAttribute('aria-disabled', String(disabled)); viewport.tabIndex = disabled ? -1 : 0;
|
|
59
|
+
const list = document.createElement('div'); list.dataset.mkWheelList = ''; viewport.append(list); frame.append(highlight, viewport);
|
|
60
|
+
const input = document.createElement('input'); input.type = 'hidden'; if (options.name) input.name = options.name; input.disabled = disabled;
|
|
61
|
+
const status = document.createElement('span'); status.className = 'mk-sr-only'; status.setAttribute('role', 'status');
|
|
62
|
+
root.append(label, frame, input, status);
|
|
63
|
+
scope.add(() => { label.remove(); frame.remove(); input.remove(); status.remove(); });
|
|
64
|
+
const enabled = () => items.flatMap((item, index) => item.disabled ? [] : [index]);
|
|
65
|
+
const find = (value: string | undefined) => items.findIndex(item => value !== undefined && item.value === String(value) && !item.disabled);
|
|
66
|
+
let current = find(options.value); if (current < 0) current = enabled()[0] ?? -1;
|
|
67
|
+
const initial = items[current]?.value ?? '';
|
|
68
|
+
let committed = initial;
|
|
69
|
+
let nodes: HTMLDivElement[] = [];
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
+
let typing: ReturnType<typeof setTimeout> | undefined;
|
|
72
|
+
let prefix = '';
|
|
73
|
+
let programmatic: number | null = null;
|
|
74
|
+
let glide: gsap.core.Tween | null = null;
|
|
75
|
+
let feedback: gsap.core.Tween | null = null;
|
|
76
|
+
let dragging: { id: number; y: number; top: number; moved: boolean } | null = null;
|
|
77
|
+
let suppressClick = false;
|
|
78
|
+
scope.add(() => { clearTimeout(timer); clearTimeout(typing); glide?.kill(); feedback?.kill(); });
|
|
79
|
+
|
|
80
|
+
// The drum's radius is half the frame, so 90° lands on its edge and each row keeps
|
|
81
|
+
// its arc length: rows compress, narrow and recede towards the rims like iOS.
|
|
82
|
+
const radius = height * rows / 2, step = height / radius, camera = radius * 3;
|
|
83
|
+
const paint = () => {
|
|
84
|
+
const offset = viewport.scrollTop / height, flat = reduced();
|
|
85
|
+
nodes.forEach((node, index) => {
|
|
86
|
+
const distance = index - offset;
|
|
87
|
+
// Project each row onto a cylinder. The ordinary list keeps native scroll,
|
|
88
|
+
// touch inertia and option semantics; Y/Z and orientation form the wheel.
|
|
89
|
+
const angle = distance * step, hidden = Math.abs(angle) >= Math.PI / 2;
|
|
90
|
+
const depth = radius * (Math.cos(angle) - 1);
|
|
91
|
+
// Depth narrows and shrinks the row; its centre stays on the drum's silhouette
|
|
92
|
+
// so the rims meet the frame instead of floating inside it.
|
|
93
|
+
const cameraScale = camera / (camera - depth);
|
|
94
|
+
const shiftY = (radius * Math.sin(angle) - distance * height) / cameraScale;
|
|
95
|
+
node.style.transform = flat || hidden ? '' : `perspective(${camera.toFixed(0)}px) translate3d(0, ${shiftY.toFixed(3)}px, ${depth.toFixed(3)}px) rotateX(${(-angle * 180 / Math.PI).toFixed(3)}deg)`;
|
|
96
|
+
node.style.opacity = String((items[index].disabled ? .32 : 1) * (flat ? 1 : hidden ? 0 : Math.pow(Math.cos(angle), 1.6)));
|
|
97
|
+
node.style.visibility = !flat && hidden ? 'hidden' : '';
|
|
98
|
+
node.style.pointerEvents = !flat && Math.abs(angle) > 1.2 ? 'none' : '';
|
|
99
|
+
node.setAttribute('aria-selected', String(index === current));
|
|
100
|
+
});
|
|
101
|
+
input.value = items[current]?.value ?? '';
|
|
102
|
+
if (current >= 0) viewport.setAttribute('aria-activedescendant', `${uid}-${current}`);
|
|
103
|
+
else viewport.removeAttribute('aria-activedescendant');
|
|
104
|
+
};
|
|
105
|
+
const announce = (kind: 'input' | 'change') => {
|
|
106
|
+
const value = items[current]?.value ?? '';
|
|
107
|
+
if (kind === 'input') options.onInput?.(value);
|
|
108
|
+
else {
|
|
109
|
+
options.onChange?.(value); status.textContent = items[current]?.label ?? '';
|
|
110
|
+
feedback?.kill();
|
|
111
|
+
if (!reduced()) feedback = gsap.fromTo(highlight, { scaleX: .94, scaleY: .82 }, { scaleX: 1, scaleY: 1, duration: .5, ease: EASE.click, clearProps: 'transform' });
|
|
112
|
+
}
|
|
113
|
+
input.dispatchEvent(new Event(kind, { bubbles: true }));
|
|
114
|
+
root.dispatchEvent(new CustomEvent(`mk:${kind}`, { bubbles: true, detail: { value, item: current >= 0 ? { ...items[current] } : null } }));
|
|
115
|
+
};
|
|
116
|
+
const align = (animate: boolean) => {
|
|
117
|
+
glide?.kill(); glide = null;
|
|
118
|
+
const top = Math.max(0, current) * height;
|
|
119
|
+
if (animate && !reduced()) {
|
|
120
|
+
programmatic = top;
|
|
121
|
+
const proxy = { top: viewport.scrollTop };
|
|
122
|
+
glide = gsap.to(proxy, { top, duration: .55, ease: EASE.swatch,
|
|
123
|
+
onUpdate: () => { viewport.scrollTop = proxy.top; paint(); },
|
|
124
|
+
onComplete: () => { glide = null; programmatic = null; viewport.scrollTop = top; paint(); },
|
|
125
|
+
});
|
|
126
|
+
} else { programmatic = null; viewport.scrollTop = top; paint(); }
|
|
127
|
+
};
|
|
128
|
+
const select = (index: number, notify: boolean, animate = true) => {
|
|
129
|
+
if (scope.disposed || index < 0 || index >= items.length || items[index].disabled) return;
|
|
130
|
+
const previous = items[current]?.value ?? '';
|
|
131
|
+
current = index; paint(); align(animate);
|
|
132
|
+
const value = items[current].value;
|
|
133
|
+
committed = value;
|
|
134
|
+
if (notify && value !== previous) { announce('input'); announce('change'); }
|
|
135
|
+
};
|
|
136
|
+
const nearest = () => {
|
|
137
|
+
const at = viewport.scrollTop / height;
|
|
138
|
+
return enabled().reduce((best, index) => best < 0 || Math.abs(index - at) < Math.abs(best - at) ? index : best, -1);
|
|
139
|
+
};
|
|
140
|
+
const settle = () => {
|
|
141
|
+
if (scope.disposed || dragging) return;
|
|
142
|
+
if (glide?.isActive()) return;
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
if (programmatic !== null) {
|
|
145
|
+
// Keep controlled changes from notifying as native scrolling passes intermediate rows.
|
|
146
|
+
programmatic = null; viewport.scrollTop = Math.max(0, current) * height; paint(); return;
|
|
147
|
+
}
|
|
148
|
+
const index = nearest(); if (index < 0) return;
|
|
149
|
+
current = index; align(false); paint();
|
|
150
|
+
const value = items[current].value;
|
|
151
|
+
if (value !== committed) { committed = value; announce('change'); }
|
|
152
|
+
};
|
|
153
|
+
const build = () => {
|
|
154
|
+
list.replaceChildren();
|
|
155
|
+
nodes = items.map((item, index) => {
|
|
156
|
+
const node = document.createElement('div'); node.id = `${uid}-${index}`; node.dataset.mkWheelOption = String(index); node.setAttribute('role', 'option'); node.setAttribute('aria-disabled', String(item.disabled)); node.textContent = item.label; list.append(node); return node;
|
|
157
|
+
});
|
|
158
|
+
if (!items.length) { const empty = document.createElement('span'); empty.dataset.mkWheelEmpty = ''; empty.textContent = 'Sin opciones'; list.append(empty); }
|
|
159
|
+
align(false); paint();
|
|
160
|
+
};
|
|
161
|
+
const interrupt = () => {
|
|
162
|
+
glide?.kill(); glide = null;
|
|
163
|
+
programmatic = null;
|
|
164
|
+
};
|
|
165
|
+
scope.on(viewport, 'pointerdown', event => {
|
|
166
|
+
interrupt();
|
|
167
|
+
const pointer = event as PointerEvent;
|
|
168
|
+
if (disabled || pointer.pointerType === 'touch' || pointer.button !== 0) return;
|
|
169
|
+
dragging = { id: pointer.pointerId, y: pointer.clientY, top: viewport.scrollTop, moved: false };
|
|
170
|
+
viewport.focus({ preventScroll: true });
|
|
171
|
+
});
|
|
172
|
+
scope.on(window, 'pointermove', event => {
|
|
173
|
+
const pointer = event as PointerEvent;
|
|
174
|
+
if (!dragging || pointer.pointerId !== dragging.id) return;
|
|
175
|
+
const delta = pointer.clientY - dragging.y;
|
|
176
|
+
if (Math.abs(delta) > 4 && !dragging.moved) {
|
|
177
|
+
dragging.moved = true; viewport.style.scrollSnapType = 'none';
|
|
178
|
+
try { viewport.setPointerCapture(pointer.pointerId); } catch { /* Synthetic events have no pointer capture. */ }
|
|
179
|
+
}
|
|
180
|
+
if (!dragging.moved) return;
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
viewport.scrollTop = Math.max(0, Math.min(Math.max(0, (items.length - 1) * height), dragging.top - delta));
|
|
183
|
+
});
|
|
184
|
+
const release = (event: Event) => {
|
|
185
|
+
if (!dragging || dragging.id !== (event as PointerEvent).pointerId) return;
|
|
186
|
+
suppressClick = dragging.moved; dragging = null; viewport.style.removeProperty('scroll-snap-type');
|
|
187
|
+
if (suppressClick) { settle(); clearTimeout(timer); timer = setTimeout(() => { suppressClick = false; }, 0); }
|
|
188
|
+
};
|
|
189
|
+
scope.on(window, 'pointerup', release); scope.on(window, 'pointercancel', release); scope.on(viewport, 'lostpointercapture', release);
|
|
190
|
+
scope.on(viewport, 'wheel', interrupt, { passive: true });
|
|
191
|
+
scope.on(viewport, 'scroll', () => {
|
|
192
|
+
if (disabled) return;
|
|
193
|
+
if (programmatic === null) {
|
|
194
|
+
const index = nearest();
|
|
195
|
+
if (index >= 0 && index !== current) { current = index; paint(); announce('input'); }
|
|
196
|
+
}
|
|
197
|
+
paint(); clearTimeout(timer); timer = setTimeout(settle, 100);
|
|
198
|
+
}, { passive: true });
|
|
199
|
+
scope.on(viewport, 'scrollend', settle);
|
|
200
|
+
scope.on(viewport, 'click', event => {
|
|
201
|
+
if (disabled) return;
|
|
202
|
+
if (suppressClick) { event.preventDefault(); suppressClick = false; return; }
|
|
203
|
+
const row = (event.target as Element).closest<HTMLElement>('[data-mk-wheel-option]');
|
|
204
|
+
if (!row || !list.contains(row)) return;
|
|
205
|
+
select(Number(row.dataset.mkWheelOption), true); viewport.focus({ preventScroll: true });
|
|
206
|
+
});
|
|
207
|
+
scope.on(viewport, 'keydown', event => {
|
|
208
|
+
if (disabled) return;
|
|
209
|
+
const key = (event as KeyboardEvent).key, choices = enabled(), at = choices.indexOf(current);
|
|
210
|
+
if (key === 'Enter' || key === ' ') { event.preventDefault(); return; }
|
|
211
|
+
const move = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : key === 'PageDown' ? rows - 1 : key === 'PageUp' ? 1 - rows : 0;
|
|
212
|
+
let next = key === 'Home' ? choices[0] : key === 'End' ? choices.at(-1) : move ? choices[Math.min(choices.length - 1, Math.max(0, at + move))] : undefined;
|
|
213
|
+
if (next === undefined && key.length === 1 && !(event as KeyboardEvent).ctrlKey && !(event as KeyboardEvent).metaKey) {
|
|
214
|
+
prefix += key.toLocaleLowerCase(); clearTimeout(typing); typing = setTimeout(() => { prefix = ''; }, 600);
|
|
215
|
+
next = choices.find(index => items[index].label.toLocaleLowerCase().startsWith(prefix));
|
|
216
|
+
}
|
|
217
|
+
if (next === undefined) return;
|
|
218
|
+
event.preventDefault(); interrupt(); select(next, true);
|
|
219
|
+
});
|
|
220
|
+
const form = root.closest('form');
|
|
221
|
+
if (form) scope.on(form, 'reset', event => queueMicrotask(() => {
|
|
222
|
+
if (scope.disposed || event.defaultPrevented) return;
|
|
223
|
+
const index = find(initial); select(index >= 0 ? index : enabled()[0] ?? -1, true, false); paint();
|
|
224
|
+
}));
|
|
225
|
+
build(); input.defaultValue = initial;
|
|
226
|
+
return {
|
|
227
|
+
setValue(value, notify = false) { const next = find(value); if (next !== current) select(next, notify); },
|
|
228
|
+
setItems(next, notify = false) {
|
|
229
|
+
if (scope.disposed) return;
|
|
230
|
+
clearTimeout(timer); programmatic = null;
|
|
231
|
+
const before = items[current]?.value ?? ''; items = normalize(Array.isArray(next) ? next : []);
|
|
232
|
+
current = find(before); if (current < 0) current = enabled()[0] ?? -1;
|
|
233
|
+
build(); committed = items[current]?.value ?? '';
|
|
234
|
+
if (notify && committed !== before) { announce('input'); announce('change'); }
|
|
235
|
+
},
|
|
236
|
+
get value() { return items[current]?.value ?? ''; },
|
|
237
|
+
destroy: () => scope.dispose(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useReducer, useRef, type HTMLAttributes } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createWheelPicker, type WheelPickerInstance, type WheelPickerOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface WheelPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, WheelPickerOptions {
|
|
7
|
+
defaultValue?: string;
|
|
8
|
+
instanceRef?: { current: WheelPickerInstance | null };
|
|
9
|
+
}
|
|
10
|
+
export function WheelPicker({ items, value, defaultValue, name, label, disabled, visibleItems, itemHeight, onInput, onChange, instanceRef, className, ...props }: WheelPickerProps) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null), instance = useRef<WheelPickerInstance | null>(null);
|
|
12
|
+
const changed = useLatest(onChange), input = useLatest(onInput), latestItems = useLatest(items);
|
|
13
|
+
const controlled = useLatest(value);
|
|
14
|
+
const [, refresh] = useReducer((revision: number) => revision + 1, 0);
|
|
15
|
+
const last = useRef(value ?? defaultValue);
|
|
16
|
+
useIsomorphicLayoutEffect(() => {
|
|
17
|
+
const created = createWheelPicker(ref.current, { items: latestItems.current, value: value ?? last.current, name, label, disabled, visibleItems, itemHeight,
|
|
18
|
+
onInput: next => { last.current = next; input.current?.(next); }, onChange: next => { last.current = next; changed.current?.(next); if (controlled.current !== undefined) refresh(); } });
|
|
19
|
+
instance.current = created;
|
|
20
|
+
return () => { last.current = created.value; created.destroy(); instance.current = null; };
|
|
21
|
+
}, [name, label, disabled, visibleItems, itemHeight]);
|
|
22
|
+
const key = JSON.stringify(items ?? []);
|
|
23
|
+
useIsomorphicLayoutEffect(() => { instance.current?.setItems(items ?? []); }, [key]);
|
|
24
|
+
useIsomorphicLayoutEffect(() => { if (value !== undefined) instance.current?.setValue(value); });
|
|
25
|
+
useIsomorphicLayoutEffect(() => {
|
|
26
|
+
if (instanceRef) instanceRef.current = instance.current;
|
|
27
|
+
return () => { if (instanceRef) instanceRef.current = null; };
|
|
28
|
+
}, [instanceRef, name, label, disabled, visibleItems, itemHeight]);
|
|
29
|
+
const domProps: HTMLAttributes<HTMLDivElement> = { ...props };
|
|
30
|
+
delete domProps.children; delete domProps.dangerouslySetInnerHTML;
|
|
31
|
+
return <div {...domProps} ref={ref} className={['mk-wheel-picker', className].filter(Boolean).join(' ')} />;
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.mk-wheel-picker, mk-wheel-picker { --mk-wheel-row: 44px; --mk-wheel-rows: 5; display: grid; gap: .75rem; min-width: 0; color: var(--mk-text); font-family: var(--mk-font); }
|
|
2
|
+
[data-mk-wheel-label] { color: var(--mk-muted); font-size: .8125rem; text-align: center; }
|
|
3
|
+
[data-mk-wheel-frame] { position: relative; isolation: isolate; min-width: 0; overflow: hidden; border-radius: var(--mk-radius-xl); background: var(--mk-surface-1); }
|
|
4
|
+
[data-mk-wheel-highlight] { position: absolute; top: calc(50% - var(--mk-wheel-row) / 2); right: .5rem; left: .5rem; height: var(--mk-wheel-row); border-radius: var(--mk-radius-md); background: var(--mk-surface-3); box-shadow: inset 0 1px color-mix(in srgb, var(--mk-text) 5%, transparent), inset 0 -1px color-mix(in srgb, var(--mk-text) 4%, transparent); pointer-events: none; z-index: -1; }
|
|
5
|
+
[data-mk-wheel-viewport] { height: calc(var(--mk-wheel-row) * var(--mk-wheel-rows)); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; -webkit-tap-highlight-color: transparent; mask-image: linear-gradient(transparent, #000 16%, #000 84%, transparent); }
|
|
6
|
+
[data-mk-wheel-viewport]::-webkit-scrollbar { display: none; }
|
|
7
|
+
[data-mk-wheel-viewport]:focus { outline: none; }
|
|
8
|
+
[data-mk-wheel-frame]:has([data-mk-wheel-viewport]:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 3px; border-radius: 24px; }
|
|
9
|
+
[data-mk-wheel-list] { padding-block: calc(var(--mk-wheel-row) * (var(--mk-wheel-rows) - 1) / 2); }
|
|
10
|
+
[data-mk-wheel-option] { display: grid; place-items: center; height: var(--mk-wheel-row); min-height: 44px; padding-inline: 1rem; color: var(--mk-text); font-size: 1.35rem; font-variant-numeric: tabular-nums; font-weight: 450; scroll-snap-align: center; cursor: pointer; user-select: none; transform-origin: center; backface-visibility: hidden; transition: color .18s ease; }
|
|
11
|
+
[data-mk-wheel-option][aria-selected='true'] { color: var(--mk-text); font-weight: 650; }
|
|
12
|
+
[data-mk-wheel-option]:not([aria-disabled='true']):hover { color: var(--mk-accent); }
|
|
13
|
+
[data-mk-wheel-option][aria-disabled='true'] { color: var(--mk-muted); cursor: not-allowed; }
|
|
14
|
+
[data-mk-wheel-empty] { display: block; text-align: center; color: var(--mk-muted); font-size: .875rem; }
|
|
15
|
+
[data-mk-disabled] [data-mk-wheel-viewport] { overflow: hidden; opacity: .5; }
|
|
16
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-wheel-option] { transition: none; transform: none !important; } }
|
|
17
|
+
@media (forced-colors: active) { [data-mk-wheel-highlight] { border: 2px solid Highlight; } }
|
package/src/css/tokens.css
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
--mk-ease-nav-move: cubic-bezier(0.38, 0.49, 0, 1.2);
|
|
16
16
|
--mk-ease-swatch: cubic-bezier(0.38, 0.49, 0, 1.5);
|
|
17
17
|
--mk-ease-swatch-light: cubic-bezier(0.11, 1, 0, 1);
|
|
18
|
+
--mk-ease-click: cubic-bezier(0.37, 1.42, 0.37, 1);
|
|
19
|
+
--mk-ease-playful: cubic-bezier(0.38, 0.49, 0, 2);
|
|
20
|
+
--mk-ease-pop: cubic-bezier(0.1, 0.8, 0, 1.1);
|
|
21
|
+
--mk-ease-wipe: cubic-bezier(0.38, 0.49, 0, 1);
|
|
18
22
|
--mk-ease-slot: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
19
23
|
/* Glass, sheet and control curves */
|
|
20
24
|
--mk-ease-glass: cubic-bezier(0.25, 1, 0.5, 1);
|