@blukavantui/community 1.0.1 → 1.2.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 +11 -5
- package/dist/build-audit.json +234 -44
- package/dist/checkbox.css +1 -0
- package/dist/checkbox.d.ts +2 -0
- package/dist/checkbox.js +1 -0
- package/dist/chunk-2BSBXDOT.js +1 -0
- package/dist/chunk-4LHIQROI.js +0 -0
- package/dist/chunk-5ENZVZGF.js +0 -0
- package/dist/chunk-6T677NSC.js +1 -0
- package/dist/chunk-7CEOCZ5L.js +0 -0
- package/dist/chunk-7DXFNK7C.js +1 -0
- package/dist/chunk-AL2ETLJV.js +1 -0
- package/dist/chunk-BN3PDE5G.js +0 -0
- package/dist/chunk-CNIIMGT3.js +0 -0
- package/dist/chunk-CSTCEBEK.js +1 -0
- package/dist/chunk-DRZM2AL3.js +0 -0
- package/dist/chunk-EOKNLCNS.js +1 -0
- package/dist/chunk-EPPETNK6.js +1 -0
- package/dist/chunk-EPPSBHZR.js +1 -0
- package/dist/chunk-EPZGLHPD.js +0 -0
- package/dist/chunk-FNX33PDD.js +0 -0
- package/dist/{chunk-NTFHNL7T.js → chunk-G6OW4OZM.js} +1 -1
- package/dist/chunk-GS67ZBLI.js +1 -0
- package/dist/chunk-HO6CYWED.js +0 -0
- package/dist/{chunk-6EHKOVZK.js → chunk-HVR2UDFX.js} +1 -1
- package/dist/chunk-IJ62ILKE.js +0 -0
- package/dist/chunk-IKYBFMHI.js +1 -0
- package/dist/chunk-IO22GWON.js +0 -0
- package/dist/chunk-KVOA55NE.js +0 -0
- package/dist/{chunk-MZBB5ROJ.js → chunk-LF2HELEZ.js} +1 -1
- package/dist/chunk-MYVLBALF.js +1 -0
- package/dist/{chunk-UEOCR2C3.js → chunk-OLDA73XG.js} +2 -2
- package/dist/chunk-RWBLICYX.js +0 -0
- package/dist/chunk-RYD6RHRX.js +0 -0
- package/dist/chunk-T45FJNEQ.js +1 -0
- package/dist/chunk-TZSX4OTV.js +1 -0
- package/dist/chunk-UN2SLVFG.js +1 -0
- package/dist/{chunk-GK3INPAZ.js → chunk-V2YPIC6Z.js} +1 -1
- package/dist/chunk-V4ZQLQ7L.js +0 -0
- package/dist/chunk-VAM3FPCS.js +0 -0
- package/dist/chunk-VJVFHL6P.js +0 -0
- package/dist/chunk-VSTKXZYY.js +0 -0
- package/dist/chunk-W3H7XTA7.js +1 -0
- package/dist/chunk-WG4US4JH.js +1 -0
- package/dist/chunk-WGSTNXAQ.js +0 -0
- package/dist/chunk-WJHCVECU.js +1 -0
- package/dist/chunk-XVMJG26X.js +1 -0
- package/dist/chunk-Y4AXWTAD.js +0 -0
- package/dist/chunk-YCLR3IE3.js +0 -0
- package/dist/chunk-YLFXSHKZ.js +0 -0
- package/dist/chunk-ZDGZGAYG.js +0 -0
- package/dist/columns2.css +1 -0
- package/dist/columns2.d.ts +2 -0
- package/dist/columns2.js +1 -0
- package/dist/columns3.css +1 -0
- package/dist/columns3.d.ts +2 -0
- package/dist/columns3.js +1 -0
- package/dist/columns4.css +1 -0
- package/dist/columns4.d.ts +2 -0
- package/dist/columns4.js +1 -0
- package/dist/command-palette.js +1 -1
- package/dist/component-motion.css +39 -0
- package/dist/component-motion.d.ts +45 -0
- package/dist/component-motion.js +1 -0
- package/dist/content.js +1 -1
- package/dist/core.css +8 -0
- package/dist/core.js +1 -1
- package/dist/data-motion.css +4 -0
- package/dist/data-motion.d.ts +23 -0
- package/dist/data-motion.js +1 -0
- package/dist/data.css +63 -0
- package/dist/data.d.ts +1 -0
- package/dist/data.js +1 -1
- package/dist/date-picker.css +5 -5
- package/dist/date-picker.js +1 -1
- package/dist/date-range-picker.css +5 -5
- package/dist/date-range-picker.js +1 -1
- package/dist/date-time-picker.css +5 -5
- package/dist/date-time-picker.js +1 -1
- package/dist/draft-components.json +77 -0
- package/dist/drawer.js +1 -1
- package/dist/essentials.css +8 -0
- package/dist/feedback.js +1 -1
- package/dist/form-field.css +1 -0
- package/dist/form-field.d.ts +2 -0
- package/dist/form-field.js +1 -0
- package/dist/form-layout/choice.d.ts +15 -0
- package/dist/form-layout/layout.d.ts +12 -0
- package/dist/form-layout/select.d.ts +3 -0
- package/dist/form-layout/shared.d.ts +21 -0
- package/dist/form-layout/table.d.ts +20 -0
- package/dist/form-layout/text.d.ts +3 -0
- package/dist/form-layout/types.d.ts +151 -0
- package/dist/form-layout.css +62 -0
- package/dist/form-layout.d.ts +12 -0
- package/dist/form-layout.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/inputs.css +83 -20
- package/dist/inputs.d.ts +8 -0
- package/dist/inputs.js +1 -1
- package/dist/layout.css +63 -0
- package/dist/layout.d.ts +3 -0
- package/dist/layout.js +1 -1
- package/dist/modal.js +1 -1
- package/dist/motion/action-feedback.d.ts +14 -0
- package/dist/motion/border-glow.d.ts +12 -0
- package/dist/motion/button.d.ts +11 -0
- package/dist/motion/component-settings.d.ts +51 -0
- package/dist/motion/controller.d.ts +33 -0
- package/dist/motion/effects.d.ts +30 -0
- package/dist/motion/feedback.d.ts +6 -0
- package/dist/motion/loading.d.ts +9 -0
- package/dist/motion/optional.d.ts +35 -0
- package/dist/motion/photo.d.ts +7 -0
- package/dist/motion/presence.d.ts +12 -0
- package/dist/motion/scene.d.ts +10 -0
- package/dist/motion/screen.d.ts +6 -0
- package/dist/motion/scroll-reveal.d.ts +13 -0
- package/dist/motion/settings-group.d.ts +14 -0
- package/dist/motion/stagger.d.ts +12 -0
- package/dist/motion/structure.d.ts +18 -0
- package/dist/motion/tabs.d.ts +19 -0
- package/dist/motion/tokens.d.ts +11 -0
- package/dist/motion/value.d.ts +21 -0
- package/dist/motion/view-transition.d.ts +14 -0
- package/dist/motion-enhancers.d.ts +12 -0
- package/dist/motion-enhancers.js +1 -0
- package/dist/motion-settings.d.ts +3 -0
- package/dist/motion-settings.js +1 -0
- package/dist/motion.css +1076 -0
- package/dist/motion.d.ts +20 -0
- package/dist/motion.js +1 -0
- package/dist/native/date-picker.js +1 -1
- package/dist/native/date-range-picker.js +1 -1
- package/dist/native/date-time-picker.js +1 -1
- package/dist/native/form-layout.js +1 -0
- package/dist/native/time-picker.js +1 -1
- package/dist/native-components/form-layout.d.ts +1 -0
- package/dist/native-motion-components.d.ts +3 -0
- package/dist/native-motion-content.d.ts +1 -0
- package/dist/native-motion-foundations.d.ts +3 -0
- package/dist/native-motion-layout.d.ts +3 -0
- package/dist/native-motion-pilot.d.ts +34 -0
- package/dist/native-motion.d.ts +71 -0
- package/dist/notification.js +1 -1
- package/dist/pagination.js +1 -1
- package/dist/radio-button.css +1 -0
- package/dist/radio-button.d.ts +2 -0
- package/dist/radio-button.js +1 -0
- package/dist/radio-group.css +1 -0
- package/dist/radio-group.d.ts +2 -0
- package/dist/radio-group.js +1 -0
- package/dist/select.css +1 -0
- package/dist/select.d.ts +2 -0
- package/dist/select.js +1 -0
- package/dist/sidebar.js +1 -1
- package/dist/sizes.json +416 -112
- package/dist/skeleton.css +8 -0
- package/dist/styles.css +1213 -20
- package/dist/switch.css +1 -0
- package/dist/switch.d.ts +2 -0
- package/dist/switch.js +1 -0
- package/dist/table.css +1 -0
- package/dist/table.d.ts +2 -0
- package/dist/table.js +1 -0
- package/dist/text-area.css +1 -0
- package/dist/text-area.d.ts +2 -0
- package/dist/text-area.js +1 -0
- package/dist/text-input.css +1 -0
- package/dist/text-input.d.ts +2 -0
- package/dist/text-input.js +1 -0
- package/dist/time-picker.css +5 -5
- package/dist/time-picker.js +1 -1
- package/dist/tree-view.js +1 -1
- package/dist/web-motion/chip-ripple.d.ts +6 -0
- package/dist/web-motion/choice-ripple.d.ts +6 -0
- package/dist/web-motion/content.d.ts +9 -0
- package/dist/web-motion/data-rows.d.ts +12 -0
- package/dist/web-motion/fields.d.ts +6 -0
- package/dist/web-motion/navigation.d.ts +6 -0
- package/dist/web-motion/notifications.d.ts +6 -0
- package/dist/web-motion/select-ripple.d.ts +6 -0
- package/dist/web-motion/statistic.d.ts +13 -0
- package/dist/web-motion/surfaces.d.ts +10 -0
- package/dist/web-motion/tree.d.ts +6 -0
- package/docs/FORM-LAYOUT-ESSENTIALS.md +122 -0
- package/docs/PUBLISHING.md +129 -25
- package/draft-components.json +77 -0
- package/edition.json +44 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +102 -10
- package/scripts/build-motion-css.mjs +36 -0
- package/scripts/build-motion-styles.mjs +29 -0
- package/scripts/build.mjs +20 -8
- package/scripts/motion-entries.mjs +2 -0
- package/src/checkbox.ts +2 -0
- package/src/columns2.ts +2 -0
- package/src/columns3.ts +2 -0
- package/src/columns4.ts +2 -0
- package/src/component-motion.ts +101 -0
- package/src/data-motion.ts +60 -0
- package/src/data.ts +1 -0
- package/src/form-field.ts +2 -0
- package/src/form-layout/choice.ts +82 -0
- package/src/form-layout/layout.ts +40 -0
- package/src/form-layout/select.ts +97 -0
- package/src/form-layout/shared.ts +57 -0
- package/src/form-layout/table.ts +86 -0
- package/src/form-layout/text.ts +73 -0
- package/src/form-layout/types.ts +59 -0
- package/src/form-layout.ts +12 -0
- package/src/index.ts +2 -0
- package/src/inputs.ts +8 -0
- package/src/layout.ts +3 -0
- package/src/motion/action-feedback.ts +38 -0
- package/src/motion/border-glow.ts +41 -0
- package/src/motion/button.ts +56 -0
- package/src/motion/component-settings.ts +147 -0
- package/src/motion/controller.ts +191 -0
- package/src/motion/effects.ts +75 -0
- package/src/motion/feedback.ts +41 -0
- package/src/motion/loading.ts +40 -0
- package/src/motion/optional.ts +123 -0
- package/src/motion/photo.ts +19 -0
- package/src/motion/presence.ts +44 -0
- package/src/motion/scene.ts +103 -0
- package/src/motion/screen.ts +15 -0
- package/src/motion/scroll-reveal.ts +49 -0
- package/src/motion/settings-group.ts +28 -0
- package/src/motion/stagger.ts +26 -0
- package/src/motion/structure.ts +75 -0
- package/src/motion/tabs.ts +105 -0
- package/src/motion/tokens.ts +2 -0
- package/src/motion/value.ts +61 -0
- package/src/motion/view-transition.ts +57 -0
- package/src/motion-enhancers.ts +12 -0
- package/src/motion-settings.ts +3 -0
- package/src/motion.ts +20 -0
- package/src/native-components/form-layout.ts +21 -0
- package/src/native-motion-components.ts +76 -0
- package/src/native-motion-content.ts +10 -0
- package/src/native-motion-foundations.ts +56 -0
- package/src/native-motion-layout.ts +35 -0
- package/src/native-motion-pilot.ts +151 -0
- package/src/native-motion.ts +188 -0
- package/src/radio-button.ts +2 -0
- package/src/radio-group.ts +2 -0
- package/src/runtime/date-picker.js +21 -12
- package/src/runtime/date-range-picker.js +21 -12
- package/src/runtime/date-time-picker.js +21 -12
- package/src/runtime/time-picker.js +21 -12
- package/src/select.ts +2 -0
- package/src/switch.ts +2 -0
- package/src/table.ts +2 -0
- package/src/text-area.ts +2 -0
- package/src/text-input.ts +2 -0
- package/src/types.ts +0 -12
- package/src/web-motion/chip-ripple.ts +31 -0
- package/src/web-motion/choice-ripple.ts +30 -0
- package/src/web-motion/content.ts +74 -0
- package/src/web-motion/data-rows.ts +106 -0
- package/src/web-motion/fields.ts +52 -0
- package/src/web-motion/navigation.ts +62 -0
- package/src/web-motion/notifications.ts +67 -0
- package/src/web-motion/select-ripple.ts +23 -0
- package/src/web-motion/statistic.ts +23 -0
- package/src/web-motion/surfaces.ts +137 -0
- package/src/web-motion/tree.ts +44 -0
- package/styles/checkbox.css +1 -0
- package/styles/columns2.css +1 -0
- package/styles/columns3.css +1 -0
- package/styles/columns4.css +1 -0
- package/styles/component-motion.css +39 -0
- package/styles/data-motion.css +4 -0
- package/styles/date-picker.css +5 -5
- package/styles/date-range-picker.css +5 -5
- package/styles/date-time-picker.css +5 -5
- package/styles/form-field.css +1 -0
- package/styles/form-layout.css +62 -0
- package/styles/motion-optional.css +15 -0
- package/styles/motion-presets.css +92 -0
- package/styles/motion-tokens.css +6 -0
- package/styles/motion.css +125 -0
- package/styles/radio-button.css +1 -0
- package/styles/radio-group.css +1 -0
- package/styles/select.css +1 -0
- package/styles/skeleton.css +8 -0
- package/styles/switch.css +1 -0
- package/styles/table.css +1 -0
- package/styles/text-area.css +1 -0
- package/styles/text-input.css +1 -0
- package/styles/time-picker.css +5 -5
- package/test/component-motion-settings.test.mjs +35 -0
- package/test/content-overlay-motion.test.mjs +36 -0
- package/test/form-layout.test.mjs +28 -0
- package/test/isolation.test.mjs +2 -2
- package/test/motion.test.mjs +215 -0
- package/dist/chunk-2TCNAQC6.js +0 -1
- package/dist/chunk-CTLZIWMX.js +0 -1
- package/dist/chunk-MHHJZYXW.js +0 -1
- package/dist/chunk-XDYBNO4P.js +0 -1
- /package/dist/{chunk-H7ETPJVW.js → chunk-3PODK22R.js} +0 -0
- /package/dist/{chunk-SIU7DSOA.js → chunk-KBWB4OIF.js} +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { preserveStyles } from './structure.js';
|
|
2
|
+
|
|
3
|
+
export interface BorderGlowOptions {
|
|
4
|
+
/** One slow circuit in milliseconds, 4–60 seconds. Default: 12 seconds. */
|
|
5
|
+
duration?: number;
|
|
6
|
+
paused?: boolean;
|
|
7
|
+
}
|
|
8
|
+
const owners = new WeakSet<HTMLElement>();
|
|
9
|
+
function duration(value: number = 12000) {
|
|
10
|
+
if (!Number.isFinite(value) || value < 4000 || value > 60000) throw Error('Border glow duration must be from 4000 to 60000 ms.');
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** Decorative, noninteractive border only. CSS honors the motion scope and OS.
|
|
14
|
+
* Pause automatically while outside the viewport or in a hidden document. */
|
|
15
|
+
export function createBorderGlow(element: HTMLElement, options: BorderGlowOptions = {}) {
|
|
16
|
+
let time = duration(options.duration), paused = options.paused ?? false, visible = true, dead = false;
|
|
17
|
+
if (owners.has(element)) throw Error('This element already has a border glow.');
|
|
18
|
+
const doc = element.ownerDocument, win = doc.defaultView;
|
|
19
|
+
if (!win || !element.isConnected) throw Error('Mount a border glow on a connected browser element.');
|
|
20
|
+
const restore = preserveStyles(element, ['position']);
|
|
21
|
+
if (win.getComputedStyle(element).position === 'static') element.style.position = 'relative';
|
|
22
|
+
const overlay = doc.createElement('span'), rim = doc.createElement('span'), beam = doc.createElement('span');
|
|
23
|
+
overlay.className = 'blu-motion-border-glow'; rim.className = 'blu-motion-border-glow__rim';
|
|
24
|
+
beam.className = 'blu-motion-border-glow__beam'; rim.append(beam);
|
|
25
|
+
overlay.setAttribute('aria-hidden', 'true'); overlay.inert = true; overlay.append(rim); element.append(overlay); owners.add(element);
|
|
26
|
+
const sync = () => { overlay.style.setProperty('--blu-glow-duration', time + 'ms'); overlay.dataset.paused = String(paused || !visible || doc.hidden); };
|
|
27
|
+
const measure = () => overlay.style.setProperty('--blu-glow-size', Math.ceil(Math.hypot(element.offsetWidth + 4, element.offsetHeight + 4)) + 'px');
|
|
28
|
+
const resize = new ResizeObserver(measure); resize.observe(element); measure();
|
|
29
|
+
const intersection = typeof IntersectionObserver === 'function' ? new IntersectionObserver(entries => { visible = entries[0].isIntersecting; sync(); }) : null;
|
|
30
|
+
intersection?.observe(element); doc.addEventListener('visibilitychange', sync); sync();
|
|
31
|
+
return {
|
|
32
|
+
element: overlay,
|
|
33
|
+
update(next: BorderGlowOptions) {
|
|
34
|
+
if (dead) throw Error('Border glow is destroyed.');
|
|
35
|
+
if (next.duration !== undefined) time = duration(next.duration);
|
|
36
|
+
if (next.paused !== undefined) paused = next.paused;
|
|
37
|
+
sync();
|
|
38
|
+
},
|
|
39
|
+
destroy() { if (dead) return; dead = true; resize.disconnect(); intersection?.disconnect(); doc.removeEventListener('visibilitychange', sync); overlay.remove(); restore(); owners.delete(element); },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { node, mountRoot, classes } from '../dom.js';
|
|
2
|
+
import type { ButtonOptions, ButtonEvents, Component } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/** Draft web API; deliberately separate from the shared native Button until review. */
|
|
5
|
+
export interface MotionButtonOptions extends ButtonOptions {
|
|
6
|
+
LoadingLabel?: string;
|
|
7
|
+
SpinnerPosition?: 'Leading' | 'Trailing';
|
|
8
|
+
/** Reserve space for known result labels to avoid shifting surrounding controls. */
|
|
9
|
+
ReserveLabels?: string[];
|
|
10
|
+
/** App-owned completion/failure message, announced outside the busy button. */
|
|
11
|
+
StatusText?: string;
|
|
12
|
+
}
|
|
13
|
+
export function createMotionButton(host: HTMLElement, initial: MotionButtonOptions, events: ButtonEvents = {}): Component<MotionButtonOptions> {
|
|
14
|
+
const root = mountRoot(host), button = node('button'), face = node('span', 'blu-motion-button__face');
|
|
15
|
+
const spinner = node('span', 'blu-button__spinner'), label = node('span'), status = node('span', 'blu-motion-sr-only');
|
|
16
|
+
let props = { ...initial }, disposed = false;
|
|
17
|
+
button.type = 'button';
|
|
18
|
+
spinner.setAttribute('aria-hidden', 'true');
|
|
19
|
+
status.setAttribute('role', 'status');
|
|
20
|
+
status.setAttribute('aria-atomic', 'true');
|
|
21
|
+
face.append(spinner, label);
|
|
22
|
+
button.append(face);
|
|
23
|
+
root.append(button, status);
|
|
24
|
+
button.onclick = event => {
|
|
25
|
+
if (props.IsLoading || props.IsDisabled) { event.preventDefault(); return; }
|
|
26
|
+
void events.Click?.(event);
|
|
27
|
+
};
|
|
28
|
+
const attr = (name: string, value?: string) => value === undefined ? button.removeAttribute(name) : button.setAttribute(name, value);
|
|
29
|
+
function update(patch: Partial<MotionButtonOptions>) {
|
|
30
|
+
if (disposed) throw new Error('Button is destroyed.');
|
|
31
|
+
props = { ...props, ...patch };
|
|
32
|
+
const loading = !!props.IsLoading;
|
|
33
|
+
button.className = classes(`blu-button blu-motion-button blu-button--${(props.Color || 'Primary').toLowerCase()} blu-button--${(props.Variant || 'Filled').toLowerCase()} blu-button--${(props.Size || 'Medium').toLowerCase()}${props.IsFullWidth ? ' blu-button--full' : ''}${loading ? ' blu-button--loading' : ''}`, props.ExtendedClass);
|
|
34
|
+
button.dataset.spinnerPosition = props.SpinnerPosition || 'Leading';
|
|
35
|
+
// aria-disabled keeps an in-progress action focusable. The click guard also covers keyboard/programmatic activation.
|
|
36
|
+
button.disabled = !!props.IsDisabled;
|
|
37
|
+
attr('aria-disabled', loading || props.IsDisabled ? 'true' : undefined);
|
|
38
|
+
attr('aria-busy', loading ? 'true' : undefined);
|
|
39
|
+
attr('aria-label', props.AccessibleLabel ? props.AccessibleLabel + (loading ? ' — ' + (props.LoadingLabel || 'Loading') : '') : undefined);
|
|
40
|
+
attr('aria-expanded', props.IsExpandable ? String(!!props.IsExpanded) : undefined);
|
|
41
|
+
attr('aria-controls', props.ControlsId);
|
|
42
|
+
attr('aria-current', props.IsCurrentPage ? 'page' : undefined);
|
|
43
|
+
label.textContent = loading ? (props.LoadingLabel || props.Label) : props.Label;
|
|
44
|
+
spinner.hidden = !loading;
|
|
45
|
+
for (const old of Array.from(button.querySelectorAll('.blu-motion-button__reserve'))) old.remove();
|
|
46
|
+
for (const text of new Set([props.Label, props.LoadingLabel || props.Label, ...(props.ReserveLabels || [])])) {
|
|
47
|
+
const reserve = node('span', 'blu-motion-button__reserve', text);
|
|
48
|
+
reserve.setAttribute('aria-hidden', 'true');
|
|
49
|
+
button.append(reserve);
|
|
50
|
+
}
|
|
51
|
+
const message = props.StatusText || '';
|
|
52
|
+
if (status.textContent !== message) status.textContent = message;
|
|
53
|
+
}
|
|
54
|
+
update({});
|
|
55
|
+
return { element: root, update, destroy() { if (disposed) return; disposed = true; button.onclick = null; root.remove(); } };
|
|
56
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { motionEasings } from './effects.js';
|
|
2
|
+
import type { MotionEasing } from './effects.js';
|
|
3
|
+
import type { MotionController, MotionMode, MotionOptions, MotionTarget } from './controller.js';
|
|
4
|
+
|
|
5
|
+
/** Local 1.2 component contract. Omitted values inherit collection defaults. */
|
|
6
|
+
export interface ComponentMotionSettings {
|
|
7
|
+
mode?: 'inherit' | 'reduced' | 'off';
|
|
8
|
+
/** Override finite component transitions. Does not change spinner speed. Zero is instant. */
|
|
9
|
+
duration?: number;
|
|
10
|
+
easing?: MotionEasing;
|
|
11
|
+
}
|
|
12
|
+
export interface ComponentMotionHandle {
|
|
13
|
+
/** Replace this component's overrides; omit settings to inherit again. */
|
|
14
|
+
setMotion(settings?: ComponentMotionSettings): void;
|
|
15
|
+
}
|
|
16
|
+
export function validateComponentMotion(settings: ComponentMotionSettings): ComponentMotionSettings {
|
|
17
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw Error('Motion settings must be an object.');
|
|
18
|
+
if (settings.mode !== undefined && !['inherit', 'reduced', 'off'].includes(settings.mode)) throw Error('Unknown component motion mode.');
|
|
19
|
+
if (settings.duration !== undefined && (!Number.isFinite(settings.duration) || settings.duration < 0 || settings.duration > 2000)) throw Error('Motion duration must be between 0 and 2000 ms.');
|
|
20
|
+
if (settings.easing !== undefined && !Object.hasOwn(motionEasings, settings.easing)) throw Error('Unknown motion easing.');
|
|
21
|
+
return Object.fromEntries(Object.entries(settings).filter(([key, value]) => ['mode', 'duration', 'easing'].includes(key) && value !== undefined));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Shared presentation state for WAAPI adapters and chart engines. */
|
|
25
|
+
export function createComponentMotionState(root: HTMLElement, parent: { readonly mode: MotionMode }, initial: ComponentMotionSettings = {}) {
|
|
26
|
+
let settings = validateComponentMotion(initial), dead = false;
|
|
27
|
+
const previousMode = root.getAttribute('data-blu-motion');
|
|
28
|
+
const properties = ['--blu-component-motion-duration', '--blu-component-motion-ease'];
|
|
29
|
+
const originalStyles = properties.map(name => [name, root.style.getPropertyValue(name), root.style.getPropertyPriority(name)]);
|
|
30
|
+
function mode(): MotionMode {
|
|
31
|
+
if (parent.mode === 'off' || root.closest('.blu-motion-off,[data-blu-motion="off"]')) return 'off';
|
|
32
|
+
return parent.mode === 'reduced' || root.closest('[data-blu-motion="reduced"]') ? 'reduced' : 'full';
|
|
33
|
+
}
|
|
34
|
+
function apply() {
|
|
35
|
+
if (previousMode === 'off' || settings.mode === 'off') root.setAttribute('data-blu-motion', 'off');
|
|
36
|
+
else if (previousMode === 'reduced' || settings.mode === 'reduced') root.setAttribute('data-blu-motion', 'reduced');
|
|
37
|
+
else if (previousMode === null) root.removeAttribute('data-blu-motion');
|
|
38
|
+
else root.setAttribute('data-blu-motion', previousMode);
|
|
39
|
+
for (const [name, value, priority] of originalStyles) {
|
|
40
|
+
const next = name.endsWith('duration') ? settings.duration === undefined ? undefined : settings.duration + 'ms' : settings.easing ? motionEasings[settings.easing] : undefined;
|
|
41
|
+
if (next !== undefined) root.style.setProperty(name, next);
|
|
42
|
+
else if (value) root.style.setProperty(name, value, priority);
|
|
43
|
+
else root.style.removeProperty(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function resolveOptions(options: MotionOptions = {}): MotionOptions {
|
|
47
|
+
const duration = options.duration === 0 ? 0 : settings.duration ?? options.duration ?? 450;
|
|
48
|
+
return { ...options, duration: mode() === 'off' ? 0 : mode() === 'reduced' ? Math.min(duration, 200) : duration,
|
|
49
|
+
easing: settings.easing ?? options.easing, delay: mode() === 'full' ? options.delay : 0 };
|
|
50
|
+
}
|
|
51
|
+
apply();
|
|
52
|
+
return {
|
|
53
|
+
mode, resolveOptions, get settings() { return { ...settings }; },
|
|
54
|
+
update(next: ComponentMotionSettings) { if (dead) throw Error('Component motion is destroyed.'); settings = validateComponentMotion(next); apply(); },
|
|
55
|
+
destroy() {
|
|
56
|
+
if (dead) return; dead = true;
|
|
57
|
+
if (previousMode === null) root.removeAttribute('data-blu-motion'); else root.setAttribute('data-blu-motion', previousMode);
|
|
58
|
+
for (const [name, value, priority] of originalStyles) { if (value) root.style.setProperty(name, value, priority); else root.style.removeProperty(name); }
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal controller contract also usable from a native lifecycle adapter. */
|
|
64
|
+
export interface TransitionMotion {
|
|
65
|
+
readonly mode: MotionMode;
|
|
66
|
+
animate(target: MotionTarget, frames: Keyframe[], options: { duration: number; easing?: MotionEasing }, reduced?: 'fade' | 'instant'): Promise<unknown>;
|
|
67
|
+
cancel(target: MotionTarget): void;
|
|
68
|
+
}
|
|
69
|
+
export function createTransitionMotionScope(root: HTMLElement, parent: TransitionMotion, initial: ComponentMotionSettings = {}) {
|
|
70
|
+
const state = createComponentMotionState(root, parent, initial), active = new Map<MotionTarget, object>();
|
|
71
|
+
let dead = false;
|
|
72
|
+
function settle() {
|
|
73
|
+
for (const target of active.keys()) parent.cancel(target); active.clear();
|
|
74
|
+
for (const animation of root.getAnimations({subtree:true})) {
|
|
75
|
+
if (typeof CSSTransition !== 'undefined' && animation instanceof CSSTransition) animation.cancel();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const motion: TransitionMotion = {
|
|
79
|
+
get mode() { return state.mode(); },
|
|
80
|
+
animate(target, frames, options, reduced = 'instant') {
|
|
81
|
+
if (dead) return Promise.resolve('cancelled');
|
|
82
|
+
if (state.mode() === 'off' || (state.mode() === 'reduced' && reduced === 'instant')) { parent.cancel(target); return Promise.resolve('finished'); }
|
|
83
|
+
const token = {}; active.set(target, token);
|
|
84
|
+
const safe = state.mode() === 'reduced' ? frames.map(f => ({opacity:f.opacity,offset:f.offset})) : frames;
|
|
85
|
+
try { return parent.animate(target, safe, state.resolveOptions(options) as {duration:number;easing?:MotionEasing}, reduced).finally(() => { if (active.get(target) === token) active.delete(target); }); }
|
|
86
|
+
catch (error) { if (active.get(target) === token) active.delete(target); throw error; }
|
|
87
|
+
},
|
|
88
|
+
cancel(target) { parent.cancel(target); active.delete(target); },
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
motion,
|
|
92
|
+
update(next: ComponentMotionSettings) { if (dead) throw Error('Component motion is destroyed.'); const valid = validateComponentMotion(next); settle(); state.update(valid); },
|
|
93
|
+
destroy() { if (dead) return; dead = true; settle(); state.destroy(); },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An owned view of a shared controller, including portal exit decorations.
|
|
98
|
+
* No second system-preference listener or component event interception is needed.
|
|
99
|
+
*/
|
|
100
|
+
export function createComponentMotionScope(root: HTMLElement, parent: MotionController, initial: ComponentMotionSettings = {}) {
|
|
101
|
+
let destroyed = false;
|
|
102
|
+
const state = createComponentMotionState(root, parent, initial);
|
|
103
|
+
const { mode, resolveOptions } = state;
|
|
104
|
+
const active = new Map<MotionTarget, object>();
|
|
105
|
+
function cancelAll() {
|
|
106
|
+
for (const target of active.keys()) parent.cancel(target); active.clear();
|
|
107
|
+
// Settle finite CSS feedback too, without cancelling a real loading spinner.
|
|
108
|
+
for (const animation of root.getAnimations({ subtree: true })) {
|
|
109
|
+
if (typeof CSSTransition !== 'undefined' && animation instanceof CSSTransition) animation.cancel();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function run(target: MotionTarget, task: () => ReturnType<MotionController['play']>) {
|
|
113
|
+
if (destroyed) return Promise.resolve('cancelled' as const);
|
|
114
|
+
const token = {}; active.set(target, token);
|
|
115
|
+
try { return task().finally(() => { if (active.get(target) === token) active.delete(target); }); }
|
|
116
|
+
catch (error) { if (active.get(target) === token) active.delete(target); throw error; }
|
|
117
|
+
}
|
|
118
|
+
const motion: MotionController = {
|
|
119
|
+
get mode() { return mode(); }, get preference() { return mode() === 'off' ? 'off' : mode() === 'reduced' ? 'reduced' : parent.preference; },
|
|
120
|
+
setPreference(preference) { update({ ...state.settings, mode: preference === 'system' ? 'inherit' : preference }); },
|
|
121
|
+
resolveOptions,
|
|
122
|
+
play(target, effect, options) {
|
|
123
|
+
return run(target, () => parent.play(target, mode() === 'reduced' ? 'fade' : effect, resolveOptions(options)));
|
|
124
|
+
},
|
|
125
|
+
animate(target, frames, options, reduced = 'instant') {
|
|
126
|
+
return run(target, () => {
|
|
127
|
+
if (mode() === 'off' || (mode() === 'reduced' && reduced === 'instant')) { parent.cancel(target); return Promise.resolve('finished'); }
|
|
128
|
+
// Reduced exit fades retain captured opacity; no transform/filter/geometry.
|
|
129
|
+
const safeFrames = mode() === 'reduced' ? frames.map(frame => ({ opacity: frame.opacity, offset: frame.offset })) : frames;
|
|
130
|
+
return parent.animate(target, safeFrames, resolveOptions(options), reduced);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
tween(target, from, to, render, options) {
|
|
134
|
+
return run(target, () => parent.tween(target, from, to, render, { ...resolveOptions(options), ...(mode() !== 'full' ? { duration: 0 } : {}) }));
|
|
135
|
+
},
|
|
136
|
+
cancel(target) { parent.cancel(target); active.delete(target); },
|
|
137
|
+
destroy() {
|
|
138
|
+
if (destroyed) return; destroyed = true; cancelAll();
|
|
139
|
+
state.destroy();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
function update(next: ComponentMotionSettings) {
|
|
143
|
+
if (destroyed) throw Error('Component motion is destroyed.');
|
|
144
|
+
const valid = validateComponentMotion(next); cancelAll(); state.update(valid);
|
|
145
|
+
}
|
|
146
|
+
return { motion, update, destroy: () => motion.destroy() };
|
|
147
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { motionTiming } from './tokens.js';
|
|
2
|
+
/** Web-only 1.2 development API. No global listeners are installed at import time. */
|
|
3
|
+
import { motionEasings, motionFrames, motionOrigins } from './effects.js';
|
|
4
|
+
import type { MotionEffect, MotionPhase, MotionEasing, MotionOrigin } from './effects.js';
|
|
5
|
+
export type { MotionEffect, MotionPhase, MotionEasing } from './effects.js';
|
|
6
|
+
export type MotionPreference = 'system' | 'reduced' | 'off';
|
|
7
|
+
export type MotionMode = 'full' | 'reduced' | 'off';
|
|
8
|
+
export type MotionResult = 'finished' | 'cancelled';
|
|
9
|
+
export type MotionTarget = HTMLElement | SVGElement;
|
|
10
|
+
export interface MotionOptions {
|
|
11
|
+
phase?: MotionPhase;
|
|
12
|
+
duration?: number;
|
|
13
|
+
delay?: number;
|
|
14
|
+
easing?: MotionEasing;
|
|
15
|
+
/** Scale/rotation anchor. Does not move or position the target itself. */
|
|
16
|
+
origin?: MotionOrigin;
|
|
17
|
+
}
|
|
18
|
+
export interface MotionController {
|
|
19
|
+
readonly mode: MotionMode;
|
|
20
|
+
readonly preference: MotionPreference;
|
|
21
|
+
setPreference(preference: MotionPreference): void;
|
|
22
|
+
/** Component-scoped timing resolver, used by bounded visual-cleanup deadlines. */
|
|
23
|
+
resolveOptions?(options?: MotionOptions): MotionOptions;
|
|
24
|
+
play(element: HTMLElement, effect?: MotionEffect, options?: MotionOptions): Promise<MotionResult>;
|
|
25
|
+
/** Custom frames use instant reduced motion unless a fade is explicitly requested. */
|
|
26
|
+
animate(element: MotionTarget, frames: Keyframe[], options?: MotionOptions, reduced?: 'instant' | 'fade'): Promise<MotionResult>;
|
|
27
|
+
/** Numeric interpolation shares cancellation and preference handling with other effects. */
|
|
28
|
+
tween(element: MotionTarget, from: number, to: number, render: (value: number) => void, options?: MotionOptions): Promise<MotionResult>;
|
|
29
|
+
cancel(element: MotionTarget): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const owners = new WeakSet<HTMLElement>();
|
|
34
|
+
export function resolveMotionMode(preference: MotionPreference, systemReduced: boolean): MotionMode {
|
|
35
|
+
return preference === 'off' ? 'off' : preference === 'reduced' || systemReduced ? 'reduced' : 'full';
|
|
36
|
+
}
|
|
37
|
+
export function boundedTime(value: number | undefined, fallback: number, maximum = 2000): number {
|
|
38
|
+
return value !== undefined && Number.isFinite(value) ? Math.min(maximum, Math.max(0, value)) : fallback;
|
|
39
|
+
}
|
|
40
|
+
function cssMilliseconds(value: string, fallback: number) {
|
|
41
|
+
const match = /^([\d.]+)(ms|s)$/.exec(value.trim());
|
|
42
|
+
return match ? boundedTime(Number(match[1]) * (match[2] === 's' ? 1000 : 1), fallback) : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createMotionController(scope: HTMLElement, initial: MotionPreference = 'system'): MotionController {
|
|
46
|
+
if (!['system', 'reduced', 'off'].includes(initial)) throw new Error('Unknown motion preference.');
|
|
47
|
+
if (owners.has(scope)) throw new Error('This scope already has a motion controller.');
|
|
48
|
+
const win = scope.ownerDocument.defaultView;
|
|
49
|
+
if (!win) throw new Error('A browser document is required.');
|
|
50
|
+
const media = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
51
|
+
const previous = scope.getAttribute('data-blu-motion');
|
|
52
|
+
const active = new Map<MotionTarget, { cancel(): void }>();
|
|
53
|
+
let preference = initial, disposed = false;
|
|
54
|
+
const mode = () => resolveMotionMode(preference, media.matches);
|
|
55
|
+
function refresh() {
|
|
56
|
+
scope.setAttribute('data-blu-motion', mode());
|
|
57
|
+
// A preference change takes effect immediately, even during an exit.
|
|
58
|
+
for (const task of Array.from(active.values())) task.cancel();
|
|
59
|
+
}
|
|
60
|
+
owners.add(scope);
|
|
61
|
+
media.addEventListener('change', refresh);
|
|
62
|
+
refresh();
|
|
63
|
+
function allowed(element: MotionTarget) {
|
|
64
|
+
if (!scope.contains(element)) throw new Error('Motion target must be inside its controller scope.');
|
|
65
|
+
return mode() === 'full' && !element.closest?.('[data-blu-motion="off"], [data-blu-motion="reduced"], .blu-motion-off');
|
|
66
|
+
}
|
|
67
|
+
const controller: MotionController = {
|
|
68
|
+
get mode() { return mode(); },
|
|
69
|
+
get preference() { return preference; },
|
|
70
|
+
setPreference(value) {
|
|
71
|
+
if (disposed) throw new Error('Motion controller is destroyed.');
|
|
72
|
+
if (!['system', 'reduced', 'off'].includes(value)) throw new Error('Unknown motion preference.');
|
|
73
|
+
preference = value;
|
|
74
|
+
refresh();
|
|
75
|
+
},
|
|
76
|
+
cancel(element) { active.get(element)?.cancel(); },
|
|
77
|
+
animate(element, frames, options = {}, reduced = 'instant') {
|
|
78
|
+
if (disposed) return Promise.resolve('cancelled');
|
|
79
|
+
const full = allowed(element);
|
|
80
|
+
active.get(element)?.cancel();
|
|
81
|
+
if (!full) {
|
|
82
|
+
if (reduced === 'fade' && mode() !== 'off' && !element.closest('.blu-motion-off, [data-blu-motion="off"]')) {
|
|
83
|
+
return controller.play(element as HTMLElement, 'fade', { ...options, duration: Math.min(options.duration ?? 200, 200), delay: 0 });
|
|
84
|
+
}
|
|
85
|
+
return Promise.resolve('finished');
|
|
86
|
+
}
|
|
87
|
+
const duration = boundedTime(options.duration, motionTiming.normal);
|
|
88
|
+
if (!duration || !element.animate) return Promise.resolve('finished');
|
|
89
|
+
return new Promise<MotionResult>((resolve) => {
|
|
90
|
+
const animation = element.animate(frames, { duration, delay: boundedTime(options.delay, 0, 500), easing: motionEasings[options.easing || 'standard'], fill: 'both' });
|
|
91
|
+
let settled = false;
|
|
92
|
+
const task = { cancel: () => finish('cancelled') };
|
|
93
|
+
function finish(result: MotionResult) {
|
|
94
|
+
if (settled) return;
|
|
95
|
+
settled = true;
|
|
96
|
+
if (active.get(element) === task) active.delete(element);
|
|
97
|
+
animation.cancel(); resolve(result);
|
|
98
|
+
}
|
|
99
|
+
active.set(element, task);
|
|
100
|
+
animation.finished.then(() => finish('finished'), () => finish('cancelled'));
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
tween(element, from, to, render, options = {}) {
|
|
104
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) throw new Error('Numeric motion requires finite values.');
|
|
105
|
+
if (disposed) return Promise.resolve('cancelled');
|
|
106
|
+
const full = allowed(element);
|
|
107
|
+
active.get(element)?.cancel();
|
|
108
|
+
const duration = boundedTime(options.duration, motionTiming.normal), delay = boundedTime(options.delay, 0, 500);
|
|
109
|
+
if (!full || !duration || !win.requestAnimationFrame) { render(to); return Promise.resolve('finished'); }
|
|
110
|
+
return new Promise<MotionResult>((resolve, reject) => {
|
|
111
|
+
let frame = 0, start: number | undefined, settled = false;
|
|
112
|
+
const task = { cancel: () => finish('cancelled') };
|
|
113
|
+
function finish(result: MotionResult) {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true; win!.cancelAnimationFrame(frame);
|
|
116
|
+
if (active.get(element) === task) active.delete(element);
|
|
117
|
+
resolve(result);
|
|
118
|
+
}
|
|
119
|
+
function tick(now: number) {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
start ??= now;
|
|
122
|
+
const progress = Math.min(1, Math.max(0, (now - start - delay) / duration));
|
|
123
|
+
try { render(progress === 1 ? to : from + (to - from) * easeProgress(progress, options.easing || 'standard')); }
|
|
124
|
+
catch (error) { settled = true; active.delete(element); reject(error); return; }
|
|
125
|
+
if (progress === 1) finish('finished'); else frame = win!.requestAnimationFrame(tick);
|
|
126
|
+
}
|
|
127
|
+
active.set(element, task); frame = win.requestAnimationFrame(tick);
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
play(element, effect = 'fade', options = {}) {
|
|
131
|
+
if (disposed) return Promise.resolve('cancelled');
|
|
132
|
+
if (!scope.contains(element)) throw new Error('Motion target must be inside its controller scope.');
|
|
133
|
+
active.get(element)?.cancel();
|
|
134
|
+
if (mode() === 'off' || element.closest?.('.blu-motion-off, [data-blu-motion="off"]') || !element.animate) return Promise.resolve('finished');
|
|
135
|
+
const reduced = mode() === 'reduced' || !!element.closest?.('[data-blu-motion="reduced"]');
|
|
136
|
+
const css = win.getComputedStyle(element);
|
|
137
|
+
const requestedDuration = boundedTime(options.duration, cssMilliseconds(css.getPropertyValue('--blu-motion-duration'), motionTiming.normal));
|
|
138
|
+
const duration = reduced ? Math.min(requestedDuration || (options.duration === 0 ? 0 : 200), 200) : requestedDuration;
|
|
139
|
+
if (!duration) return Promise.resolve('finished');
|
|
140
|
+
const distance = css.getPropertyValue('--blu-motion-distance').trim() || '12px';
|
|
141
|
+
// Reduced motion uses a short opacity change, never spatial movement.
|
|
142
|
+
const frames = motionFrames(reduced ? 'fade' : effect, options.phase, distance);
|
|
143
|
+
if (!reduced && options.origin) {
|
|
144
|
+
if (!(options.origin in motionOrigins)) throw new Error('Unknown motion origin.');
|
|
145
|
+
for (const frame of frames) frame.transformOrigin = motionOrigins[options.origin];
|
|
146
|
+
}
|
|
147
|
+
const easing = options.easing ? motionEasings[options.easing] : css.getPropertyValue('--blu-motion-ease').trim() || motionEasings.standard;
|
|
148
|
+
if (!easing) throw new Error('Unknown motion easing.');
|
|
149
|
+
return new Promise<MotionResult>((resolve) => {
|
|
150
|
+
let settled = false;
|
|
151
|
+
const animation = element.animate(frames, {
|
|
152
|
+
duration, delay: reduced ? 0 : boundedTime(options.delay, 0, 500),
|
|
153
|
+
easing: reduced ? motionEasings['ease-out'] : easing,
|
|
154
|
+
fill: 'both',
|
|
155
|
+
});
|
|
156
|
+
const task = { cancel: () => finish('cancelled') };
|
|
157
|
+
function finish(result: MotionResult) {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
if (active.get(element) === task) active.delete(element);
|
|
161
|
+
animation.cancel(); // Never leave fill styles or detached animations behind.
|
|
162
|
+
resolve(result);
|
|
163
|
+
}
|
|
164
|
+
active.set(element, task);
|
|
165
|
+
animation.finished.then(() => finish('finished'), () => finish('cancelled'));
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
destroy() {
|
|
169
|
+
if (disposed) return;
|
|
170
|
+
disposed = true;
|
|
171
|
+
media.removeEventListener('change', refresh);
|
|
172
|
+
for (const task of Array.from(active.values())) task.cancel();
|
|
173
|
+
if (previous === null) scope.removeAttribute('data-blu-motion');
|
|
174
|
+
else scope.setAttribute('data-blu-motion', previous);
|
|
175
|
+
owners.delete(scope);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
return controller;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Solve cubic-bezier x before sampling y, so numeric motion matches CSS timing. */
|
|
182
|
+
export function easeProgress(progress: number, easing: MotionEasing): number {
|
|
183
|
+
if (progress <= 0) return 0;
|
|
184
|
+
if (progress >= 1) return 1;
|
|
185
|
+
if (easing === 'linear') return progress;
|
|
186
|
+
const [x1, y1, x2, y2] = motionEasings[easing].match(/[\d.]+/g)!.map(Number);
|
|
187
|
+
const curve = (t: number, a: number, b: number) => 3 * (1-t) ** 2 * t * a + 3 * (1-t) * t * t * b + t ** 3;
|
|
188
|
+
let low = 0, high = 1;
|
|
189
|
+
for (let i = 0; i < 20; i++) { const mid = (low + high) / 2; if (curve(mid, x1, x2) < progress) low = mid; else high = mid; }
|
|
190
|
+
return curve((low + high) / 2, y1, y2);
|
|
191
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Shared preset definitions: used by the controller and the CSS generator. */
|
|
2
|
+
export const motionEffects = [
|
|
3
|
+
'fade', 'slide-up', 'slide-down', 'slide-left', 'slide-right',
|
|
4
|
+
'zoom-in', 'zoom-out', 'rotate-left', 'rotate-right', 'flip-x', 'flip-y', 'pop', 'bounce',
|
|
5
|
+
'wipe-up', 'wipe-down', 'wipe-left', 'wipe-right',
|
|
6
|
+
'circle-reveal', 'blur-clear',
|
|
7
|
+
] as const;
|
|
8
|
+
export const motionOrigins = {
|
|
9
|
+
center: '50% 50%', top: '50% 0%', bottom: '50% 100%', left: '0% 50%', right: '100% 50%',
|
|
10
|
+
'top-left': '0% 0%', 'top-right': '100% 0%', 'bottom-left': '0% 100%', 'bottom-right': '100% 100%',
|
|
11
|
+
} as const;
|
|
12
|
+
export type MotionOrigin = keyof typeof motionOrigins;
|
|
13
|
+
export type MotionPreset = typeof motionEffects[number];
|
|
14
|
+
export type MotionEffect = MotionPreset | 'scale';
|
|
15
|
+
export type MotionPhase = 'enter' | 'exit';
|
|
16
|
+
export const motionEasings = {
|
|
17
|
+
standard: 'cubic-bezier(.2,.8,.2,1)',
|
|
18
|
+
'ease-in': 'cubic-bezier(.42,0,1,1)',
|
|
19
|
+
'ease-out': 'cubic-bezier(0,0,.58,1)',
|
|
20
|
+
'ease-in-out': 'cubic-bezier(.42,0,.58,1)',
|
|
21
|
+
linear: 'linear',
|
|
22
|
+
spring: 'cubic-bezier(.34,1.25,.64,1)',
|
|
23
|
+
} as const;
|
|
24
|
+
export type MotionEasing = keyof typeof motionEasings;
|
|
25
|
+
|
|
26
|
+
/** Names describe movement: slide-left enters from the right and exits toward the left. */
|
|
27
|
+
export function motionFrames(effect: MotionEffect, phase: MotionPhase = 'enter', distance = '12px'): Keyframe[] {
|
|
28
|
+
const exit = phase === 'exit';
|
|
29
|
+
const signed = (negative: boolean) => negative ? `calc(-1 * ${distance})` : distance;
|
|
30
|
+
const spatial: Record<string, string> = {
|
|
31
|
+
'slide-up': `translateY(${signed(exit)})`,
|
|
32
|
+
'slide-down': `translateY(${signed(!exit)})`,
|
|
33
|
+
'slide-left': `translateX(${signed(exit)})`,
|
|
34
|
+
'slide-right': `translateX(${signed(!exit)})`,
|
|
35
|
+
'zoom-in': `scale(${exit ? '1.08' : '.92'})`,
|
|
36
|
+
'zoom-out': `scale(${exit ? '.92' : '1.08'})`,
|
|
37
|
+
scale: `scale(${exit ? '1.03' : '.97'})`, // Legacy draft effect remains supported.
|
|
38
|
+
'rotate-left': `rotate(${exit ? '-8' : '8'}deg) scale(.97)`,
|
|
39
|
+
'rotate-right': `rotate(${exit ? '8' : '-8'}deg) scale(.97)`,
|
|
40
|
+
'flip-x': `perspective(600px) rotateX(${exit ? '-30' : '30'}deg)`,
|
|
41
|
+
'flip-y': `perspective(600px) rotateY(${exit ? '-30' : '30'}deg)`,
|
|
42
|
+
};
|
|
43
|
+
if (effect === 'fade') return exit ? [{ opacity: 1 }, { opacity: 0 }] : [{ opacity: 0 }, { opacity: 1 }];
|
|
44
|
+
if (effect === 'circle-reveal' || effect === 'blur-clear') {
|
|
45
|
+
const edge = effect === 'circle-reveal' ? { opacity: 0, clipPath: 'circle(0% at 50% 50%)' } : { opacity: 0, filter: 'blur(6px)' };
|
|
46
|
+
const rest = effect === 'circle-reveal' ? { opacity: 1, clipPath: 'circle(72% at 50% 50%)' } : { opacity: 1, filter: 'blur(0px)' };
|
|
47
|
+
return exit ? [rest, edge] : [edge, rest];
|
|
48
|
+
}
|
|
49
|
+
if (effect.startsWith('wipe-')) {
|
|
50
|
+
const edges: Record<string, [string, string]> = {
|
|
51
|
+
'wipe-up': ['inset(100% 0 0 0)', 'inset(0 0 100% 0)'],
|
|
52
|
+
'wipe-down': ['inset(0 0 100% 0)', 'inset(100% 0 0 0)'],
|
|
53
|
+
'wipe-left': ['inset(0 0 0 100%)', 'inset(0 100% 0 0)'],
|
|
54
|
+
'wipe-right': ['inset(0 100% 0 0)', 'inset(0 0 0 100%)'],
|
|
55
|
+
};
|
|
56
|
+
const rest = { opacity: 1, clipPath: 'inset(0 0 0 0)' };
|
|
57
|
+
return exit ? [rest, { opacity: 0, clipPath: edges[effect][1] }] : [{ opacity: 0, clipPath: edges[effect][0] }, rest];
|
|
58
|
+
}
|
|
59
|
+
if (effect === 'pop') return exit
|
|
60
|
+
? [{ opacity: 1, transform: 'scale(1)' }, { opacity: 1, transform: 'scale(1.025)', offset: .25 }, { opacity: 0, transform: 'scale(.9)' }]
|
|
61
|
+
: [{ opacity: 0, transform: 'scale(.9)' }, { opacity: 1, transform: 'scale(1.025)', offset: .72 }, { opacity: 1, transform: 'scale(1)' }];
|
|
62
|
+
if (effect === 'bounce') return exit
|
|
63
|
+
? [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 1, transform: 'translateY(-3px)', offset: .25 }, { opacity: 0, transform: `translateY(${distance})` }]
|
|
64
|
+
: [{ opacity: 0, transform: `translateY(${distance})` }, { opacity: 1, transform: 'translateY(-3px)', offset: .72 }, { opacity: 1, transform: 'translateY(0)' }];
|
|
65
|
+
if (!(effect in spatial)) throw new Error('Unknown motion effect.');
|
|
66
|
+
const edge = { opacity: 0, transform: spatial[effect] }, rest = { opacity: 1, transform: 'none' };
|
|
67
|
+
return exit ? [rest, edge] : [edge, rest];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function motionClass(effect: MotionPreset, phase: MotionPhase = 'enter') {
|
|
71
|
+
return `blu-motion-${phase}-${effect}`;
|
|
72
|
+
}
|
|
73
|
+
export function motionEasingClass(easing: MotionEasing) {
|
|
74
|
+
return `blu-motion-${easing.startsWith('ease-') ? easing : 'ease-' + easing}`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MotionController, MotionOptions } from './controller.js';
|
|
2
|
+
|
|
3
|
+
export function playFeedback(element: HTMLElement, motion: MotionController, effect: 'highlight' | 'nudge', options: MotionOptions = {}) {
|
|
4
|
+
const css = element.ownerDocument.defaultView!.getComputedStyle(element), rest = css.transform;
|
|
5
|
+
return effect === 'highlight'
|
|
6
|
+
? motion.animate(element, [{ boxShadow: css.boxShadow }, { boxShadow: '0 0 0 4px var(--blu-motion-highlight, #7770ed40)', offset: .45 }, { boxShadow: css.boxShadow }], { duration: 1400, easing: 'ease-in-out', ...options })
|
|
7
|
+
: motion.animate(element, [{ transform: rest }, { transform: `translateX(-3px) ${rest === 'none' ? '' : rest}`, offset: .25 }, { transform: `translateX(3px) ${rest === 'none' ? '' : rest}`, offset: .6 }, { transform: rest }], { duration: 280, ...options });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Decorative feedback only: does not call click, prevent native activation, or alter button state. */
|
|
11
|
+
export function createPressFeedback(element: HTMLElement, motion: MotionController) {
|
|
12
|
+
const original = element.getAttribute('data-blu-pressed'), hadClass = element.classList.contains('blu-motion-press');
|
|
13
|
+
const win = element.ownerDocument.defaultView!;
|
|
14
|
+
let pressed = false, destroyed = false, pointer: number | null = null, key: string | null = null;
|
|
15
|
+
element.classList.add('blu-motion-press');
|
|
16
|
+
function set(next: boolean) {
|
|
17
|
+
if (destroyed || next === pressed) return;
|
|
18
|
+
if (next && (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true' || element.closest('[inert]'))) return;
|
|
19
|
+
const from = win.getComputedStyle(element).transform;
|
|
20
|
+
motion.cancel(element); pressed = next;
|
|
21
|
+
element.setAttribute('data-blu-pressed', String(next));
|
|
22
|
+
const to = win.getComputedStyle(element).transform;
|
|
23
|
+
void motion.animate(element, [{ transform: from }, { transform: to }], { duration: 280, easing: 'ease-out' });
|
|
24
|
+
}
|
|
25
|
+
const down = (event: PointerEvent) => { if (event.button === 0 && event.isPrimary) { pointer = event.pointerId; set(true); } };
|
|
26
|
+
const up = (event: PointerEvent) => { if (event.pointerId === pointer) { pointer = null; if (!key) set(false); } };
|
|
27
|
+
const keydown = (event: KeyboardEvent) => { if (!event.repeat && [' ', 'Enter'].includes(event.key)) { key = event.key; set(true); } };
|
|
28
|
+
const keyup = (event: KeyboardEvent) => { if (event.key === key) { key = null; if (pointer === null) set(false); } };
|
|
29
|
+
const reset = () => { pointer = null; key = null; set(false); };
|
|
30
|
+
element.addEventListener('pointerdown', down); win.addEventListener('pointerup', up); win.addEventListener('pointercancel', up);
|
|
31
|
+
element.addEventListener('keydown', keydown); win.addEventListener('keyup', keyup);
|
|
32
|
+
element.addEventListener('blur', reset); win.addEventListener('blur', reset);
|
|
33
|
+
return { destroy() {
|
|
34
|
+
if (destroyed) return; destroyed = true; motion.cancel(element);
|
|
35
|
+
element.removeEventListener('pointerdown', down); win.removeEventListener('pointerup', up); win.removeEventListener('pointercancel', up);
|
|
36
|
+
element.removeEventListener('keydown', keydown); win.removeEventListener('keyup', keyup);
|
|
37
|
+
element.removeEventListener('blur', reset); win.removeEventListener('blur', reset);
|
|
38
|
+
if (!hadClass) element.classList.remove('blu-motion-press');
|
|
39
|
+
if (original === null) element.removeAttribute('data-blu-pressed'); else element.setAttribute('data-blu-pressed', original);
|
|
40
|
+
} };
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { node, mountRoot } from '../dom.js';
|
|
2
|
+
import type { Component } from '../types.js';
|
|
3
|
+
export interface LoadingIndicatorOptions {
|
|
4
|
+
Label: string;
|
|
5
|
+
Variant?: 'Spinner' | 'Dots' | 'Bar';
|
|
6
|
+
/** Real completion percentage, 0–100. Omit when the amount of work is unknown. */
|
|
7
|
+
Progress?: number;
|
|
8
|
+
}
|
|
9
|
+
export function normalizeProgress(value: number | undefined): number | undefined {
|
|
10
|
+
return value !== undefined && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function createLoadingIndicator(host: HTMLElement, initial: LoadingIndicatorOptions): Component<LoadingIndicatorOptions> {
|
|
13
|
+
const root = mountRoot(host), label = node('span'), visual = node('span', 'blu-loading__visual'), progress = node('progress'), bar = node('span', 'blu-loading__bar');
|
|
14
|
+
let props = { ...initial }, disposed = false;
|
|
15
|
+
root.className = 'blu-loading';
|
|
16
|
+
label.id = root.id + '-label';
|
|
17
|
+
visual.setAttribute('aria-hidden', 'true');
|
|
18
|
+
for (let i = 0; i < 3; i++) visual.append(node('i'));
|
|
19
|
+
progress.max = 100;
|
|
20
|
+
progress.setAttribute('aria-labelledby', label.id);
|
|
21
|
+
bar.setAttribute('role', 'progressbar');
|
|
22
|
+
bar.setAttribute('aria-labelledby', label.id);
|
|
23
|
+
root.append(visual, progress, bar, label);
|
|
24
|
+
function update(patch: Partial<LoadingIndicatorOptions>) {
|
|
25
|
+
if (disposed) throw new Error('Loading indicator is destroyed.');
|
|
26
|
+
props = { ...props, ...patch };
|
|
27
|
+
const value = normalizeProgress(props.Progress), isBar = value !== undefined || props.Variant === 'Bar';
|
|
28
|
+
root.dataset.variant = isBar ? 'Bar' : props.Variant || 'Spinner';
|
|
29
|
+
label.textContent = props.Label;
|
|
30
|
+
visual.hidden = isBar;
|
|
31
|
+
progress.hidden = value === undefined;
|
|
32
|
+
bar.hidden = !isBar || value !== undefined;
|
|
33
|
+
if (isBar) { root.removeAttribute('role'); root.removeAttribute('aria-labelledby'); }
|
|
34
|
+
else { root.setAttribute('role', 'status'); root.setAttribute('aria-labelledby', label.id); }
|
|
35
|
+
if (value === undefined) progress.removeAttribute('value');
|
|
36
|
+
else progress.value = value;
|
|
37
|
+
}
|
|
38
|
+
update({});
|
|
39
|
+
return { element: root, update, destroy() { if (disposed) return; disposed = true; root.remove(); } };
|
|
40
|
+
}
|