@blukavantui/community 1.1.0 → 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/dist/build-audit.json +127 -65
- package/dist/checkbox.js +1 -1
- package/dist/chunk-4LHIQROI.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-CNIIMGT3.js +0 -0
- package/dist/chunk-CSTCEBEK.js +1 -0
- package/dist/chunk-DRZM2AL3.js +0 -0
- package/dist/{chunk-JVE6CXCJ.js → chunk-EPPETNK6.js} +1 -1
- package/dist/chunk-EPPSBHZR.js +1 -0
- package/dist/{chunk-NTFHNL7T.js → chunk-G6OW4OZM.js} +1 -1
- package/dist/chunk-GS67ZBLI.js +1 -0
- package/dist/{chunk-6EHKOVZK.js → chunk-HVR2UDFX.js} +1 -1
- package/dist/chunk-IKYBFMHI.js +1 -0
- package/dist/chunk-KVOA55NE.js +0 -0
- package/dist/{chunk-MZBB5ROJ.js → chunk-LF2HELEZ.js} +1 -1
- package/dist/{chunk-UEOCR2C3.js → chunk-OLDA73XG.js} +2 -2
- package/dist/{chunk-IZJN3EF7.js → chunk-T45FJNEQ.js} +1 -1
- package/dist/chunk-TZSX4OTV.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-VSTKXZYY.js +0 -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-Y4AXWTAD.js +0 -0
- package/dist/chunk-YLFXSHKZ.js +0 -0
- package/dist/chunk-ZDGZGAYG.js +0 -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.js +1 -1
- package/dist/date-picker.js +1 -1
- package/dist/date-range-picker.js +1 -1
- package/dist/date-time-picker.js +1 -1
- package/dist/drawer.js +1 -1
- package/dist/essentials.css +8 -0
- package/dist/feedback.js +1 -1
- package/dist/form-layout.js +1 -1
- package/dist/index.js +1 -1
- package/dist/inputs.js +1 -1
- 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/form-layout.js +1 -1
- 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.js +1 -1
- package/dist/radio-group.js +1 -1
- package/dist/sidebar.js +1 -1
- package/dist/sizes.json +273 -151
- package/dist/skeleton.css +8 -0
- package/dist/styles.css +1130 -0
- package/dist/switch.js +1 -1
- package/dist/table.js +1 -1
- 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 +4 -4
- package/edition.json +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +29 -3
- package/scripts/build-motion-css.mjs +36 -0
- package/scripts/build-motion-styles.mjs +29 -0
- package/scripts/build.mjs +5 -1
- package/scripts/motion-entries.mjs +2 -0
- package/src/component-motion.ts +101 -0
- package/src/data-motion.ts +60 -0
- package/src/form-layout/choice.ts +17 -1
- 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-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/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/component-motion.css +39 -0
- package/styles/data-motion.css +4 -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/skeleton.css +8 -0
- package/test/component-motion-settings.test.mjs +35 -0
- package/test/content-overlay-motion.test.mjs +36 -0
- package/test/isolation.test.mjs +1 -1
- package/test/motion.test.mjs +215 -0
- package/dist/chunk-GXHANADL.js +0 -1
- /package/dist/{chunk-3PNBQVYS.js → chunk-2BSBXDOT.js} +0 -0
- /package/dist/{chunk-H7ETPJVW.js → chunk-3PODK22R.js} +0 -0
- /package/dist/{chunk-SIU7DSOA.js → chunk-KBWB4OIF.js} +0 -0
- /package/dist/{chunk-OPMD7QNV.js → chunk-W3H7XTA7.js} +0 -0
- /package/dist/{chunk-XWXEH3SW.js → chunk-XVMJG26X.js} +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Private, unpublished O11 1.2 integration. Enhances stable native wrappers; never owns their children. */
|
|
2
|
+
import { createMotionController, type MotionController, type MotionEffect } from './motion/controller.js';
|
|
3
|
+
import { createComponentMotionScope, validateComponentMotion, type ComponentMotionSettings } from './motion/component-settings.js';
|
|
4
|
+
import { motionEffects, motionOrigins, type MotionOrigin } from './motion/effects.js';
|
|
5
|
+
import { createRipple } from './motion/optional.js';
|
|
6
|
+
import { createPressFeedback } from './motion/feedback.js';
|
|
7
|
+
import { createMotionPresence } from './motion/presence.js';
|
|
8
|
+
import { enhanceField } from './web-motion/fields.js';
|
|
9
|
+
import { nativeComponentAdapter } from './native-motion-components.js';
|
|
10
|
+
import { nativeFoundationAdapter } from './native-motion-foundations.js';
|
|
11
|
+
import { observeSurface } from './web-motion/surfaces.js';
|
|
12
|
+
|
|
13
|
+
export interface NativeMotionOptions {
|
|
14
|
+
EnableMotion?: boolean;
|
|
15
|
+
Preference?: 'System' | 'Reduced' | 'Off';
|
|
16
|
+
UseDefaultDuration?: boolean;
|
|
17
|
+
DurationMs?: number;
|
|
18
|
+
Easing?: ComponentMotionSettings['easing'];
|
|
19
|
+
Effect?: MotionEffect; Origin?: MotionOrigin; Phase?: 'enter'|'exit';
|
|
20
|
+
ReplayKey?: string;
|
|
21
|
+
ActivePanel?: string;
|
|
22
|
+
FocusFallbackId?: string;
|
|
23
|
+
Ripple?: boolean;
|
|
24
|
+
Value?: number; Precision?: number; Locale?: string;
|
|
25
|
+
IsVisible?: boolean; IsActive?: boolean; Paused?: boolean;
|
|
26
|
+
Once?: boolean; Order?: string; Columns?: number; GapMs?: number;
|
|
27
|
+
LoopDurationMs?: number; Decoration?: string;
|
|
28
|
+
RowKeysJson?: string; RowsJson?: string; ColumnsJson?: string; RowKey?: string; Page?: number; PageSize?: number; SortKey?: string; SortDirection?: string; Loading?: boolean;
|
|
29
|
+
Progress?: number; Label?: string; Variant?: string;
|
|
30
|
+
}
|
|
31
|
+
export type Kind = string;
|
|
32
|
+
export type Adapter = { update(p: NativeMotionOptions): void; capture?(): void; destroy(): void };
|
|
33
|
+
type Owner = { root: HTMLElement; kind: Kind; parent?: Owner; controller?: MotionController; scope?: ReturnType<typeof createComponentMotionScope>; adapter: Adapter; signature: string; priorKind: string | null; options: NativeMotionOptions };
|
|
34
|
+
|
|
35
|
+
export function nativeMotionSettings(p: NativeMotionOptions): ComponentMotionSettings {
|
|
36
|
+
const preference = p.Preference ?? 'System';
|
|
37
|
+
if (!['System', 'Reduced', 'Off'].includes(preference)) throw Error('Preference must be System, Reduced or Off.');
|
|
38
|
+
if (p.Effect !== undefined && !(motionEffects as readonly string[]).includes(p.Effect) && p.Effect !== 'scale') throw Error('Unknown motion effect.');
|
|
39
|
+
if (p.UseDefaultDuration === false && p.DurationMs === undefined) throw Error('DurationMs is required when UseDefaultDuration is False.');
|
|
40
|
+
if(p.Origin!==undefined&&!(p.Origin in motionOrigins))throw Error('Unknown transform origin.');
|
|
41
|
+
if(p.Phase!==undefined&&!['enter','exit'].includes(p.Phase))throw Error('Unknown motion phase.');
|
|
42
|
+
return validateComponentMotion({ mode: preference === 'System' ? 'inherit' : preference.toLowerCase() as 'reduced' | 'off',
|
|
43
|
+
duration: p.UseDefaultDuration === false ? p.DurationMs : undefined, easing: p.Easing });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Multiple producer scripts share this registry, never overlapping body controllers. */
|
|
47
|
+
export function createNativeMotionRuntime() {
|
|
48
|
+
const extensions = new Map<string, (root: HTMLElement, motion: MotionController) => Adapter>();
|
|
49
|
+
const owners = new Map<string, Owner>();
|
|
50
|
+
const hosts = new WeakMap<HTMLElement, string>();
|
|
51
|
+
function actionOwner(id:string) {
|
|
52
|
+
if(owners.has(id))return owners.get(id);
|
|
53
|
+
// Consumers can pass the Id of their own Container inside a provider;
|
|
54
|
+
// they never need access to a producer Block's private widget names.
|
|
55
|
+
for(let el=document.getElementById(id);el;el=el.parentElement){const owner=owners.get(hosts.get(el)||'');if(owner)return owner;}
|
|
56
|
+
}
|
|
57
|
+
function parentOf(root: HTMLElement): Owner | undefined {
|
|
58
|
+
for (let el = root.parentElement; el; el = el.parentElement) {
|
|
59
|
+
const owner = owners.get(hosts.get(el) || '');
|
|
60
|
+
if (owner?.kind === 'provider') return owner;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function destroy(id: string) {
|
|
64
|
+
const owner = owners.get(id); if (!owner) return;
|
|
65
|
+
// Children release their adapters before a screen provider disappears.
|
|
66
|
+
for (const [childId, child] of owners) if (child.parent === owner) destroy(childId);
|
|
67
|
+
owner.adapter.destroy(); owner.scope?.destroy(); owner.controller?.destroy();
|
|
68
|
+
if (owner.priorKind === null) owner.root.removeAttribute('data-blu-native-motion');
|
|
69
|
+
else owner.root.setAttribute('data-blu-native-motion', owner.priorKind);
|
|
70
|
+
owners.delete(id); if (hosts.get(owner.root) === id) hosts.delete(owner.root);
|
|
71
|
+
}
|
|
72
|
+
function makeAdapter(kind: Kind, root: HTMLElement, motion: MotionController): Adapter {
|
|
73
|
+
const extension = extensions.get(kind);
|
|
74
|
+
if(extension) return extension(root, motion);
|
|
75
|
+
const foundation = nativeFoundationAdapter(kind, root, motion);
|
|
76
|
+
if(foundation) return foundation;
|
|
77
|
+
const component = nativeComponentAdapter(kind, root, motion);
|
|
78
|
+
if(component) return component;
|
|
79
|
+
if (kind === 'chart') return {update() {},destroy() {}};
|
|
80
|
+
if (kind === 'modal') {
|
|
81
|
+
// The original modal owns focus, events and slots. Only its JS-created
|
|
82
|
+
// surface is enhanced; closing copies are inert, unnamed visual decoration.
|
|
83
|
+
const surface = observeSurface(root, motion, 'modal');
|
|
84
|
+
return { update() { surface.sync(); }, capture: surface.capture, destroy: surface.destroy };
|
|
85
|
+
}
|
|
86
|
+
if (kind === 'button') {
|
|
87
|
+
const press = createPressFeedback(root, motion);
|
|
88
|
+
let ripple: ReturnType<typeof createRipple> | undefined;
|
|
89
|
+
let busy = root.getAttribute('aria-busy'), face: HTMLElement | null = null;
|
|
90
|
+
const refresh = () => {
|
|
91
|
+
const next = root.getAttribute('aria-busy');
|
|
92
|
+
if (next === busy) return;
|
|
93
|
+
busy = next; if (face) motion.cancel(face);
|
|
94
|
+
face = root.querySelector<HTMLElement>('.blu-button-content');
|
|
95
|
+
// Native If widgets still own the label/spinner. Fade the stable face only.
|
|
96
|
+
if (face) void motion.play(face, 'fade', { duration: 350 });
|
|
97
|
+
};
|
|
98
|
+
const observer = new MutationObserver(refresh);
|
|
99
|
+
observer.observe(root, { attributes: true, attributeFilter: ['aria-busy'], childList: true, subtree: true });
|
|
100
|
+
return { update(p) { if(p.Ripple && !ripple) ripple=createRipple(root,motion); else if(!p.Ripple && ripple){ripple.destroy();ripple=undefined;} refresh(); }, destroy() { ripple?.destroy(); observer.disconnect(); if (face) motion.cancel(face); press.destroy(); } };
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'field') {
|
|
103
|
+
let target: HTMLElement | null = null, field: ReturnType<typeof enhanceField> | undefined;
|
|
104
|
+
return { update() {
|
|
105
|
+
const next = root.matches('.blu-field') ? root : root.querySelector<HTMLElement>('.blu-field');
|
|
106
|
+
if (next !== target) { field?.destroy(); target = next; field = target ? enhanceField(target, motion) : undefined; }
|
|
107
|
+
field?.sync();
|
|
108
|
+
}, destroy() { field?.destroy(); } };
|
|
109
|
+
}
|
|
110
|
+
if (kind === 'workspace') {
|
|
111
|
+
const panels = Array.from(root.children).filter((el): el is HTMLElement => el instanceof HTMLElement && el.classList.contains('blu-native-workspace-panel'));
|
|
112
|
+
if (panels.length !== 2) throw Error('WorkspaceTransition requires two stable native panel wrappers.');
|
|
113
|
+
const presence = panels.map(el => createMotionPresence(el, motion));
|
|
114
|
+
let active = -1, dead = false, revision = 0;
|
|
115
|
+
return { update(p) {
|
|
116
|
+
const next = p.ActivePanel === 'Second' ? 1 : 0;
|
|
117
|
+
if (next === active) return;
|
|
118
|
+
const old = active; active = next; const own = ++revision;
|
|
119
|
+
const fallback = (p.FocusFallbackId ? root.ownerDocument.getElementById(p.FocusFallbackId) : root) || root;
|
|
120
|
+
if (old < 0) { panels.forEach((panel, i) => { panel.hidden = i !== next; panel.inert = i !== next; }); return; }
|
|
121
|
+
// Existing values and nodes stay in place; application state already changed.
|
|
122
|
+
if (panels[old].contains(root.ownerDocument.activeElement)) fallback.focus({ preventScroll: true });
|
|
123
|
+
if (panels[old].contains(root.ownerDocument.activeElement)) root.focus({ preventScroll: true });
|
|
124
|
+
const options = { effect: p.Effect || 'fade' as MotionEffect, duration: 650, focusFallback: root };
|
|
125
|
+
void presence[old].hide(options).then(() => { if (!dead && own === revision) panels[old].inert = true; });
|
|
126
|
+
void presence[next].show(options);
|
|
127
|
+
}, destroy() { dead = true; ++revision; presence.forEach(p => p.destroy()); } };
|
|
128
|
+
}
|
|
129
|
+
if(!['container','provider'].includes(kind)) throw Error('Unsupported native motion adapter: '+kind);
|
|
130
|
+
let replay: string | undefined;
|
|
131
|
+
return { update(p) {
|
|
132
|
+
if (kind !== 'container' || p.ReplayKey === replay) return;
|
|
133
|
+
replay = p.ReplayKey;
|
|
134
|
+
void motion.play(root, p.Effect || 'fade', { duration: 750, origin:p.Origin,phase:p.Phase });
|
|
135
|
+
}, destroy() { motion.cancel(root); } };
|
|
136
|
+
}
|
|
137
|
+
function sync(kind: Kind, hostId: string, p: NativeMotionOptions, ownerId = hostId): string {
|
|
138
|
+
try {
|
|
139
|
+
if (!ownerId) throw Error('A stable native motion owner is required.');
|
|
140
|
+
// These two foundation Blocks own their empty visual slot. Disabling
|
|
141
|
+
// motion must leave a static icon/progress indicator, not erase it.
|
|
142
|
+
if(p.EnableMotion!==true&&(kind==='icon'||kind==='loading'))p={...p,EnableMotion:true,Preference:'Off'};
|
|
143
|
+
if (p.EnableMotion !== true) { destroy(ownerId); return ''; }
|
|
144
|
+
const settings = nativeMotionSettings(p); // Reject invalid changes before touching the old adapter.
|
|
145
|
+
const root = document.getElementById(hostId); if (!root) { destroy(ownerId); return 'Native motion host is not rendered yet.'; }
|
|
146
|
+
const parent = parentOf(root), previous = owners.get(ownerId), occupant = hosts.get(root);
|
|
147
|
+
if (occupant && occupant !== ownerId) throw Error('Native motion host already has an owner.');
|
|
148
|
+
if (previous && (previous.root !== root || previous.kind !== kind || previous.parent !== parent)) destroy(ownerId);
|
|
149
|
+
let owner = owners.get(ownerId);
|
|
150
|
+
const signature = JSON.stringify(settings);
|
|
151
|
+
if (!owner) {
|
|
152
|
+
const controller = parent ? undefined : createMotionController(root, 'system');
|
|
153
|
+
const base = parent?.scope?.motion || parent?.controller || controller!;
|
|
154
|
+
// A private controller owns the root attribute; its component scope is destroyed first.
|
|
155
|
+
const scope = createComponentMotionScope(root, base, settings);
|
|
156
|
+
const priorKind = root.getAttribute('data-blu-native-motion');
|
|
157
|
+
root.setAttribute('data-blu-native-motion', kind);
|
|
158
|
+
try {
|
|
159
|
+
owner = { root, kind, parent, controller, scope, adapter: makeAdapter(kind, root, scope.motion), signature, priorKind, options: { ...p } };
|
|
160
|
+
owners.set(ownerId, owner); hosts.set(root, ownerId);
|
|
161
|
+
} catch (error) { scope.destroy(); controller?.destroy(); if (priorKind === null) root.removeAttribute('data-blu-native-motion'); else root.setAttribute('data-blu-native-motion', priorKind); throw error; }
|
|
162
|
+
} else if (owner.signature !== signature) { owner.scope!.update(settings); owner.signature = signature; }
|
|
163
|
+
const preference = settings.mode === 'inherit' ? 'system' : settings.mode || 'system';
|
|
164
|
+
if (owner.controller && owner.controller.preference !== preference) owner.controller.setPreference(preference);
|
|
165
|
+
owner.options = { ...p };
|
|
166
|
+
owner.adapter.update({ ...p, ReplayKey: p.ReplayKey ?? '' });
|
|
167
|
+
// O11 can ready inner Blocks before their enclosing provider.
|
|
168
|
+
if (kind === 'provider') for (const [childId, child] of Array.from(owners)) {
|
|
169
|
+
if (child !== owner && root.contains(child.root) && child.parent !== parentOf(child.root)) sync(child.kind, child.root.id, child.options, childId);
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
172
|
+
} catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
173
|
+
}
|
|
174
|
+
return { version: '1.2.0-local-full', sync, destroy,
|
|
175
|
+
register(kind: string, build: (root: HTMLElement, motion: MotionController) => Adapter) { if(extensions.has(kind)) return; extensions.set(kind,build); },
|
|
176
|
+
play(id: string, targetId: string, effect: MotionEffect = 'fade', phase: 'enter'|'exit' = 'enter') { nativeMotionSettings({Effect:effect,Phase:phase});const owner=actionOwner(id),target=document.getElementById(targetId); if(!owner?.scope || !target || !owner.root.contains(target))return Promise.resolve('cancelled'); return owner.scope.motion.play(target,effect,{phase}); },
|
|
177
|
+
cancel(id: string, targetId: string) { const owner=actionOwner(id),target=document.getElementById(targetId); if(target&&owner?.root.contains(target)) owner.scope?.motion.cancel(target); },
|
|
178
|
+
setPreference(id:string,preference:NativeMotionOptions['Preference']) {const owner=actionOwner(id);return owner?sync(owner.kind,owner.root.id,{...owner.options,Preference:preference},hosts.get(owner.root)):'Native motion owner is unavailable.';},
|
|
179
|
+
capture: (id: string) => owners.get(id)?.adapter.capture?.(),
|
|
180
|
+
/** Read-only counts for lifecycle checks; does not expose application data. */
|
|
181
|
+
inspect: () => ({ owners: owners.size, kinds: Array.from(owners.values(), o => o.kind) }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
declare global { interface Window { BluUIMotion12?: ReturnType<typeof createNativeMotionRuntime>; BluUIMotion12Plugins?: Array<(runtime:ReturnType<typeof createNativeMotionRuntime>)=>void> } }
|
|
186
|
+
if (typeof window !== 'undefined' && !window.BluUIMotion12) window.BluUIMotion12 = createNativeMotionRuntime();
|
|
187
|
+
|
|
188
|
+
if(typeof window!=="undefined"&&window.BluUIMotion12)for(const install of window.BluUIMotion12Plugins?.splice(0)||[])install(window.BluUIMotion12);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {MotionController} from '../motion/controller.js';
|
|
2
|
+
import {createRipple} from '../motion/optional.js';
|
|
3
|
+
|
|
4
|
+
/** Decorate the selected chip after its real click, even if that click replaces
|
|
5
|
+
* its button. No synthesized activation or application-state change. */
|
|
6
|
+
export function enhanceChipRipple(root:HTMLElement,motion:MotionController) {
|
|
7
|
+
let dead=false,revision=0,frame=0,current:ReturnType<typeof createRipple>|undefined;
|
|
8
|
+
const click=(event:MouseEvent)=>{
|
|
9
|
+
const source=event.target instanceof Element?event.target.closest<HTMLButtonElement>('.blu-chip__select'):null;
|
|
10
|
+
if(!source||!root.contains(source)||source.disabled)return;
|
|
11
|
+
const labels=[...root.querySelectorAll<HTMLButtonElement>('.blu-chip__select')];
|
|
12
|
+
const label=source.textContent,occurrence=labels.filter(b=>b.textContent===label).indexOf(source);
|
|
13
|
+
const selected=source.getAttribute('aria-pressed');
|
|
14
|
+
const point=event.detail?{clientX:event.clientX,clientY:event.clientY}:undefined,own=++revision;
|
|
15
|
+
// Microtasks can run between capture and the target's click handler.
|
|
16
|
+
// A frame waits until the component has completed its normal selection.
|
|
17
|
+
cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{
|
|
18
|
+
frame=0;
|
|
19
|
+
if(dead||own!==revision)return;
|
|
20
|
+
current?.destroy();current=undefined;
|
|
21
|
+
const target=source.isConnected?source:[...root.querySelectorAll<HTMLButtonElement>('.blu-chip__select')].filter(b=>b.textContent===label)[occurrence];
|
|
22
|
+
if(!target||target.disabled||motion.mode!=='full')return;
|
|
23
|
+
// O11 prevents the browser's default click while still updating selection.
|
|
24
|
+
// A cancelled activation with no real selection change stays undecorated.
|
|
25
|
+
if(event.defaultPrevented&&target.getAttribute('aria-pressed')===selected)return;
|
|
26
|
+
current=createRipple(target,motion,{duration:1000,easing:'ease-out',listen:false});current.play(point);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
root.addEventListener('click',click,true);
|
|
30
|
+
return {destroy(){if(dead)return;dead=true;++revision;cancelAnimationFrame(frame);root.removeEventListener('click',click,true);current?.destroy();}};
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {MotionController,MotionOptions} from '../motion/controller.js';
|
|
2
|
+
import {createRipple} from '../motion/optional.js';
|
|
3
|
+
|
|
4
|
+
/** One decorative pulse per native activation. Label clicks, Space and radio
|
|
5
|
+
* arrow changes keep the original input, focus, value and change events. */
|
|
6
|
+
export function enhanceChoiceRipple(root:HTMLElement,motion:MotionController,timing:MotionOptions={}) {
|
|
7
|
+
let dead=false,frame=0,current:ReturnType<typeof createRipple>|undefined;
|
|
8
|
+
const activate=(event:Event)=>{
|
|
9
|
+
const input=event.target;
|
|
10
|
+
if(!(input instanceof HTMLInputElement)||!input.matches('.blu-choice__input')||input.disabled||input.getAttribute('aria-readonly')==='true')return;
|
|
11
|
+
const name=input.name,value=input.value,type=input.type;
|
|
12
|
+
const matches=()=>[...root.querySelectorAll<HTMLInputElement>('.blu-choice__input')].filter(el=>el.name===name&&el.value===value&&el.type===type);
|
|
13
|
+
const occurrence=matches().indexOf(input),checked=input.checked;
|
|
14
|
+
cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{
|
|
15
|
+
frame=0;if(dead||event.defaultPrevented||motion.mode!=='full')return;
|
|
16
|
+
// Native parameter synchronization can replace a RadioGroup's inputs.
|
|
17
|
+
// Resolve only the same choice with its already-committed checked state.
|
|
18
|
+
const target=root.contains(input)?input:matches()[occurrence];
|
|
19
|
+
if(!target||target.disabled||target.getAttribute('aria-readonly')==='true'||target.checked!==checked)return;
|
|
20
|
+
const mark=target.nextElementSibling as HTMLElement|null;
|
|
21
|
+
if(!mark?.classList.contains('blu-choice__mark'))return;
|
|
22
|
+
current?.destroy();current=createRipple(mark,motion,{duration:1000,easing:'ease-out',...timing,listen:false});
|
|
23
|
+
current.element.classList.add('blu-motion-choice-ripple');current.play();
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
// Capture identity before application handlers can replace the input. The
|
|
27
|
+
// decorative work still waits until the event and native state commit finish.
|
|
28
|
+
root.addEventListener('click',activate,true);root.addEventListener('change',activate,true);
|
|
29
|
+
return {destroy(){if(dead)return;dead=true;cancelAnimationFrame(frame);root.removeEventListener('click',activate,true);root.removeEventListener('change',activate,true);current?.destroy();}};
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {MotionController} from '../motion/controller.js';
|
|
2
|
+
import {observeSurface} from './surfaces.js';
|
|
3
|
+
import {preserveStyles} from '../motion/structure.js';
|
|
4
|
+
export type ContentMotionKind = 'profile-card'|'video-lightbox'|'video-player'|'collection-layout'|'cascader'|'transfer'|'chip'|'tree-select'|'timeline'|'skeleton'|'media-image'|'tour'|'notification-center';
|
|
5
|
+
/** DOM-only enhancement: the original component still owns data, focus and media playback. */
|
|
6
|
+
export function enhanceContentMotion(root:HTMLElement,motion:MotionController,kind:ContentMotionKind,options:{timelineKeys?:()=>string[]}={}) {
|
|
7
|
+
let dead=false,frame=0,serial=0;
|
|
8
|
+
const active=new Map<HTMLElement,number>(),identities=new WeakMap<Element,string>();
|
|
9
|
+
const identity=(el:Element)=>{let id=identities.get(el);if(!id){id='node-'+ ++serial;identities.set(el,id);}return id;};
|
|
10
|
+
const run=(el:HTMLElement,frames:Keyframe[],duration=650,fade=false)=>{const token=++serial;active.set(el,token);void motion.animate(el,frames,{duration,easing:'ease-in-out'},fade?'fade':'instant').finally(()=>{if(active.get(el)===token)active.delete(el);});};
|
|
11
|
+
type Item={el:HTMLElement;rect:DOMRect;signature:string};
|
|
12
|
+
function read(){
|
|
13
|
+
const origin=root.getBoundingClientRect();
|
|
14
|
+
const entries: [string,Item][]=[];
|
|
15
|
+
const add=(selector:string,key:(el:HTMLElement,i:number)=>string,signature:(el:HTMLElement)=>string=()=> '')=>{
|
|
16
|
+
[...root.querySelectorAll<HTMLElement>(selector)].slice(0,40).forEach((el,i)=>{const r=el.getBoundingClientRect(),shift=active.has(el)?getComputedStyle(el).translate.split(/\s+/).map(Number.parseFloat):[],rect=new DOMRect(r.left-origin.left-(shift[0]||0),r.top-origin.top-(shift[1]||0),r.width,r.height);if(rect.width&&rect.height&&!el.closest('[hidden]'))entries.push([key(el,i),{el,rect,signature:signature(el)}]);});
|
|
17
|
+
};
|
|
18
|
+
if(kind==='collection-layout')add('.blu-collection-layout__item',identity);
|
|
19
|
+
if(kind==='timeline')add('.blu-timeline__item',(el,i)=>options.timelineKeys?.()[i]||el.querySelector('h3')?.textContent||identity(el));
|
|
20
|
+
if(kind==='transfer')add('.blu-transfer__row',el=>el.dataset.key||el.querySelector('.blu-transfer__label')?.textContent||identity(el));
|
|
21
|
+
if(kind==='chip')add('.blu-chip',el=>el.querySelector('.blu-chip__select,.blu-chip__label')?.textContent||identity(el),el=>String(el.classList.contains('blu-chip--selected')));
|
|
22
|
+
if(kind==='tree-select')add('.blu-tree-select__item[data-key] > .blu-tree-select__row',el=>el.parentElement!.dataset.key!);
|
|
23
|
+
if(kind==='cascader')add('.blu-cascader__column',(_,i)=>'column-'+i,el=>el.textContent||'');
|
|
24
|
+
if(kind==='tour')add('.blu-tour__panel',()=> 'tour-step',el=>el.textContent||'');
|
|
25
|
+
if(kind==='notification-center'){add('.blu-notification-center__list',()=> 'inbox',el=>el.textContent||'');add('.blu-notification-center__item',el=>el.querySelector('[data-focus]')?.getAttribute('data-focus')||identity(el),el=>el.className);}
|
|
26
|
+
if(kind==='skeleton')add('[aria-busy]',()=> 'loading-content',el=>el.getAttribute('aria-busy')||'');
|
|
27
|
+
const unique=new Map<string,Item>(),duplicates=new Set<string>();
|
|
28
|
+
for(const [key,item] of entries){if(unique.has(key))duplicates.add(key);unique.set(key,item);}
|
|
29
|
+
for(const key of duplicates)unique.delete(key); // Ambiguous labels never identify moving records.
|
|
30
|
+
return unique;
|
|
31
|
+
}
|
|
32
|
+
let before=read();
|
|
33
|
+
const capture=()=>{if(!frame)before=read();};
|
|
34
|
+
function reconcile(){frame=0;if(dead)return;const after=read();
|
|
35
|
+
for(const [key,item] of after){const old=before.get(key);if(!old){run(item.el,[{opacity:0},{opacity:1}],450,true);continue;}
|
|
36
|
+
const x=old.rect.left-item.rect.left,y=old.rect.top-item.rect.top;
|
|
37
|
+
if(kind!=='tour'&&(Math.abs(x)>1||Math.abs(y)>1))run(item.el,[{translate:`${x}px ${y}px`},{translate:'0px 0px'}]);
|
|
38
|
+
else if(old.signature!==item.signature)run(item.el,[{opacity:.55},{opacity:1}],450,true);
|
|
39
|
+
}
|
|
40
|
+
for(const el of active.keys())if(!root.contains(el)){motion.cancel(el);active.delete(el);}
|
|
41
|
+
before=after;
|
|
42
|
+
}
|
|
43
|
+
const observer=new MutationObserver(()=>{if(!frame)frame=requestAnimationFrame(reconcile);});
|
|
44
|
+
observer.observe(root,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','class','aria-selected','aria-checked','style']});
|
|
45
|
+
for(const event of ['click','change','input','keydown','pointerdown'])root.addEventListener(event,capture,true);
|
|
46
|
+
const previousKind=root.getAttribute('data-blu-content-motion');root.setAttribute('data-blu-content-motion',kind);
|
|
47
|
+
const surface=kind==='cascader'||kind==='tree-select'?observeSurface(root,motion,kind):undefined;
|
|
48
|
+
// Media/profile dialogs retain the real content on close. It is already paused
|
|
49
|
+
// by the component, inert and inaccessible during the short cosmetic fade.
|
|
50
|
+
const layers=new Map<HTMLElement,{open:boolean;revision:number;restore:()=>void}>();
|
|
51
|
+
function overlay(){for(const layer of root.querySelectorAll<HTMLElement>('.blu-overlay,.blu-tour')){
|
|
52
|
+
// Nested components own their own dialogs. Otherwise a collection and its
|
|
53
|
+
// profile card both snapshot temporary exit styles and restore display:flex
|
|
54
|
+
// after the hidden dialog finishes closing.
|
|
55
|
+
if(layer.closest('[data-blu-content-motion]')!==root)continue;
|
|
56
|
+
let state=layers.get(layer);if(!state){state={open:false,revision:0,restore:()=>{}};layers.set(layer,state);}
|
|
57
|
+
const open=!layer.hidden;if(open===state.open)continue;state.open=open;const revision=++state.revision;state.restore();motion.cancel(layer);
|
|
58
|
+
if(open){void motion.play(layer,'fade',{duration:450});continue;}
|
|
59
|
+
if(motion.mode==='off')continue;
|
|
60
|
+
const restore=preserveStyles(layer,['display','pointer-events']),inert=layer.inert,aria=layer.getAttribute('aria-hidden');
|
|
61
|
+
let timer:ReturnType<typeof setTimeout>|undefined;
|
|
62
|
+
state.restore=()=>{clearTimeout(timer);restore();layer.inert=inert;if(aria===null)layer.removeAttribute('aria-hidden');else layer.setAttribute('aria-hidden',aria);};
|
|
63
|
+
layer.inert=true;layer.setAttribute('aria-hidden','true');layer.style.setProperty('display','flex','important');layer.style.pointerEvents='none';
|
|
64
|
+
const finish=()=>{if(!dead&&state!.revision===revision){state!.restore();state!.restore=()=>{};}};
|
|
65
|
+
timer=setTimeout(finish,(motion.resolveOptions?.({duration:450}).duration??450)+100);
|
|
66
|
+
void motion.animate(layer,[{opacity:1},{opacity:0}],{duration:450,easing:'ease-in-out'},'fade').finally(finish);
|
|
67
|
+
}}
|
|
68
|
+
const overlayObserver=new MutationObserver(overlay);
|
|
69
|
+
if(['profile-card','video-lightbox','collection-layout','media-image','tour'].includes(kind)){overlayObserver.observe(root,{subtree:true,childList:true,attributes:true,attributeFilter:['hidden']});overlay();}
|
|
70
|
+
// Playback is never delayed or restarted; only the pause/notice presentation fades.
|
|
71
|
+
const mediaFeedback=(event:Event)=>{const video=event.target as HTMLVideoElement;if(video.tagName!=='VIDEO')return;queueMicrotask(()=>{if(dead||!root.contains(video))return;const player=video.closest('.blu-video-player');const glass=player?.querySelector<HTMLElement>('.blu-video-player__pause-glass');const feedback=video.paused&&glass&&!glass.hidden&&getComputedStyle(glass).display!=='none'?glass:player?.querySelector<HTMLElement>('.blu-video-player__primary');if(feedback)run(feedback,[{opacity:.4},{opacity:1}],450,true);});};
|
|
72
|
+
for(const event of ['play','pause','waiting','loadeddata'])root.addEventListener(event,mediaFeedback,true);
|
|
73
|
+
return {capture,destroy(){if(dead)return;dead=true;observer.disconnect();overlayObserver.disconnect();cancelAnimationFrame(frame);surface?.destroy();for(const [el,state] of layers){state.revision++;state.restore();motion.cancel(el);}for(const el of active.keys())motion.cancel(el);active.clear();for(const event of ['click','change','input','keydown','pointerdown'])root.removeEventListener(event,capture,true);if(previousKind===null)root.removeAttribute('data-blu-content-motion');else root.setAttribute('data-blu-content-motion',previousKind);for(const event of ['play','pause','waiting','loadeddata'])root.removeEventListener(event,mediaFeedback,true);}};
|
|
74
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
|
|
3
|
+
export interface MotionRow { key: string; element: HTMLElement; cells: HTMLElement[]; cellKeys: string[]; }
|
|
4
|
+
interface Snapshot { row: MotionRow; top: number; left: number; width: number; height: number; text: string[]; widths: number[]; }
|
|
5
|
+
/** Presentation only. Data, selection, sorting, paging and keyboard handling stay with the component. */
|
|
6
|
+
export function enhanceDataRows(root: HTMLElement, motion: MotionController, read: () => MotionRow[]) {
|
|
7
|
+
let disposed = false, frame = 0, settle = 0, generation = 0, scrollUntil = 0, unsupportedRows = false;
|
|
8
|
+
const owned = new Set<HTMLElement>(), exits = new Map<string, { element: HTMLElement; timer: ReturnType<typeof setTimeout> }>();
|
|
9
|
+
const previousPosition = root.style.position;
|
|
10
|
+
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
|
|
11
|
+
const layer = document.createElement('div'); layer.className = 'blu-data-motion-exits'; layer.inert = true; layer.setAttribute('aria-hidden', 'true');
|
|
12
|
+
root.append(layer);
|
|
13
|
+
function snapshot() {
|
|
14
|
+
const rows = read();
|
|
15
|
+
const origin = root.getBoundingClientRect();
|
|
16
|
+
// Bound work to rendered rows. Never enumerate an entire virtual data set.
|
|
17
|
+
unsupportedRows = rows.length > 80 || new Set(rows.map(row => row.key)).size !== rows.length;
|
|
18
|
+
if (unsupportedRows) return new Map<string, Snapshot>();
|
|
19
|
+
return new Map(rows.map(row => {
|
|
20
|
+
const r = row.element.getBoundingClientRect();
|
|
21
|
+
// Read layout coordinates, excluding our in-flight visual translation.
|
|
22
|
+
const translate = owned.has(row.element) ? getComputedStyle(row.element).translate.split(/\s+/).map(Number.parseFloat) : [];
|
|
23
|
+
return [row.key, { row, top: r.top-origin.top-(translate[1]||0), left: r.left-origin.left-(translate[0]||0), width: r.width, height: r.height,
|
|
24
|
+
text: row.cells.map(cell => cell.textContent || ''), widths: row.cells.map(cell => cell.getBoundingClientRect().width) }];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
let before = snapshot();
|
|
28
|
+
function animate(el: HTMLElement, frames: Keyframe[], duration: number, fade = false) {
|
|
29
|
+
owned.add(el);
|
|
30
|
+
void motion.animate(el, frames, { duration, easing: 'ease-in-out' }, fade ? 'fade' : 'instant').then(() => { if (!el.getAnimations().length) owned.delete(el); });
|
|
31
|
+
}
|
|
32
|
+
function clearExits() {
|
|
33
|
+
for (const { element, timer } of exits.values()) { clearTimeout(timer); motion.cancel(element); owned.delete(element); element.remove(); }
|
|
34
|
+
exits.clear();
|
|
35
|
+
}
|
|
36
|
+
function capture() {
|
|
37
|
+
if (disposed) return;
|
|
38
|
+
if (frame) { cancelAnimationFrame(frame); frame = 0; reconcile(); }
|
|
39
|
+
before = snapshot();
|
|
40
|
+
}
|
|
41
|
+
function reconcile() {
|
|
42
|
+
if (disposed) return;
|
|
43
|
+
frame = 0;
|
|
44
|
+
const current = snapshot();
|
|
45
|
+
// Grid engines make additional style/scrollbar updates after sorting. Those
|
|
46
|
+
// must not cancel an entrance or reorder that is already playing.
|
|
47
|
+
const unchanged = current.size===before.size && [...current].every(([key,item])=>{
|
|
48
|
+
const old=before.get(key);return old && old.row.element===item.row.element && Math.abs(old.top-item.top)<1 && Math.abs(old.left-item.left)<1 && old.width===item.width && old.height===item.height && old.text.join('\u0000')===item.text.join('\u0000');
|
|
49
|
+
});
|
|
50
|
+
if(unchanged)return;
|
|
51
|
+
const revision = ++generation;
|
|
52
|
+
clearTimeout(settle); clearExits();
|
|
53
|
+
for (const el of owned) { motion.cancel(el); } owned.clear();
|
|
54
|
+
if (unsupportedRows || performance.now() < scrollUntil) { before = current; return; }
|
|
55
|
+
const bounds = root.getBoundingClientRect();
|
|
56
|
+
// Large page changes use a quiet entrance instead of dozens of crossing rows.
|
|
57
|
+
const modest = current.size <= 30 && before.size <= 30;
|
|
58
|
+
for (const [key, entry] of current) {
|
|
59
|
+
const old = before.get(key), el = entry.row.element;
|
|
60
|
+
if (!old) animate(el, [{ opacity: 0 }, { opacity: 1 }], 450, true);
|
|
61
|
+
else {
|
|
62
|
+
const dy = old.top - entry.top;
|
|
63
|
+
if (modest && Math.abs(dy) > 1 && Math.abs(dy) < bounds.height) {
|
|
64
|
+
// Individual translate composes with the grid engine's own transform.
|
|
65
|
+
animate(el, [{ translate: `0 ${dy}px` }, { translate: '0 0' }], 650);
|
|
66
|
+
}
|
|
67
|
+
entry.row.cells.forEach((cell, index) => {
|
|
68
|
+
const previousIndex = old.row.cellKeys.indexOf(entry.row.cellKeys[index]);
|
|
69
|
+
if (previousIndex >= 0 && old.text[previousIndex] !== entry.text[index]) {
|
|
70
|
+
const resting = getComputedStyle(cell).backgroundColor;
|
|
71
|
+
animate(cell, [{ backgroundColor: resting }, { backgroundColor: 'rgba(105, 88, 240, .18)', offset: .35 }, { backgroundColor: resting }], 1400);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (modest && motion.mode !== 'off') for (const [key, old] of before) {
|
|
77
|
+
if (current.has(key) || !old.width || old.top + old.height < 0 || old.top > bounds.height) continue;
|
|
78
|
+
// Text-only decoration: no copied IDs, controls, handlers or live regions.
|
|
79
|
+
const ghost = document.createElement('div'); ghost.className = 'blu-data-motion-exit';
|
|
80
|
+
ghost.style.cssText = `left:${old.left}px;top:${old.top}px;width:${old.width}px;height:${old.height}px;grid-template-columns:${old.widths.map(w => w+'px').join(' ')};`;
|
|
81
|
+
for (const text of old.text) { const cell = document.createElement('span'); cell.textContent = text; ghost.append(cell); }
|
|
82
|
+
layer.append(ghost);
|
|
83
|
+
const remove = () => { if (exits.get(key)?.element !== ghost) return; clearTimeout(exits.get(key)!.timer); motion.cancel(ghost); owned.delete(ghost); ghost.remove(); exits.delete(key); };
|
|
84
|
+
exits.set(key, { element: ghost, timer: setTimeout(remove, (motion.resolveOptions?.({duration:450}).duration ?? 450) + 250) }); owned.add(ghost);
|
|
85
|
+
void motion.play(ghost, 'fade', { phase: 'exit', duration: 450, easing: 'ease-in-out' }).then(remove);
|
|
86
|
+
}
|
|
87
|
+
before = current;
|
|
88
|
+
settle = window.setTimeout(() => { if (!disposed && generation === revision) before = snapshot(); }, (motion.resolveOptions?.({duration:650}).duration ?? 650) + 50);
|
|
89
|
+
}
|
|
90
|
+
function changed(records: MutationRecord[]) {
|
|
91
|
+
if (records.every(r => layer.contains(r.target) || r.target === layer)) return;
|
|
92
|
+
if (!frame) frame = requestAnimationFrame(reconcile);
|
|
93
|
+
}
|
|
94
|
+
const observer = new MutationObserver(changed);
|
|
95
|
+
observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['row-index', 'row-id', 'style'] });
|
|
96
|
+
// Capture the current visual positions before the existing click handler rerenders.
|
|
97
|
+
root.addEventListener('click', capture, true);
|
|
98
|
+
const scrollPositions = new WeakMap<Element, string>();
|
|
99
|
+
for(const el of [root,...root.querySelectorAll('*')])scrollPositions.set(el,`${el.scrollLeft},${el.scrollTop}`);
|
|
100
|
+
const scrolling = (event:Event) => { const el=event.target;if(!(el instanceof Element))return;const position=`${el.scrollLeft},${el.scrollTop}`,previous=scrollPositions.get(el)||'0,0';scrollPositions.set(el,position);if(previous===position)return;scrollUntil = performance.now() + 150; if (frame) cancelAnimationFrame(frame); frame = 0; clearExits(); for (const el of owned) motion.cancel(el); owned.clear(); before = snapshot(); };
|
|
101
|
+
root.addEventListener('scroll', scrolling, true);
|
|
102
|
+
return {
|
|
103
|
+
capture,
|
|
104
|
+
destroy() { if (disposed) return; disposed = true; observer.disconnect(); cancelAnimationFrame(frame); clearTimeout(settle); clearExits(); for (const el of owned) motion.cancel(el); owned.clear(); root.removeEventListener('click', capture, true); root.removeEventListener('scroll', scrolling, true); layer.remove(); root.style.position = previousPosition; },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
import { createPressFeedback } from '../motion/feedback.js';
|
|
3
|
+
|
|
4
|
+
/** Adds feedback to existing controls without replacing inputs or intercepting native events. */
|
|
5
|
+
export function enhanceField(root: HTMLElement, motion: MotionController) {
|
|
6
|
+
const previous = root.getAttribute('data-blu-component-motion'); root.setAttribute('data-blu-component-motion', 'field');
|
|
7
|
+
let destroyed = false, revision = 0, errorText = '', signature = '', height = root.getBoundingClientRect().height;
|
|
8
|
+
const buttons = new Map<HTMLButtonElement, ReturnType<typeof createPressFeedback>>();
|
|
9
|
+
const originalOverflow = root.style.overflow;
|
|
10
|
+
let nudgeTarget: HTMLElement | null = null;
|
|
11
|
+
function sync(initial = false) {
|
|
12
|
+
if (destroyed) return;
|
|
13
|
+
for (const [button, press] of buttons) if (!root.contains(button)) { press.destroy(); buttons.delete(button); }
|
|
14
|
+
for (const button of Array.from(root.querySelectorAll<HTMLButtonElement>('.blu-text__action'))) if (!buttons.has(button)) buttons.set(button, createPressFeedback(button, motion));
|
|
15
|
+
const error = root.querySelector<HTMLElement>('.blu-field__error'), next = error && !error.hidden ? error.textContent || '' : '';
|
|
16
|
+
const nextSignature = [root.textContent, next, root.querySelector('textarea')?.style.height].join('|');
|
|
17
|
+
if (!initial && nextSignature === signature) return;
|
|
18
|
+
signature = nextSignature;
|
|
19
|
+
const from = root.getBoundingClientRect().height;
|
|
20
|
+
motion.cancel(root); root.style.overflow = originalOverflow;
|
|
21
|
+
const to = root.getBoundingClientRect().height, own = ++revision;
|
|
22
|
+
if (!initial && height > 0 && to > 0 && Math.abs(height - to) > 1) {
|
|
23
|
+
root.style.overflow = 'visible';
|
|
24
|
+
void motion.animate(root, [{ height: from === to ? height + 'px' : from + 'px' }, { height: to + 'px' }], { duration: 280, easing: 'ease-in-out' }).finally(() => { if (!destroyed && own === revision) root.style.overflow = originalOverflow; });
|
|
25
|
+
}
|
|
26
|
+
height = to;
|
|
27
|
+
if (!initial && next && !errorText && error) {
|
|
28
|
+
void motion.play(error, 'fade', { duration: 350 });
|
|
29
|
+
nudgeTarget = root.querySelector<HTMLElement>('.blu-text, .blu-textarea, .blu-select__trigger, .blu-radio-group, .blu-choice');
|
|
30
|
+
if (nudgeTarget) void motion.animate(nudgeTarget, [
|
|
31
|
+
{ transform: 'translateX(0)' }, { transform: 'translateX(-3px)', offset: .22 },
|
|
32
|
+
{ transform: 'translateX(2px)', offset: .5 }, { transform: 'translateX(-1px)', offset: .75 },
|
|
33
|
+
{ transform: 'translateX(0)' },
|
|
34
|
+
], { duration: 550, easing: 'ease-in-out' });
|
|
35
|
+
}
|
|
36
|
+
if (!next && nudgeTarget) { motion.cancel(nudgeTarget); nudgeTarget = null; }
|
|
37
|
+
if (!next && error) motion.cancel(error);
|
|
38
|
+
errorText = next;
|
|
39
|
+
}
|
|
40
|
+
sync(true);
|
|
41
|
+
const observer = new MutationObserver(records => {
|
|
42
|
+
if (records.some(record => record.type === 'childList' || record.attributeName !== 'style' || (record.target as Element).matches('textarea'))) sync();
|
|
43
|
+
});
|
|
44
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden', 'aria-invalid', 'data-invalid', 'disabled', 'style'] });
|
|
45
|
+
return { sync: () => sync(), destroy() {
|
|
46
|
+
if (destroyed) return; destroyed = true; ++revision; observer.disconnect();
|
|
47
|
+
motion.cancel(root); root.style.overflow = originalOverflow; if (nudgeTarget) motion.cancel(nudgeTarget);
|
|
48
|
+
const error = root.querySelector<HTMLElement>('.blu-field__error'); if (error) motion.cancel(error);
|
|
49
|
+
for (const press of buttons.values()) press.destroy(); buttons.clear();
|
|
50
|
+
if (previous === null) root.removeAttribute('data-blu-component-motion'); else root.setAttribute('data-blu-component-motion', previous);
|
|
51
|
+
} };
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
import { preserveStyles } from '../motion/structure.js';
|
|
3
|
+
|
|
4
|
+
/** The existing Sidebar/Pagination still owns navigation, focus and events.
|
|
5
|
+
* This marker only follows aria-current; it never intercepts links or history. */
|
|
6
|
+
export function enhanceNavigation(root: HTMLElement, motion: MotionController, kind: 'sidebar' | 'pagination' | 'tabs') {
|
|
7
|
+
const win = root.ownerDocument.defaultView!, restore = preserveStyles(root, ['position']);
|
|
8
|
+
const originalKind = root.getAttribute('data-blu-navigation-motion'); root.setAttribute('data-blu-navigation-motion', kind);
|
|
9
|
+
const marker = root.ownerDocument.createElement('span');
|
|
10
|
+
marker.className = 'blu-motion-navigation-marker'; marker.dataset.kind = kind;
|
|
11
|
+
marker.setAttribute('aria-hidden', 'true'); marker.hidden = true;
|
|
12
|
+
root.style.position = 'relative'; root.append(marker);
|
|
13
|
+
let current: HTMLElement | null = null, key = '', geometry = '', dead = false, moving = false, generation = 0;
|
|
14
|
+
let range: HTMLElement | null = null, rangeText = '';
|
|
15
|
+
const stop = () => { ++generation; moving = false; motion.cancel(marker); };
|
|
16
|
+
function place(animate = true) {
|
|
17
|
+
if (dead) return;
|
|
18
|
+
// Sidebar renders replace its children, so keep our decoration outside that render.
|
|
19
|
+
if (!root.contains(marker)) root.append(marker);
|
|
20
|
+
const next = root.querySelector<HTMLElement>(kind === 'tabs' ? '[role=tab][aria-selected=true]' : '[aria-current="page"]')
|
|
21
|
+
|| (kind === 'pagination' ? root.querySelector<HTMLElement>('.blu-pagination__current') : null);
|
|
22
|
+
if (current !== next) { if (current) resize.unobserve(current); current = next; if (current) resize.observe(current); }
|
|
23
|
+
if (!next || !next.getClientRects().length) { if (!marker.hidden) marker.hidden = true; key = ''; geometry = ''; stop(); return; }
|
|
24
|
+
const nextKey = next.dataset.navigationKey || next.getAttribute('aria-label') || next.textContent || '';
|
|
25
|
+
const from = marker.hidden ? null : marker.getBoundingClientRect();
|
|
26
|
+
const parent = root.getBoundingClientRect(), target = next.getBoundingClientRect();
|
|
27
|
+
const x = target.left - parent.left + root.scrollLeft - root.clientLeft;
|
|
28
|
+
const y = target.top - parent.top + root.scrollTop - root.clientTop;
|
|
29
|
+
const nextGeometry = [x, y, target.width, target.height].join(',');
|
|
30
|
+
if (nextGeometry === geometry && nextKey === key && !marker.hidden) return;
|
|
31
|
+
// A page-size chooser can finish its layout after the selected page changes.
|
|
32
|
+
// Continue from the visible position instead of cancelling that transition.
|
|
33
|
+
const continueMove = moving, changed = key !== nextKey;
|
|
34
|
+
stop(); if (marker.hidden) marker.hidden = false;
|
|
35
|
+
Object.assign(marker.style, {
|
|
36
|
+
left: (x + (kind === 'tabs' ? 6 : 0)) + 'px',
|
|
37
|
+
top: (y + (kind === 'sidebar' ? 8 : kind === 'tabs' ? target.height - 3 : 0)) + 'px',
|
|
38
|
+
width: (kind === 'sidebar' ? 3 : kind === 'tabs' ? Math.max(1, target.width - 12) : target.width) + 'px',
|
|
39
|
+
height: (kind === 'sidebar' ? Math.max(1, target.height - 16) : kind === 'tabs' ? 3 : target.height) + 'px',
|
|
40
|
+
});
|
|
41
|
+
if ((animate && changed || continueMove) && from) {
|
|
42
|
+
const to = marker.getBoundingClientRect();
|
|
43
|
+
const own = ++generation; moving = true;
|
|
44
|
+
void motion.animate(marker, [{ transform: `translate(${from.left-to.left}px,${from.top-to.top}px) scale(${from.width/to.width},${from.height/to.height})` }, { transform: 'none' }], { duration: kind === 'pagination' ? 750 : 450, easing: 'ease-in-out' }).finally(() => { if (own === generation) moving = false; });
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'pagination') {
|
|
47
|
+
const nextRange = root.querySelector<HTMLElement>('.blu-pagination__range');
|
|
48
|
+
if (range !== nextRange && range) motion.cancel(range);
|
|
49
|
+
const text = nextRange?.textContent || '';
|
|
50
|
+
if (animate && rangeText && text !== rangeText && nextRange) void motion.animate(nextRange, [{ opacity: .35 }, { opacity: 1 }], { duration: 750, easing: 'ease-in-out' }, 'fade');
|
|
51
|
+
range = nextRange; rangeText = text;
|
|
52
|
+
}
|
|
53
|
+
key = nextKey; geometry = nextGeometry;
|
|
54
|
+
}
|
|
55
|
+
const resize = new ResizeObserver(() => place(false));
|
|
56
|
+
const observer = new MutationObserver(() => place());
|
|
57
|
+
const onScroll = () => { stop(); place(false); };
|
|
58
|
+
place(false); resize.observe(root);
|
|
59
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-current', 'aria-selected', 'hidden'] });
|
|
60
|
+
root.addEventListener('scroll', onScroll, true); win.addEventListener('resize', onScroll);
|
|
61
|
+
return { destroy() { if (dead) return; dead = true; observer.disconnect(); resize.disconnect(); stop(); if(range)motion.cancel(range); marker.remove(); restore(); if (originalKind === null) root.removeAttribute('data-blu-navigation-motion'); else root.setAttribute('data-blu-navigation-motion', originalKind); root.removeEventListener('scroll', onScroll, true); win.removeEventListener('resize', onScroll); } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
|
|
3
|
+
/** Observe the existing message stack. Its runtime still owns timers, pause on
|
|
4
|
+
* hover/focus, announcements and dismissal. Exits are decorative, inert copies. */
|
|
5
|
+
export function enhanceNotifications(root: HTMLElement, motion: MotionController) {
|
|
6
|
+
const previous = root.getAttribute('data-blu-component-motion'); root.setAttribute('data-blu-component-motion', 'notifications');
|
|
7
|
+
const cards = new Map<HTMLElement, { rect: DOMRect; opacity: string }>(), ghosts = new Set<HTMLElement>();
|
|
8
|
+
let dead = false, frame = 0, generation = 0;
|
|
9
|
+
const win = root.ownerDocument.defaultView!;
|
|
10
|
+
// Some engines emit focusin/out but do not support onfocusin/out properties.
|
|
11
|
+
// Forward only in those engines; the existing handlers own the timer state.
|
|
12
|
+
type FocusCard = HTMLElement & { onfocusin?: (event: FocusEvent) => void; onfocusout?: (event: FocusEvent) => void };
|
|
13
|
+
const forwardFocus = (event: FocusEvent) => {
|
|
14
|
+
const card = (event.target as Element).closest<FocusCard>('.blu-notifications > .blu-notification');
|
|
15
|
+
if (!card || !root.contains(card)) return;
|
|
16
|
+
if (event.type === 'focusin') card.onfocusin?.call(card, event); else card.onfocusout?.call(card, event);
|
|
17
|
+
};
|
|
18
|
+
const bridgeIn = !('onfocusin' in root), bridgeOut = !('onfocusout' in root);
|
|
19
|
+
if (bridgeIn) root.addEventListener('focusin', forwardFocus);
|
|
20
|
+
if (bridgeOut) root.addEventListener('focusout', forwardFocus);
|
|
21
|
+
const measure = () => { for (const [card, state] of cards) if (card.isConnected) { state.rect = card.getBoundingClientRect(); state.opacity = win.getComputedStyle(card).opacity; } };
|
|
22
|
+
const resize = new ResizeObserver(measure);
|
|
23
|
+
function exit(card: HTMLElement, state: { rect: DOMRect; opacity: string }) {
|
|
24
|
+
if (!state.rect.width || motion.mode === 'off' || root.closest('.blu-motion-off,[data-blu-motion=off]')) return;
|
|
25
|
+
const copy = card.cloneNode(true) as HTMLElement;
|
|
26
|
+
for (const el of [copy, ...copy.querySelectorAll<HTMLElement>('*')]) {
|
|
27
|
+
for (const attr of ['id','name','role','aria-live','aria-labelledby','aria-describedby','aria-controls','tabindex','autofocus']) el.removeAttribute(attr);
|
|
28
|
+
}
|
|
29
|
+
copy.dataset.bluExitVisual = 'notification'; copy.inert = true; copy.setAttribute('aria-hidden', 'true');
|
|
30
|
+
const rect = state.rect;
|
|
31
|
+
Object.assign(copy.style, { position: 'fixed', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', boxSizing: 'border-box', margin: '0', zIndex: '1300', pointerEvents: 'none', animation: 'none', opacity: state.opacity });
|
|
32
|
+
root.append(copy); ghosts.add(copy);
|
|
33
|
+
void motion.animate(copy, [{ opacity: state.opacity }, { opacity: 0 }], { phase: 'exit', duration: 450, easing: 'ease-in-out' }, 'fade').then(() => { copy.remove(); ghosts.delete(copy); });
|
|
34
|
+
}
|
|
35
|
+
function sync() {
|
|
36
|
+
if (dead) return;
|
|
37
|
+
const current = Array.from(root.querySelectorAll<HTMLElement>('.blu-notifications > .blu-notification'));
|
|
38
|
+
if (current.length === cards.size && current.every(card => cards.has(card))) return;
|
|
39
|
+
const own = ++generation; win.cancelAnimationFrame(frame);
|
|
40
|
+
const tasks: Promise<unknown>[] = [];
|
|
41
|
+
for (const [card, state] of cards) if (!current.includes(card)) { motion.cancel(card); resize.unobserve(card); cards.delete(card); exit(card, state); }
|
|
42
|
+
for (const card of current) {
|
|
43
|
+
const before = cards.get(card); motion.cancel(card);
|
|
44
|
+
const rect = card.getBoundingClientRect();
|
|
45
|
+
cards.set(card, { rect, opacity: win.getComputedStyle(card).opacity }); resize.observe(card);
|
|
46
|
+
if (!before) tasks.push(motion.play(card, 'slide-up', { duration: 450, easing: 'ease-out' }));
|
|
47
|
+
else {
|
|
48
|
+
const x = before.rect.left - rect.left, y = before.rect.top - rect.top;
|
|
49
|
+
if (Math.abs(x) > .5 || Math.abs(y) > .5 || Number(before.opacity) < .99) tasks.push(motion.animate(card, [{ transform: `translate(${x}px,${y}px)`, opacity: before.opacity }, { transform: 'none', opacity: 1 }], { duration: 450, easing: 'ease-in-out' }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const track = () => { if (dead || own !== generation) return; measure(); frame = win.requestAnimationFrame(track); };
|
|
53
|
+
frame = win.requestAnimationFrame(track);
|
|
54
|
+
void Promise.all(tasks).then(() => { if (!dead && own === generation) { win.cancelAnimationFrame(frame); measure(); } });
|
|
55
|
+
}
|
|
56
|
+
const observer = new MutationObserver(sync); observer.observe(root, { childList: true, subtree: true }); sync();
|
|
57
|
+
win.addEventListener('resize', measure); win.addEventListener('scroll', measure, true);
|
|
58
|
+
return { destroy() {
|
|
59
|
+
if (dead) return; dead = true; ++generation; observer.disconnect(); resize.disconnect(); win.cancelAnimationFrame(frame);
|
|
60
|
+
win.removeEventListener('resize', measure); win.removeEventListener('scroll', measure, true);
|
|
61
|
+
if (bridgeIn) root.removeEventListener('focusin', forwardFocus);
|
|
62
|
+
if (bridgeOut) root.removeEventListener('focusout', forwardFocus);
|
|
63
|
+
for (const card of cards.keys()) motion.cancel(card); cards.clear();
|
|
64
|
+
for (const ghost of ghosts) { motion.cancel(ghost); ghost.remove(); } ghosts.clear();
|
|
65
|
+
if (previous === null) root.removeAttribute('data-blu-component-motion'); else root.setAttribute('data-blu-component-motion', previous);
|
|
66
|
+
} };
|
|
67
|
+
}
|