@yewdiel21/motion-kit 0.17.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 +23 -0
- package/dist/auto/index.d.ts +18 -3
- package/dist/auto/index.js +32 -2
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-VJFPTLAK.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-BZLMCHUD.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-7IQTMF7Y.js → chunk-Q7IW4FV3.js} +2580 -58
- 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 +11 -7
- 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 +130 -103
- 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 +62 -45
- package/dist/css/components/waveform-player.css +49 -34
- package/dist/css/components/wheel-picker.css +16 -15
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +206 -4
- package/dist/elements/index.js +581 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Bn6hC87X.d.ts → engine-t8d7brjn.d.ts} +341 -1
- package/dist/index.css +578 -170
- package/dist/index.d.ts +23 -23
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +113 -7
- package/dist/react/index.js +669 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-YS6TZjpa.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-CZ6vdtIE.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +1 -1
- package/registry.json +180 -0
- package/src/auto/auto-init.ts +31 -0
- 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/drilldown-menu.css +11 -7
- package/src/components/drilldown-menu/engine.ts +44 -4
- 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/engine.ts +550 -437
- package/src/components/signature-pad/signature-pad.css +130 -103
- 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/engine.ts +11 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
- package/src/components/waveform-player/README.md +1 -1
- package/src/components/waveform-player/engine.ts +344 -261
- package/src/components/waveform-player/waveform-player.css +50 -35
- package/src/components/wheel-picker/README.md +3 -1
- package/src/components/wheel-picker/element.ts +12 -5
- package/src/components/wheel-picker/engine.ts +37 -8
- package/src/components/wheel-picker/wheel-picker.css +17 -16
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +45 -0
- package/src/index.ts +311 -295
- package/src/react/index.ts +16 -0
- package/dist/chunk-7IQTMF7Y.js.map +0 -1
- /package/dist/{chunk-VJFPTLAK.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-BZLMCHUD.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* Tonal selectors: the selected surface rounds into a pill on the swatch curve. */
|
|
2
|
+
:is(.mk-radio-group, mk-radio-group) { display: block; min-width: 0; font: 500 .875rem/1.4 var(--mk-font); color: var(--mk-text); }
|
|
3
|
+
[data-mk-radio-body] { display: grid; gap: .5rem; }
|
|
4
|
+
[data-mk-radio-options] { display: flex; flex-wrap: wrap; gap: .375rem; }
|
|
5
|
+
[data-mk-radio-option] { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 44px; padding: .65rem 1rem; border: 1px solid transparent; border-radius: 16px; background: var(--mk-surface-2); cursor: pointer; transition: background .2s, color .2s, border-radius .4s var(--mk-ease-swatch), padding .4s var(--mk-ease-swatch), box-shadow .2s; -webkit-tap-highlight-color: transparent; }
|
|
6
|
+
[data-mk-radio-input] { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
7
|
+
[data-mk-radio-copy] { display: grid; gap: .2rem; }
|
|
8
|
+
[data-mk-radio-description] { color: var(--mk-muted); font-size: .8125rem; font-weight: 400; }
|
|
9
|
+
[data-mk-radio-mark] { flex: none; display: none; width: 20px; height: 20px; border: 1.5px solid var(--mk-outline); border-radius: 50%; box-sizing: border-box; }
|
|
10
|
+
[data-mk-radio-option][data-mk-selected] { border-radius: 64px; padding-inline: 1.2rem; background: var(--mk-text); color: var(--mk-bg); }
|
|
11
|
+
[data-mk-radio-option][data-mk-selected] [data-mk-radio-description] { color: inherit; opacity: .7; }
|
|
12
|
+
[data-mk-radio-option]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
|
|
13
|
+
[data-mk-radio-option]:has(:disabled) { opacity: .4; cursor: not-allowed; }
|
|
14
|
+
[data-mk-radio-message] { color: var(--mk-muted); font-size: .8125rem; font-weight: 400; }
|
|
15
|
+
[data-mk-radio-message]:empty { display: none; }
|
|
16
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-error] [data-mk-radio-message] { color: var(--mk-error); }
|
|
17
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='card'] [data-mk-radio-options] { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); gap: .75rem; }
|
|
18
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='card'] [data-mk-radio-option] { flex-direction: column; align-items: flex-start; padding: 1rem; border-radius: 24px; background: var(--mk-surface-1); border-color: var(--mk-outline-soft); }
|
|
19
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='card'] [data-mk-radio-option][data-mk-selected] { background: var(--mk-surface-3); color: var(--mk-text); box-shadow: inset 0 0 0 1px var(--mk-text); border-radius: 32px; }
|
|
20
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='list'] [data-mk-radio-options] { display: grid; gap: .375rem; }
|
|
21
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='list'] [data-mk-radio-option] { background: transparent; border-radius: 12px; }
|
|
22
|
+
:is(.mk-radio-group, mk-radio-group)[data-mk-variant='list'] [data-mk-radio-option][data-mk-selected] { background: var(--mk-surface-2); color: var(--mk-text); border-radius: 16px; }
|
|
23
|
+
:is(.mk-radio-group, mk-radio-group):is([data-mk-variant='card'], [data-mk-variant='list']) [data-mk-radio-mark] { display: block; }
|
|
24
|
+
[data-mk-radio-option][data-mk-selected] [data-mk-radio-mark] { border: 6px solid var(--mk-text); }
|
|
25
|
+
@media (hover: hover) { [data-mk-radio-option]:not(:has(:disabled)):hover { background: var(--mk-surface-3); border-radius: 24px; } [data-mk-radio-option][data-mk-selected]:hover { background: var(--mk-text); color: var(--mk-bg); border-radius: 64px; } }
|
|
26
|
+
@media (hover: hover) { :is(.mk-radio-group, mk-radio-group):is([data-mk-variant='card'], [data-mk-variant='list']) [data-mk-radio-option][data-mk-selected]:hover { background: var(--mk-surface-3); color: var(--mk-text); border-radius: 24px; } }
|
|
27
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-radio-option] { transition: none; } }
|
|
28
|
+
@media (forced-colors: active) { [data-mk-radio-option] { border: 1px solid ButtonText; } [data-mk-radio-option][data-mk-selected] { outline: 2px solid Highlight; } }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createRadioGroup, type RadioGroupOptions, type RadioGroupInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface RadioGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<RadioGroupOptions, 'value' | 'defaultValue'> {
|
|
7
|
+
value?: string | null;
|
|
8
|
+
defaultValue?: string | null;
|
|
9
|
+
instanceRef?: MutableRefObject<RadioGroupInstance | null>;
|
|
10
|
+
/** The engine owns this component's DOM. */
|
|
11
|
+
children?: never;
|
|
12
|
+
dangerouslySetInnerHTML?: never;
|
|
13
|
+
}
|
|
14
|
+
export function RadioGroup({ value, defaultValue, items, name, label, disabled, required, error, variant, onChange, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: RadioGroupProps) {
|
|
15
|
+
const root = useRef<HTMLDivElement>(null);
|
|
16
|
+
const engine = useRef<RadioGroupInstance | null>(null);
|
|
17
|
+
const latestValue = useLatest(value);
|
|
18
|
+
const change = useLatest(onChange);
|
|
19
|
+
|
|
20
|
+
const baseline = useRef(value !== undefined ? value : defaultValue);
|
|
21
|
+
const current = useRef(baseline.current);
|
|
22
|
+
const reconcile = () => queueMicrotask(() => {
|
|
23
|
+
if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
|
|
24
|
+
});
|
|
25
|
+
useIsomorphicLayoutEffect(() => {
|
|
26
|
+
const created = createRadioGroup(root.current, {
|
|
27
|
+
items, name, label, disabled, required, error, variant, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
|
|
28
|
+
onChange: (next) => { change.current?.(next); reconcile(); },
|
|
29
|
+
|
|
30
|
+
});
|
|
31
|
+
engine.current = created;
|
|
32
|
+
const form = root.current?.closest('form');
|
|
33
|
+
const pending = new Set<ReturnType<typeof setTimeout>>();
|
|
34
|
+
const reset = (event: Event) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
pending.delete(timer);
|
|
37
|
+
if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
|
|
38
|
+
}, 0);
|
|
39
|
+
pending.add(timer);
|
|
40
|
+
};
|
|
41
|
+
form?.addEventListener('reset', reset);
|
|
42
|
+
return () => {
|
|
43
|
+
form?.removeEventListener('reset', reset);
|
|
44
|
+
pending.forEach((timer) => clearTimeout(timer));
|
|
45
|
+
current.current = created.value; created.destroy(); engine.current = null;
|
|
46
|
+
};
|
|
47
|
+
// Callbacks and controlled value are read through refs.
|
|
48
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
|
+
}, [items, name, label, disabled, required, error, variant]);
|
|
50
|
+
useIsomorphicLayoutEffect(() => {
|
|
51
|
+
if (value !== undefined) engine.current?.setValue(value, false);
|
|
52
|
+
});
|
|
53
|
+
useIsomorphicLayoutEffect(() => {
|
|
54
|
+
if (!instanceRef) return;
|
|
55
|
+
instanceRef.current = engine.current;
|
|
56
|
+
return () => { instanceRef.current = null; };
|
|
57
|
+
}, [instanceRef, items, name, label, disabled, required, error, variant]);
|
|
58
|
+
return <div {...props} ref={root} className={['mk-radio-group', className].filter(Boolean).join(' ')} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { blurIn, press, rollDigits } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface ChoiceItem { value: string; label: string; description?: string; disabled?: boolean; symbol?: string }
|
|
8
|
+
export interface ChoiceOptions {
|
|
9
|
+
items?: ChoiceItem[]; value?: string | null; defaultValue?: string | null; name?: string; label?: string;
|
|
10
|
+
disabled?: boolean; required?: boolean; error?: string; variant?: string;
|
|
11
|
+
caption?: (value: string | null) => string;
|
|
12
|
+
eventValue?: (value: string | null) => unknown;
|
|
13
|
+
onChange?: (value: string | null) => void;
|
|
14
|
+
/** Private: decorates the options after each paint; `animate` is true only for live changes. */
|
|
15
|
+
decorate?: (labels: HTMLLabelElement[], index: number, previous: number, animate: boolean, keep: (effect: gsap.core.Animation | null) => void) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface ChoiceInstance extends Instance { readonly value: string | null; setValue(value: string | null, notify?: boolean): void; reset(notify?: boolean): void }
|
|
18
|
+
let sequence = 0;
|
|
19
|
+
|
|
20
|
+
/** Private native-radio renderer. The browser owns arrow keys, validation and submission. */
|
|
21
|
+
export function createChoices(root: HTMLElement | null, prefix: string, options: ChoiceOptions): ChoiceInstance {
|
|
22
|
+
if (!root) return inert({ value: null, setValue() {}, reset() {} });
|
|
23
|
+
registerMotion();
|
|
24
|
+
const scope = createScope();
|
|
25
|
+
const effects = new Set<gsap.core.Animation>();
|
|
26
|
+
const keep = (effect: gsap.core.Animation | null) => {
|
|
27
|
+
for (const prior of effects) if (prior.totalProgress() === 1) effects.delete(prior);
|
|
28
|
+
if (effect) effects.add(effect);
|
|
29
|
+
};
|
|
30
|
+
scope.add(() => { effects.forEach((effect) => effect.kill()); effects.clear(); });
|
|
31
|
+
const part = <K extends keyof HTMLElementTagNameMap>(tag: K, parent: Element, name: string) => {
|
|
32
|
+
const el = document.createElement(tag);
|
|
33
|
+
el.setAttribute(`data-mk-${prefix}-${name}`, '');
|
|
34
|
+
parent.append(el);
|
|
35
|
+
return el;
|
|
36
|
+
};
|
|
37
|
+
const shell = part('div', root, 'body');
|
|
38
|
+
scope.add(() => shell.remove());
|
|
39
|
+
const group = part('div', shell, 'options');
|
|
40
|
+
group.setAttribute('role', 'radiogroup');
|
|
41
|
+
group.setAttribute('aria-label', options.label ?? 'Elige una opción');
|
|
42
|
+
group.setAttribute('aria-required', String(Boolean(options.required)));
|
|
43
|
+
if (options.disabled) group.setAttribute('aria-disabled', 'true');
|
|
44
|
+
const message = part('span', shell, 'message');
|
|
45
|
+
message.id = `mk-${prefix}-message-${++sequence}`;
|
|
46
|
+
message.setAttribute('aria-live', 'polite');
|
|
47
|
+
group.setAttribute('aria-describedby', message.id);
|
|
48
|
+
if (options.error) group.setAttribute('aria-invalid', 'true');
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
const items = (Array.isArray(options.items) ? options.items : []).filter((item) => {
|
|
51
|
+
if (!item || typeof item.value !== 'string' || seen.has(item.value)) return false;
|
|
52
|
+
seen.add(item.value); return true;
|
|
53
|
+
});
|
|
54
|
+
const name = options.name ?? `mk-${prefix}-${sequence}`;
|
|
55
|
+
const valid = (candidate: string | null | undefined) => candidate != null && items.some((item) => item.value === candidate) ? candidate : null;
|
|
56
|
+
const initial = valid(options.defaultValue !== undefined ? options.defaultValue : options.value);
|
|
57
|
+
let value = valid(options.value !== undefined ? options.value : initial);
|
|
58
|
+
const labels: HTMLLabelElement[] = [];
|
|
59
|
+
const inputs: HTMLInputElement[] = [];
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const label = part('label', group, 'option');
|
|
62
|
+
const input = part('input', label, 'input');
|
|
63
|
+
input.type = 'radio'; input.name = name; input.value = item.value;
|
|
64
|
+
input.required = Boolean(options.required);
|
|
65
|
+
input.disabled = Boolean(options.disabled || item.disabled);
|
|
66
|
+
input.defaultChecked = item.value === initial;
|
|
67
|
+
const mark = part('span', label, 'mark');
|
|
68
|
+
mark.setAttribute('aria-hidden', 'true'); mark.textContent = item.symbol ?? '';
|
|
69
|
+
const copy = part('span', label, 'copy');
|
|
70
|
+
part('span', copy, 'label').textContent = item.label;
|
|
71
|
+
if (item.description) part('span', copy, 'description').textContent = item.description;
|
|
72
|
+
if (options.error) { input.setAttribute('aria-invalid', 'true'); input.setAttribute('aria-describedby', message.id); }
|
|
73
|
+
labels.push(label); inputs.push(input);
|
|
74
|
+
}
|
|
75
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
76
|
+
scope.attr(root, 'data-mk-variant', options.variant ?? 'pill');
|
|
77
|
+
scope.attr(root, 'data-mk-disabled', options.disabled ? '' : null);
|
|
78
|
+
scope.attr(root, 'data-mk-error', options.error ? '' : null);
|
|
79
|
+
const paint = (animate: boolean, before: string | null) => {
|
|
80
|
+
inputs.forEach((input, index) => {
|
|
81
|
+
input.checked = input.value === value;
|
|
82
|
+
labels[index]!.toggleAttribute('data-mk-selected', input.checked);
|
|
83
|
+
});
|
|
84
|
+
const caption = options.error ?? (items.length ? options.caption?.(value) ?? '' : 'No hay opciones');
|
|
85
|
+
if (animate && !reduced()) keep(rollDigits(message, caption, Number(value) >= Number(before) ? 1 : -1, 0.28));
|
|
86
|
+
else message.textContent = caption;
|
|
87
|
+
const at = (candidate: string | null) => items.findIndex((item) => item.value === candidate);
|
|
88
|
+
options.decorate?.(labels, at(value), at(before), animate && before !== value && !reduced(), keep);
|
|
89
|
+
};
|
|
90
|
+
const choose = (next: string | null, notify = false, animate = true) => {
|
|
91
|
+
if (scope.disposed) return;
|
|
92
|
+
const target = valid(next); const before = value;
|
|
93
|
+
value = target; paint(animate, before);
|
|
94
|
+
if (before === value) return;
|
|
95
|
+
const index = items.findIndex((item) => item.value === value);
|
|
96
|
+
if (animate && index >= 0 && !reduced()) {
|
|
97
|
+
keep(press(labels[index]));
|
|
98
|
+
keep(gsap.fromTo(labels[index]!.querySelector(`[data-mk-${prefix}-mark]`), { scale: 0.65, filter: 'blur(4px)' }, { scale: 1, filter: 'blur(0px)', duration: 0.4, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform,filter' }));
|
|
99
|
+
}
|
|
100
|
+
if (notify) {
|
|
101
|
+
const emitted = value;
|
|
102
|
+
options.onChange?.(emitted);
|
|
103
|
+
if (!scope.disposed) root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value: options.eventValue ? options.eventValue(emitted) : emitted } }));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
inputs.forEach((input) => scope.on(input, 'change', () => {
|
|
107
|
+
if (!input.disabled && !input.matches(':disabled') && input.checked) choose(input.value, true);
|
|
108
|
+
else paint(false, value);
|
|
109
|
+
}));
|
|
110
|
+
const form = inputs[0]?.form;
|
|
111
|
+
if (form) scope.on(form, 'reset', (event) => {
|
|
112
|
+
// Reset is cancelable. Run after the browser restores checked/defaultChecked.
|
|
113
|
+
scope.timeout(() => { if (!scope.disposed && !event.defaultPrevented) choose(initial, false); }, 0);
|
|
114
|
+
});
|
|
115
|
+
paint(false, value);
|
|
116
|
+
keep(blurIn(labels, { from: 'soft', stagger: 0.035, duration: 0.35 }));
|
|
117
|
+
return { get value() { return value; }, setValue: choose, reset: (notify = false) => choose(initial, notify), destroy: () => scope.dispose() };
|
|
118
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# RatingScale
|
|
2
|
+
|
|
3
|
+
Una escala de feedback, distinta de star-rating: once opciones numéricas 0–10 para NPS o cinco caras con etiquetas para sentimiento. Una cápsula oscura identifica la respuesta elegida y la cifra/caption cambia con rollDigits.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createRatingScale } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/rating-scale';
|
|
8
|
+
|
|
9
|
+
const control = createRatingScale(document.querySelector('.mk-rating-scale'), {
|
|
10
|
+
name: 'recommend', label: '¿Lo recomendarías?', value: null, onChange: console.log,
|
|
11
|
+
});
|
|
12
|
+
control.setValue(8);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Opciones y contrato
|
|
16
|
+
|
|
17
|
+
`variant: numbered | sentiment` (numbered), `min` (0 o 1), `max` (10 o 5), `labels`, `value` / `defaultValue` (null), `name`, `label`, `required`, `disabled`, `error`, `onChange(value)`.
|
|
18
|
+
|
|
19
|
+
Cero es una respuesta válida; null significa sin respuesta. Los valores se redondean y limitan al rango. La escala admite hasta 21 opciones. mk:change entrega un número o null, también en el elemento personalizado.
|
|
20
|
+
|
|
21
|
+
La instancia expone `value: number | null`, `setValue(value, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:change` con `detail: { value }`.
|
|
22
|
+
|
|
23
|
+
## React y HTML
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { RatingScale } from '@yewdiel21/motion-kit/react';
|
|
27
|
+
|
|
28
|
+
<RatingScale value={score} onChange={setScore} name="recommend" />
|
|
29
|
+
<RatingScale variant="sentiment" value={feeling} onChange={setFeeling} />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<mk-rating-scale class="mk-rating-scale" name="recommend" label="¿Lo recomendarías?" value="8"></mk-rating-scale>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Registra con `defineRatingScale()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`.
|
|
39
|
+
|
|
40
|
+
## Accesibilidad y lifecycle
|
|
41
|
+
|
|
42
|
+
Se conserva el input nativo, su etiqueta, teclado, tacto, validación y participación en formularios. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
|
|
43
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createRatingScale, type RatingScaleInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
/** Light DOM; attribute changes keep the current imperative value and reset baseline. */
|
|
5
|
+
export class MkRatingScaleElement extends MkElement<RatingScaleInstance> {
|
|
6
|
+
static options: Record<string, OptionType> = {"min":"number","max":"number","value":"number","variant":"string","labels":"json","label":"string","name":"string","disabled":"boolean","required":"boolean","error":"string"};
|
|
7
|
+
private baseline: number | null | undefined;
|
|
8
|
+
private saved: number | null | undefined;
|
|
9
|
+
protected create(options: Record<string, unknown>) {
|
|
10
|
+
const created = createRatingScale(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | null | undefined, defaultValue: this.baseline });
|
|
11
|
+
if (this.baseline === undefined) this.baseline = created.value;
|
|
12
|
+
this.saved = undefined;
|
|
13
|
+
return created;
|
|
14
|
+
}
|
|
15
|
+
disconnectedCallback() {
|
|
16
|
+
this.saved = this.instance?.value;
|
|
17
|
+
super.disconnectedCallback();
|
|
18
|
+
}
|
|
19
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
20
|
+
if (previous === next) return;
|
|
21
|
+
if (name === 'value' && (!this.instance || !this.isConnected)) {
|
|
22
|
+
this.saved = next === null ? this.baseline ?? null : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? null;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!this.instance || !this.isConnected) return;
|
|
26
|
+
if (name === 'value') {
|
|
27
|
+
const parsed = next === null ? this.baseline ?? null : Number(next);
|
|
28
|
+
this.instance.setValue(parsed, false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.saved = this.instance.value;
|
|
32
|
+
super.attributeChangedCallback(name, previous, next);
|
|
33
|
+
}
|
|
34
|
+
get value(): number | null { return this.instance?.value ?? this.saved ?? null; }
|
|
35
|
+
set value(next: number | null) { this.setValue(next); }
|
|
36
|
+
setValue(value: number | null, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
|
|
37
|
+
reset(notify = false) { this.instance?.reset(notify); }
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
export const defineRatingScale = () => defineElement('mk-rating-scale', MkRatingScaleElement);
|
|
41
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { gsap, EASE } from '../../core/gsap';
|
|
2
|
+
import { createChoices } from '../radio-group/shared';
|
|
3
|
+
import { inert, type Instance } from '../../core/types';
|
|
4
|
+
|
|
5
|
+
export interface RatingScaleOptions {
|
|
6
|
+
/** Inclusive numeric scale; defaults to 0…10, or 1…5 for sentiment. */
|
|
7
|
+
min?: number; max?: number; value?: number | null; defaultValue?: number | null;
|
|
8
|
+
variant?: 'numbered' | 'sentiment';
|
|
9
|
+
labels?: string[]; label?: string; name?: string;
|
|
10
|
+
disabled?: boolean; required?: boolean; error?: string;
|
|
11
|
+
onChange?: (value: number | null) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface RatingScaleInstance extends Instance {
|
|
14
|
+
readonly value: number | null;
|
|
15
|
+
setValue(value: number | null, notify?: boolean): void;
|
|
16
|
+
reset(notify?: boolean): void;
|
|
17
|
+
}
|
|
18
|
+
const SENTIMENTS = ['Muy mal', 'Mal', 'Regular', 'Bien', 'Muy bien'];
|
|
19
|
+
const SYMBOLS = ['⌢', '⌢', '−', '⌣', '⌣'];
|
|
20
|
+
export function createRatingScale(root: HTMLElement | null, options: RatingScaleOptions = {}): RatingScaleInstance {
|
|
21
|
+
if (!root) return inert({ value: null, setValue() {}, reset() {} });
|
|
22
|
+
const sentiment = options.variant === 'sentiment';
|
|
23
|
+
const min = Number.isFinite(options.min) ? Math.round(options.min!) : sentiment ? 1 : 0;
|
|
24
|
+
const max = Number.isFinite(options.max) ? Math.max(min, Math.min(min + 20, Math.round(options.max!))) : sentiment ? min + 4 : min + 10;
|
|
25
|
+
const labels = options.labels ?? (sentiment ? SENTIMENTS : []);
|
|
26
|
+
const items = Array.from({ length: max - min + 1 }, (_, i) => ({
|
|
27
|
+
value: String(min + i), label: labels[i] ?? String(min + i), symbol: sentiment ? SYMBOLS[Math.min(4, Math.round(i / Math.max(1, max - min) * 4))] : String(min + i),
|
|
28
|
+
}));
|
|
29
|
+
const normalize = (value: number | null | undefined) => value != null && Number.isFinite(value) ? String(Math.max(min, Math.min(max, Math.round(value)))) : null;
|
|
30
|
+
const instance = createChoices(root, 'rating-scale', {
|
|
31
|
+
...options, variant: sentiment ? 'sentiment' : 'numbered', items, value: normalize(options.value !== undefined ? options.value : options.defaultValue), defaultValue: normalize(options.defaultValue !== undefined ? options.defaultValue : options.value),
|
|
32
|
+
caption: (value) => value === null ? 'Sin responder' : labels[Number(value) - min] ?? `${value} de ${max}`,
|
|
33
|
+
eventValue: (value) => value === null ? null : Number(value),
|
|
34
|
+
onChange: (value) => options.onChange?.(value === null ? null : Number(value)),
|
|
35
|
+
decorate(labels, index, previous, animate, keep) {
|
|
36
|
+
// Numbers fill up to the answer, like the equalizer's lit bars.
|
|
37
|
+
labels.forEach((label, i) => label.toggleAttribute('data-mk-in-range', !sentiment && index >= 0 && i < index));
|
|
38
|
+
if (!animate || index < 0) return;
|
|
39
|
+
if (sentiment) {
|
|
40
|
+
// The chosen face reacts to its mood: positive jumps, neutral wavers, negative nods down.
|
|
41
|
+
const mood = index / Math.max(1, labels.length - 1) * 2 - 1;
|
|
42
|
+
const mark = labels[index]!.querySelector('[data-mk-rating-scale-mark]');
|
|
43
|
+
keep(gsap.timeline()
|
|
44
|
+
.to(mark, { y: mood > 0 ? -8 * mood : 4 * -mood, rotate: mood === 0 ? 8 : 12 * mood, duration: .14, ease: 'power2.out' })
|
|
45
|
+
.to(mark, { y: 0, rotate: 0, duration: .55, ease: EASE.click, clearProps: 'y,rotate' }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// A short wave travels from the previous answer to the new one.
|
|
49
|
+
const from = previous >= 0 ? previous : 0, step = index >= from ? 1 : -1;
|
|
50
|
+
for (let i = from, k = 0; i !== index; i += step, k++) {
|
|
51
|
+
keep(gsap.timeline({ delay: Math.min(k * .03, .3) })
|
|
52
|
+
.to(labels[i]!, { y: -5, duration: .12, ease: 'power2.out' })
|
|
53
|
+
.to(labels[i]!, { y: 0, duration: .4, ease: EASE.click, clearProps: 'y' }));
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
get value() { return instance.value === null ? null : Number(instance.value); },
|
|
59
|
+
setValue: (value, notify = false) => instance.setValue(normalize(value), notify),
|
|
60
|
+
reset: (notify = false) => instance.reset(notify),
|
|
61
|
+
destroy: () => instance.destroy(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
:is(.mk-rating-scale, mk-rating-scale) { display: block; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
|
|
2
|
+
[data-mk-rating-scale-body] { display: grid; gap: .875rem; }
|
|
3
|
+
[data-mk-rating-scale-options] { display: flex; flex-wrap: wrap; gap: .375rem; }
|
|
4
|
+
[data-mk-rating-scale-option] { position: relative; flex: 1 0 2.25rem; display: grid; place-items: center; gap: .5rem; min-width: 0; min-height: 44px; border: 1px solid transparent; border-radius: 12px; background: var(--mk-surface-2); cursor: pointer; transition: border-radius .4s var(--mk-ease-swatch), background .2s, color .2s, translate .35s var(--mk-ease-swatch); -webkit-tap-highlight-color: transparent; }
|
|
5
|
+
[data-mk-rating-scale-input] { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
6
|
+
[data-mk-rating-scale-mark] { display: grid; place-items: center; width: 100%; min-height: 44px; font-variant-numeric: tabular-nums; }
|
|
7
|
+
[data-mk-rating-scale-copy] { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
|
|
8
|
+
[data-mk-rating-scale-option][data-mk-in-range] { background: color-mix(in srgb, var(--mk-text) 12%, var(--mk-surface-2)); }
|
|
9
|
+
[data-mk-rating-scale-option][data-mk-selected] { background: var(--mk-text); color: var(--mk-bg); border-radius: 64px; }
|
|
10
|
+
[data-mk-rating-scale-option]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
|
|
11
|
+
[data-mk-rating-scale-option]:has(:disabled) { opacity: .4; cursor: not-allowed; }
|
|
12
|
+
[data-mk-rating-scale-message] { min-height: 1.2em; text-align: center; color: var(--mk-muted); font-size: .8125rem; }
|
|
13
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-error] [data-mk-rating-scale-message] { color: var(--mk-error); }
|
|
14
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-options] { gap: .5rem; }
|
|
15
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-option] { padding: .875rem .375rem .65rem; border-radius: 24px; flex-basis: 3rem; }
|
|
16
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-option][data-mk-selected] { border-radius: 32px; }
|
|
17
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-mark] { position: relative; box-sizing: border-box; width: 38px; height: 38px; min-height: 0; padding-top: 10px; border: 1.5px solid currentColor; border-radius: 50%; font: 500 25px/.7 var(--mk-font); }
|
|
18
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-mark]::before { content: '··'; position: absolute; top: -2px; left: 8px; letter-spacing: 5px; font-size: 24px; line-height: 1; }
|
|
19
|
+
:is(.mk-rating-scale, mk-rating-scale)[data-mk-variant='sentiment'] [data-mk-rating-scale-copy] { position: static; width: auto; height: auto; clip-path: none; white-space: normal; text-align: center; font-size: .6875rem; }
|
|
20
|
+
@media (hover: hover) { [data-mk-rating-scale-option]:not(:has(:disabled)):hover { translate: 0 -3px; border-radius: 24px; background: var(--mk-surface-3); } [data-mk-rating-scale-option][data-mk-selected]:hover { background: var(--mk-text); border-radius: 64px; } }
|
|
21
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-rating-scale-option] { transition: none; } }
|
|
22
|
+
@media (forced-colors: active) { [data-mk-rating-scale-option] { border: 1px solid ButtonText; } [data-mk-rating-scale-option][data-mk-selected] { outline: 2px solid Highlight; } }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createRatingScale, type RatingScaleOptions, type RatingScaleInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface RatingScaleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<RatingScaleOptions, 'value' | 'defaultValue'> {
|
|
7
|
+
value?: number | null;
|
|
8
|
+
defaultValue?: number | null;
|
|
9
|
+
instanceRef?: MutableRefObject<RatingScaleInstance | null>;
|
|
10
|
+
/** The engine owns this component's DOM. */
|
|
11
|
+
children?: never;
|
|
12
|
+
dangerouslySetInnerHTML?: never;
|
|
13
|
+
}
|
|
14
|
+
export function RatingScale({ value, defaultValue, min, max, variant, labels, label, name, disabled, required, error, onChange, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: RatingScaleProps) {
|
|
15
|
+
const root = useRef<HTMLDivElement>(null);
|
|
16
|
+
const engine = useRef<RatingScaleInstance | null>(null);
|
|
17
|
+
const latestValue = useLatest(value);
|
|
18
|
+
const change = useLatest(onChange);
|
|
19
|
+
|
|
20
|
+
const baseline = useRef(value !== undefined ? value : defaultValue);
|
|
21
|
+
const current = useRef(baseline.current);
|
|
22
|
+
const reconcile = () => queueMicrotask(() => {
|
|
23
|
+
if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
|
|
24
|
+
});
|
|
25
|
+
useIsomorphicLayoutEffect(() => {
|
|
26
|
+
const created = createRatingScale(root.current, {
|
|
27
|
+
min, max, variant, labels, label, name, disabled, required, error, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
|
|
28
|
+
onChange: (next) => { change.current?.(next); reconcile(); },
|
|
29
|
+
|
|
30
|
+
});
|
|
31
|
+
engine.current = created;
|
|
32
|
+
const form = root.current?.closest('form');
|
|
33
|
+
const pending = new Set<ReturnType<typeof setTimeout>>();
|
|
34
|
+
const reset = (event: Event) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
pending.delete(timer);
|
|
37
|
+
if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
|
|
38
|
+
}, 0);
|
|
39
|
+
pending.add(timer);
|
|
40
|
+
};
|
|
41
|
+
form?.addEventListener('reset', reset);
|
|
42
|
+
return () => {
|
|
43
|
+
form?.removeEventListener('reset', reset);
|
|
44
|
+
pending.forEach((timer) => clearTimeout(timer));
|
|
45
|
+
current.current = created.value; created.destroy(); engine.current = null;
|
|
46
|
+
};
|
|
47
|
+
// Callbacks and controlled value are read through refs.
|
|
48
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
|
+
}, [min, max, variant, labels, label, name, disabled, required, error]);
|
|
50
|
+
useIsomorphicLayoutEffect(() => {
|
|
51
|
+
if (value !== undefined) engine.current?.setValue(value, false);
|
|
52
|
+
});
|
|
53
|
+
useIsomorphicLayoutEffect(() => {
|
|
54
|
+
if (!instanceRef) return;
|
|
55
|
+
instanceRef.current = engine.current;
|
|
56
|
+
return () => { instanceRef.current = null; };
|
|
57
|
+
}, [instanceRef, min, max, variant, labels, label, name, disabled, required, error]);
|
|
58
|
+
return <div {...props} ref={root} className={['mk-rating-scale', className].filter(Boolean).join(' ')} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Scroll preview dock
|
|
2
|
+
|
|
3
|
+
Section links with thumbnails, a travelling selected pill and an adjacent hover/focus preview. Selection follows the actual scroll position in the supplied scroller.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const dock = createScrollPreviewDock(document.querySelector('#dock'), {
|
|
7
|
+
scroller: document.querySelector('#story'),
|
|
8
|
+
offset: 12,
|
|
9
|
+
items: [
|
|
10
|
+
{ id: 'idea', label: 'La idea', description: 'Un punto de partida', thumbnail: '/idea.webp' },
|
|
11
|
+
{ id: 'shape', label: 'La forma', thumbnail: '/shape.webp' },
|
|
12
|
+
],
|
|
13
|
+
});
|
|
14
|
+
dock.select('shape');
|
|
15
|
+
dock.destroy();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Exports: `createScrollPreviewDock`, `ScrollPreviewDockItem`, `ScrollPreviewDockOptions`, `ScrollPreviewDockInstance`; React `ScrollPreviewDock`, `ScrollPreviewDockProps`; elements `MkScrollPreviewDockElement`, `defineScrollPreviewDock` (`mk-scroll-preview-dock`). CSS: `scroll-preview-dock.css` plus kit tokens/theme/fonts.
|
|
19
|
+
|
|
20
|
+
Options: `items`, `scroller` (element/selector, otherwise window), `offset` (pixels), `label`, `disabled`, `onChange(id)`. Each item has `id`, `label`, optional `description`, `thumbnail`, `section` (element/selector, defaults to the element with that ID), `disabled`. Give each target section a real `id` to preserve native fragment-link behavior. Missing sections leave a working native link and do not initiate scripted scrolling.
|
|
21
|
+
|
|
22
|
+
Instance: `select(id)`, `update()`, `setOptions(partial)`, readonly `value`, `destroy()`. `mk:change` emits `{value}` for the tracked section. Hover/focus changes only the preview. ArrowLeft/ArrowRight and Home/End move focus among enabled links; normal Tab/Enter and modified link clicks retain native semantics. Wheel/touch interrupts scripted scrolling. Reduced motion jumps immediately.
|
|
23
|
+
|
|
24
|
+
React owns an empty host and accepts data through `items`, with optional `instanceRef`; children/HTML are ignored at runtime. An element accepts JSON `items`, scalar attributes and `setOptions`, `items`, `select`/`update` methods. Updates retain the engine and restore focus to a surviving link. Destroy removes listeners, pending animation frames and scroll tweens, restoring authored content.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createScrollPreviewDock, type ScrollPreviewDockInstance, type ScrollPreviewDockOptions, type ScrollPreviewDockItem } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkScrollPreviewDockElement extends MkElement<ScrollPreviewDockInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = {"items":"json","scroller":"string","offset":"number","label":"string","disabled":"boolean"};
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createScrollPreviewDock(this, options); }
|
|
7
|
+
setOptions(options: Partial<ScrollPreviewDockOptions>) { this.instance?.setOptions(options); }
|
|
8
|
+
|
|
9
|
+
select(id: string) { this.instance?.select(id); }
|
|
10
|
+
update() { this.instance?.update(); }
|
|
11
|
+
get value() { return this.instance?.value ?? ''; }
|
|
12
|
+
set items(value: ScrollPreviewDockItem[]) { this.instance?.setOptions({ items: value }); }
|
|
13
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
14
|
+
if (previous === next || !this.isConnected || !this.instance) return;
|
|
15
|
+
const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
16
|
+
const defaults: Record<string, unknown> = {"items":[],"scroller":null,"offset":0,"label":"Explorar secciones","disabled":false};
|
|
17
|
+
this.instance.setOptions({ [option]: this.options[option] ?? defaults[option] });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const defineScrollPreviewDock = () => defineElement('mk-scroll-preview-dock', MkScrollPreviewDockElement);
|