@yewdiel21/motion-kit 0.17.0 → 0.19.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 +240 -199
- package/dist/auto/index.d.ts +19 -3
- package/dist/auto/index.js +35 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-QFBOHPWZ.js → chunk-CQP66AZK.js} +2 -2
- package/dist/{chunk-QP4QJOEN.js → chunk-H4STLMCM.js} +2 -2
- package/dist/{chunk-BZLMCHUD.js → chunk-KDIJX6G4.js} +3 -3
- package/dist/{chunk-DOOMULVV.js → chunk-OLT3CWDJ.js} +4 -2
- package/dist/chunk-OLT3CWDJ.js.map +1 -0
- package/dist/{chunk-VJFPTLAK.js → chunk-PEDYJ2TV.js} +8 -22
- package/dist/chunk-PEDYJ2TV.js.map +1 -0
- package/dist/{chunk-7IQTMF7Y.js → chunk-Y4GIBTRG.js} +5130 -2153
- package/dist/chunk-Y4GIBTRG.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/code-block.css +267 -0
- package/dist/css/components/command-search.css +274 -2
- package/dist/css/components/dialog.css +264 -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/heatmap-chart.css +267 -1
- 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-area.css +261 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +300 -4
- 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 +217 -4
- package/dist/elements/index.js +614 -9
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Bn6hC87X.d.ts → engine-D6XV-WNt.d.ts} +390 -1
- package/dist/gl/index.js +2 -2
- package/dist/index.css +886 -177
- package/dist/index.d.ts +23 -23
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +126 -7
- package/dist/react/index.js +722 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-YS6TZjpa.d.ts → reduced-4croIXl-.d.ts} +1 -1
- package/dist/{store-CZ6vdtIE.d.ts → store-CAHWkurn.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +1357 -1160
- package/src/auto/auto-init.ts +341 -308
- 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/code-block/README.md +1 -1
- package/src/components/code-block/code-block.css +5 -0
- package/src/components/code-block/engine.ts +5 -0
- package/src/components/command-search/README.md +1 -1
- package/src/components/command-search/command-search.css +12 -2
- package/src/components/command-search/engine.ts +6 -0
- package/src/components/dialog/README.md +104 -103
- package/src/components/dialog/dialog.css +2 -0
- package/src/components/dialog/engine.ts +465 -449
- 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/heatmap-chart/README.md +48 -48
- package/src/components/heatmap-chart/engine.ts +6 -1
- package/src/components/heatmap-chart/heatmap-chart.css +5 -1
- package/src/components/icons.ts +1 -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-area/README.md +89 -0
- package/src/components/scroll-area/element.ts +35 -0
- package/src/components/scroll-area/engine.ts +528 -0
- package/src/components/scroll-area/react.tsx +67 -0
- package/src/components/scroll-area/scroll-area.css +260 -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/engine.ts +16 -4
- package/src/components/select/select.css +38 -4
- 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/core/timing.ts +2 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +321 -273
- package/src/index.ts +312 -295
- package/src/motion/morph.ts +5 -2
- package/src/react/index.ts +17 -0
- package/dist/chunk-7IQTMF7Y.js.map +0 -1
- package/dist/chunk-DOOMULVV.js.map +0 -1
- package/dist/chunk-VJFPTLAK.js.map +0 -1
- /package/dist/{chunk-QFBOHPWZ.js.map → chunk-CQP66AZK.js.map} +0 -0
- /package/dist/{chunk-QP4QJOEN.js.map → chunk-H4STLMCM.js.map} +0 -0
- /package/dist/{chunk-BZLMCHUD.js.map → chunk-KDIJX6G4.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,89 @@
|
|
|
1
|
+
# scroll-area
|
|
2
|
+
|
|
3
|
+
Área desplazable con barras propias del kit en lugar de la barra del sistema. El desplazamiento sigue siendo el nativo: teclado, tacto, inercia, `scroll-snap` y buscar en la página funcionan como siempre.
|
|
4
|
+
|
|
5
|
+
Origen: Melonmind oculta las barras nativas (`::-webkit-scrollbar { display: none }`) y no tiene barra visible. Esta barra se construye con su lenguaje: píldoras, superficies tonales y sus curvas.
|
|
6
|
+
|
|
7
|
+
- **Reposo:** una píldora de 6 px (el texto al 32 %) que aparece al pasar el puntero o al desplazarte y se desvanece 0,9 s después.
|
|
8
|
+
- **Hover:** sobre la barra aparece un carril tonal y la píldora crece a 10 px con la curva swing (`.38,.49,0,1.5`).
|
|
9
|
+
- **Arrastre:** al agarrarla se presiona (curva click), toma el acento y sigue al puntero sin suavizado.
|
|
10
|
+
- **Clic en el carril:** desliza hasta ese punto (0,6 s, curva signature). La rueda, un toque, una tecla u otro agarre lo cortan.
|
|
11
|
+
- **Bordes y volver arriba:** los bordes con más contenido se funden. Con `backToTop`, una píldora tonal entra con pop cuando has bajado una pantalla.
|
|
12
|
+
|
|
13
|
+
## HTML / Astro
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<mk-scroll-area class="mk-scroll-area" style="height: 20rem" label="Notas" back-to-top>
|
|
17
|
+
<article>…contenido largo…</article>
|
|
18
|
+
</mk-scroll-area>
|
|
19
|
+
<script>
|
|
20
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
21
|
+
defineElements(['mk-scroll-area']);
|
|
22
|
+
</script>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
El contenido se envuelve en un viewport `[data-mk-scroll-viewport]`; al destruirlo vuelve a su sitio.
|
|
26
|
+
|
|
27
|
+
## React
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
<ScrollArea style={{ height: 320 }} label="Notas" backToTop onScroll={({ y }) => setRead(y)}>
|
|
31
|
+
<article>…</article>
|
|
32
|
+
</ScrollArea>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Vanilla
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const area = createScrollArea(document.querySelector('.panel'), { axis: 'y' });
|
|
39
|
+
area.scrollTo({ top: 0 });
|
|
40
|
+
// Con un scroller que ya existe dentro de la raíz (no se envuelve nada):
|
|
41
|
+
createScrollArea(dialog, { viewport: dialog.querySelector('.list'), focusable: false, decorative: true });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**El área necesita una altura (`height` o `max-height`).** Si el contenido cabe, no se muestra ninguna barra.
|
|
45
|
+
|
|
46
|
+
## Opciones
|
|
47
|
+
|
|
48
|
+
| Opción | Atributo | Por defecto | Qué hace |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `visibility` | `visibility` | `'hover'` | `hover`: al pasar el puntero por el área o al desplazarte. `scroll`: solo al desplazarte. `always`: siempre que haya desbordamiento. Con el puntero encima, la barra siempre se ve. |
|
|
51
|
+
| `axis` | `axis` | `'both'` | `y`, `x` o `both`. Cada barra solo aparece si su eje desborda. |
|
|
52
|
+
| `idle` | `idle` | `900` | ms antes de ocultarse. |
|
|
53
|
+
| `fade` | `fade` | `true` | Funde los bordes con contenido oculto hasta 24 px. `false` lo quita; un número fija la profundidad. |
|
|
54
|
+
| `backToTop` | `back-to-top` | `false` | Botón «Volver arriba». `true`: aparece tras bajar una altura del área; un número: tras esos px. |
|
|
55
|
+
| `label` | `label` | — | Nombre accesible; el viewport pasa a ser una `region`. |
|
|
56
|
+
| `focusable` | `focusable` | `true` | El viewport entra en el orden de Tab para desplazarlo con el teclado. `false` si su contenido ya recibe foco (un menú). |
|
|
57
|
+
| `decorative` | `decorative` | `false` | Saca las barras del árbol accesible: dentro de un `menu` o `listbox`, cuyos hijos deben ser sus opciones. |
|
|
58
|
+
| `viewport` | — | — | Un scroller que ya existe dentro de la raíz; se usa tal cual. |
|
|
59
|
+
| `onScroll({ x, y })` | — | — | Progreso de cada eje, de 0 a 1. |
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
`scrollTo({ top, left }, { animate = true })` desliza (salta con `animate: false` o con movimiento reducido). `refresh()` vuelve a medir. `viewport` es el elemento que se desplaza. `destroy()` lo deshace todo.
|
|
64
|
+
|
|
65
|
+
## Eventos
|
|
66
|
+
|
|
67
|
+
`mk:scroll` con `{ x, y }`.
|
|
68
|
+
|
|
69
|
+
## Variables CSS
|
|
70
|
+
|
|
71
|
+
| Variable | Qué controla |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `--mk-scroll-thumb` | Píldora en reposo (texto al 32 %). |
|
|
74
|
+
| `--mk-scroll-thumb-hover` | Píldora con el puntero encima (48 %). |
|
|
75
|
+
| `--mk-scroll-thumb-active` | Píldora mientras se arrastra (el acento). |
|
|
76
|
+
| `--mk-scroll-rail` | Carril tonal (6 %). |
|
|
77
|
+
| `--mk-scroll-inset` | Distancia de los extremos de la barra a las esquinas (6 px). Súbela en contenedores muy redondeados. |
|
|
78
|
+
|
|
79
|
+
El motor escribe `--mk-scroll-fade-top/right/bottom/left` en el viewport y `--mk-scroll-view-top/right/bottom/left` en las barras: las barras siguen la caja del viewport aunque la raíz sea más grande (una paleta con campo y pie, el cuerpo de un diálogo).
|
|
80
|
+
|
|
81
|
+
## Accesibilidad
|
|
82
|
+
|
|
83
|
+
- **Viewport:** desplazamiento nativo y `tabindex="0"`; con `label` es una región con nombre. Al enfocarlo con el teclado, el área muestra un anillo de acento.
|
|
84
|
+
- **Barras:** `role="scrollbar"` con `aria-controls`, `aria-orientation` y `aria-valuenow` (0–100). No son paradas de Tab: el teclado desplaza el viewport.
|
|
85
|
+
- **Volver arriba:** es un `<button>` con nombre. Si desaparece mientras tiene el foco, el foco pasa al área.
|
|
86
|
+
- **Tacto:** se arrastra la píldora con `touch-action: none` y se mantiene la inercia del sistema.
|
|
87
|
+
- **Movimiento reducido:** sin crecimientos, fundidos animados ni deslizamientos; la barra salta a su sitio y todo sigue funcionando.
|
|
88
|
+
|
|
89
|
+
`destroy()` mata las animaciones, suelta la captura del puntero, limpia temporizadores, observadores y eventos, quita las barras y devuelve el contenido.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createScrollArea, type ScrollAreaInstance, type ScrollAreaOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
/** `fade` / `back-to-top`: present or "true" → true, "false" → false, a number → that many px. */
|
|
5
|
+
const flagOrNumber = (raw: unknown): boolean | number | undefined => {
|
|
6
|
+
if (raw === undefined) return undefined;
|
|
7
|
+
const text = String(raw).trim();
|
|
8
|
+
if (text === '' || text === 'true') return true;
|
|
9
|
+
if (text === 'false') return false;
|
|
10
|
+
const value = Number(text);
|
|
11
|
+
return Number.isFinite(value) ? value : true;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* <mk-scroll-area class="mk-scroll-area" style="height: 20rem" back-to-top>…</mk-scroll-area>
|
|
16
|
+
* Its content is wrapped in the viewport. Emits mk:scroll ({x, y}).
|
|
17
|
+
*/
|
|
18
|
+
export class MkScrollAreaElement extends MkElement<ScrollAreaInstance> {
|
|
19
|
+
static options: Record<string, OptionType> = {
|
|
20
|
+
visibility: 'string',
|
|
21
|
+
axis: 'string',
|
|
22
|
+
idle: 'number',
|
|
23
|
+
fade: 'string',
|
|
24
|
+
backToTop: 'string',
|
|
25
|
+
label: 'string',
|
|
26
|
+
focusable: 'boolean',
|
|
27
|
+
decorative: 'boolean',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
protected create(options: Record<string, unknown>) {
|
|
31
|
+
return createScrollArea(this, { ...options, fade: flagOrNumber(options.fade), backToTop: flagOrNumber(options.backToTop) } as ScrollAreaOptions);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const defineScrollArea = () => defineElement('mk-scroll-area', MkScrollAreaElement);
|