@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.
Files changed (304) hide show
  1. package/README.md +11 -5
  2. package/dist/build-audit.json +234 -44
  3. package/dist/checkbox.css +1 -0
  4. package/dist/checkbox.d.ts +2 -0
  5. package/dist/checkbox.js +1 -0
  6. package/dist/chunk-2BSBXDOT.js +1 -0
  7. package/dist/chunk-4LHIQROI.js +0 -0
  8. package/dist/chunk-5ENZVZGF.js +0 -0
  9. package/dist/chunk-6T677NSC.js +1 -0
  10. package/dist/chunk-7CEOCZ5L.js +0 -0
  11. package/dist/chunk-7DXFNK7C.js +1 -0
  12. package/dist/chunk-AL2ETLJV.js +1 -0
  13. package/dist/chunk-BN3PDE5G.js +0 -0
  14. package/dist/chunk-CNIIMGT3.js +0 -0
  15. package/dist/chunk-CSTCEBEK.js +1 -0
  16. package/dist/chunk-DRZM2AL3.js +0 -0
  17. package/dist/chunk-EOKNLCNS.js +1 -0
  18. package/dist/chunk-EPPETNK6.js +1 -0
  19. package/dist/chunk-EPPSBHZR.js +1 -0
  20. package/dist/chunk-EPZGLHPD.js +0 -0
  21. package/dist/chunk-FNX33PDD.js +0 -0
  22. package/dist/{chunk-NTFHNL7T.js → chunk-G6OW4OZM.js} +1 -1
  23. package/dist/chunk-GS67ZBLI.js +1 -0
  24. package/dist/chunk-HO6CYWED.js +0 -0
  25. package/dist/{chunk-6EHKOVZK.js → chunk-HVR2UDFX.js} +1 -1
  26. package/dist/chunk-IJ62ILKE.js +0 -0
  27. package/dist/chunk-IKYBFMHI.js +1 -0
  28. package/dist/chunk-IO22GWON.js +0 -0
  29. package/dist/chunk-KVOA55NE.js +0 -0
  30. package/dist/{chunk-MZBB5ROJ.js → chunk-LF2HELEZ.js} +1 -1
  31. package/dist/chunk-MYVLBALF.js +1 -0
  32. package/dist/{chunk-UEOCR2C3.js → chunk-OLDA73XG.js} +2 -2
  33. package/dist/chunk-RWBLICYX.js +0 -0
  34. package/dist/chunk-RYD6RHRX.js +0 -0
  35. package/dist/chunk-T45FJNEQ.js +1 -0
  36. package/dist/chunk-TZSX4OTV.js +1 -0
  37. package/dist/chunk-UN2SLVFG.js +1 -0
  38. package/dist/{chunk-GK3INPAZ.js → chunk-V2YPIC6Z.js} +1 -1
  39. package/dist/chunk-V4ZQLQ7L.js +0 -0
  40. package/dist/chunk-VAM3FPCS.js +0 -0
  41. package/dist/chunk-VJVFHL6P.js +0 -0
  42. package/dist/chunk-VSTKXZYY.js +0 -0
  43. package/dist/chunk-W3H7XTA7.js +1 -0
  44. package/dist/chunk-WG4US4JH.js +1 -0
  45. package/dist/chunk-WGSTNXAQ.js +0 -0
  46. package/dist/chunk-WJHCVECU.js +1 -0
  47. package/dist/chunk-XVMJG26X.js +1 -0
  48. package/dist/chunk-Y4AXWTAD.js +0 -0
  49. package/dist/chunk-YCLR3IE3.js +0 -0
  50. package/dist/chunk-YLFXSHKZ.js +0 -0
  51. package/dist/chunk-ZDGZGAYG.js +0 -0
  52. package/dist/columns2.css +1 -0
  53. package/dist/columns2.d.ts +2 -0
  54. package/dist/columns2.js +1 -0
  55. package/dist/columns3.css +1 -0
  56. package/dist/columns3.d.ts +2 -0
  57. package/dist/columns3.js +1 -0
  58. package/dist/columns4.css +1 -0
  59. package/dist/columns4.d.ts +2 -0
  60. package/dist/columns4.js +1 -0
  61. package/dist/command-palette.js +1 -1
  62. package/dist/component-motion.css +39 -0
  63. package/dist/component-motion.d.ts +45 -0
  64. package/dist/component-motion.js +1 -0
  65. package/dist/content.js +1 -1
  66. package/dist/core.css +8 -0
  67. package/dist/core.js +1 -1
  68. package/dist/data-motion.css +4 -0
  69. package/dist/data-motion.d.ts +23 -0
  70. package/dist/data-motion.js +1 -0
  71. package/dist/data.css +63 -0
  72. package/dist/data.d.ts +1 -0
  73. package/dist/data.js +1 -1
  74. package/dist/date-picker.css +5 -5
  75. package/dist/date-picker.js +1 -1
  76. package/dist/date-range-picker.css +5 -5
  77. package/dist/date-range-picker.js +1 -1
  78. package/dist/date-time-picker.css +5 -5
  79. package/dist/date-time-picker.js +1 -1
  80. package/dist/draft-components.json +77 -0
  81. package/dist/drawer.js +1 -1
  82. package/dist/essentials.css +8 -0
  83. package/dist/feedback.js +1 -1
  84. package/dist/form-field.css +1 -0
  85. package/dist/form-field.d.ts +2 -0
  86. package/dist/form-field.js +1 -0
  87. package/dist/form-layout/choice.d.ts +15 -0
  88. package/dist/form-layout/layout.d.ts +12 -0
  89. package/dist/form-layout/select.d.ts +3 -0
  90. package/dist/form-layout/shared.d.ts +21 -0
  91. package/dist/form-layout/table.d.ts +20 -0
  92. package/dist/form-layout/text.d.ts +3 -0
  93. package/dist/form-layout/types.d.ts +151 -0
  94. package/dist/form-layout.css +62 -0
  95. package/dist/form-layout.d.ts +12 -0
  96. package/dist/form-layout.js +1 -0
  97. package/dist/index.d.ts +1 -0
  98. package/dist/index.js +1 -1
  99. package/dist/inputs.css +83 -20
  100. package/dist/inputs.d.ts +8 -0
  101. package/dist/inputs.js +1 -1
  102. package/dist/layout.css +63 -0
  103. package/dist/layout.d.ts +3 -0
  104. package/dist/layout.js +1 -1
  105. package/dist/modal.js +1 -1
  106. package/dist/motion/action-feedback.d.ts +14 -0
  107. package/dist/motion/border-glow.d.ts +12 -0
  108. package/dist/motion/button.d.ts +11 -0
  109. package/dist/motion/component-settings.d.ts +51 -0
  110. package/dist/motion/controller.d.ts +33 -0
  111. package/dist/motion/effects.d.ts +30 -0
  112. package/dist/motion/feedback.d.ts +6 -0
  113. package/dist/motion/loading.d.ts +9 -0
  114. package/dist/motion/optional.d.ts +35 -0
  115. package/dist/motion/photo.d.ts +7 -0
  116. package/dist/motion/presence.d.ts +12 -0
  117. package/dist/motion/scene.d.ts +10 -0
  118. package/dist/motion/screen.d.ts +6 -0
  119. package/dist/motion/scroll-reveal.d.ts +13 -0
  120. package/dist/motion/settings-group.d.ts +14 -0
  121. package/dist/motion/stagger.d.ts +12 -0
  122. package/dist/motion/structure.d.ts +18 -0
  123. package/dist/motion/tabs.d.ts +19 -0
  124. package/dist/motion/tokens.d.ts +11 -0
  125. package/dist/motion/value.d.ts +21 -0
  126. package/dist/motion/view-transition.d.ts +14 -0
  127. package/dist/motion-enhancers.d.ts +12 -0
  128. package/dist/motion-enhancers.js +1 -0
  129. package/dist/motion-settings.d.ts +3 -0
  130. package/dist/motion-settings.js +1 -0
  131. package/dist/motion.css +1076 -0
  132. package/dist/motion.d.ts +20 -0
  133. package/dist/motion.js +1 -0
  134. package/dist/native/date-picker.js +1 -1
  135. package/dist/native/date-range-picker.js +1 -1
  136. package/dist/native/date-time-picker.js +1 -1
  137. package/dist/native/form-layout.js +1 -0
  138. package/dist/native/time-picker.js +1 -1
  139. package/dist/native-components/form-layout.d.ts +1 -0
  140. package/dist/native-motion-components.d.ts +3 -0
  141. package/dist/native-motion-content.d.ts +1 -0
  142. package/dist/native-motion-foundations.d.ts +3 -0
  143. package/dist/native-motion-layout.d.ts +3 -0
  144. package/dist/native-motion-pilot.d.ts +34 -0
  145. package/dist/native-motion.d.ts +71 -0
  146. package/dist/notification.js +1 -1
  147. package/dist/pagination.js +1 -1
  148. package/dist/radio-button.css +1 -0
  149. package/dist/radio-button.d.ts +2 -0
  150. package/dist/radio-button.js +1 -0
  151. package/dist/radio-group.css +1 -0
  152. package/dist/radio-group.d.ts +2 -0
  153. package/dist/radio-group.js +1 -0
  154. package/dist/select.css +1 -0
  155. package/dist/select.d.ts +2 -0
  156. package/dist/select.js +1 -0
  157. package/dist/sidebar.js +1 -1
  158. package/dist/sizes.json +416 -112
  159. package/dist/skeleton.css +8 -0
  160. package/dist/styles.css +1213 -20
  161. package/dist/switch.css +1 -0
  162. package/dist/switch.d.ts +2 -0
  163. package/dist/switch.js +1 -0
  164. package/dist/table.css +1 -0
  165. package/dist/table.d.ts +2 -0
  166. package/dist/table.js +1 -0
  167. package/dist/text-area.css +1 -0
  168. package/dist/text-area.d.ts +2 -0
  169. package/dist/text-area.js +1 -0
  170. package/dist/text-input.css +1 -0
  171. package/dist/text-input.d.ts +2 -0
  172. package/dist/text-input.js +1 -0
  173. package/dist/time-picker.css +5 -5
  174. package/dist/time-picker.js +1 -1
  175. package/dist/tree-view.js +1 -1
  176. package/dist/web-motion/chip-ripple.d.ts +6 -0
  177. package/dist/web-motion/choice-ripple.d.ts +6 -0
  178. package/dist/web-motion/content.d.ts +9 -0
  179. package/dist/web-motion/data-rows.d.ts +12 -0
  180. package/dist/web-motion/fields.d.ts +6 -0
  181. package/dist/web-motion/navigation.d.ts +6 -0
  182. package/dist/web-motion/notifications.d.ts +6 -0
  183. package/dist/web-motion/select-ripple.d.ts +6 -0
  184. package/dist/web-motion/statistic.d.ts +13 -0
  185. package/dist/web-motion/surfaces.d.ts +10 -0
  186. package/dist/web-motion/tree.d.ts +6 -0
  187. package/docs/FORM-LAYOUT-ESSENTIALS.md +122 -0
  188. package/docs/PUBLISHING.md +129 -25
  189. package/draft-components.json +77 -0
  190. package/edition.json +44 -7
  191. package/npm-shrinkwrap.json +2 -2
  192. package/package.json +102 -10
  193. package/scripts/build-motion-css.mjs +36 -0
  194. package/scripts/build-motion-styles.mjs +29 -0
  195. package/scripts/build.mjs +20 -8
  196. package/scripts/motion-entries.mjs +2 -0
  197. package/src/checkbox.ts +2 -0
  198. package/src/columns2.ts +2 -0
  199. package/src/columns3.ts +2 -0
  200. package/src/columns4.ts +2 -0
  201. package/src/component-motion.ts +101 -0
  202. package/src/data-motion.ts +60 -0
  203. package/src/data.ts +1 -0
  204. package/src/form-field.ts +2 -0
  205. package/src/form-layout/choice.ts +82 -0
  206. package/src/form-layout/layout.ts +40 -0
  207. package/src/form-layout/select.ts +97 -0
  208. package/src/form-layout/shared.ts +57 -0
  209. package/src/form-layout/table.ts +86 -0
  210. package/src/form-layout/text.ts +73 -0
  211. package/src/form-layout/types.ts +59 -0
  212. package/src/form-layout.ts +12 -0
  213. package/src/index.ts +2 -0
  214. package/src/inputs.ts +8 -0
  215. package/src/layout.ts +3 -0
  216. package/src/motion/action-feedback.ts +38 -0
  217. package/src/motion/border-glow.ts +41 -0
  218. package/src/motion/button.ts +56 -0
  219. package/src/motion/component-settings.ts +147 -0
  220. package/src/motion/controller.ts +191 -0
  221. package/src/motion/effects.ts +75 -0
  222. package/src/motion/feedback.ts +41 -0
  223. package/src/motion/loading.ts +40 -0
  224. package/src/motion/optional.ts +123 -0
  225. package/src/motion/photo.ts +19 -0
  226. package/src/motion/presence.ts +44 -0
  227. package/src/motion/scene.ts +103 -0
  228. package/src/motion/screen.ts +15 -0
  229. package/src/motion/scroll-reveal.ts +49 -0
  230. package/src/motion/settings-group.ts +28 -0
  231. package/src/motion/stagger.ts +26 -0
  232. package/src/motion/structure.ts +75 -0
  233. package/src/motion/tabs.ts +105 -0
  234. package/src/motion/tokens.ts +2 -0
  235. package/src/motion/value.ts +61 -0
  236. package/src/motion/view-transition.ts +57 -0
  237. package/src/motion-enhancers.ts +12 -0
  238. package/src/motion-settings.ts +3 -0
  239. package/src/motion.ts +20 -0
  240. package/src/native-components/form-layout.ts +21 -0
  241. package/src/native-motion-components.ts +76 -0
  242. package/src/native-motion-content.ts +10 -0
  243. package/src/native-motion-foundations.ts +56 -0
  244. package/src/native-motion-layout.ts +35 -0
  245. package/src/native-motion-pilot.ts +151 -0
  246. package/src/native-motion.ts +188 -0
  247. package/src/radio-button.ts +2 -0
  248. package/src/radio-group.ts +2 -0
  249. package/src/runtime/date-picker.js +21 -12
  250. package/src/runtime/date-range-picker.js +21 -12
  251. package/src/runtime/date-time-picker.js +21 -12
  252. package/src/runtime/time-picker.js +21 -12
  253. package/src/select.ts +2 -0
  254. package/src/switch.ts +2 -0
  255. package/src/table.ts +2 -0
  256. package/src/text-area.ts +2 -0
  257. package/src/text-input.ts +2 -0
  258. package/src/types.ts +0 -12
  259. package/src/web-motion/chip-ripple.ts +31 -0
  260. package/src/web-motion/choice-ripple.ts +30 -0
  261. package/src/web-motion/content.ts +74 -0
  262. package/src/web-motion/data-rows.ts +106 -0
  263. package/src/web-motion/fields.ts +52 -0
  264. package/src/web-motion/navigation.ts +62 -0
  265. package/src/web-motion/notifications.ts +67 -0
  266. package/src/web-motion/select-ripple.ts +23 -0
  267. package/src/web-motion/statistic.ts +23 -0
  268. package/src/web-motion/surfaces.ts +137 -0
  269. package/src/web-motion/tree.ts +44 -0
  270. package/styles/checkbox.css +1 -0
  271. package/styles/columns2.css +1 -0
  272. package/styles/columns3.css +1 -0
  273. package/styles/columns4.css +1 -0
  274. package/styles/component-motion.css +39 -0
  275. package/styles/data-motion.css +4 -0
  276. package/styles/date-picker.css +5 -5
  277. package/styles/date-range-picker.css +5 -5
  278. package/styles/date-time-picker.css +5 -5
  279. package/styles/form-field.css +1 -0
  280. package/styles/form-layout.css +62 -0
  281. package/styles/motion-optional.css +15 -0
  282. package/styles/motion-presets.css +92 -0
  283. package/styles/motion-tokens.css +6 -0
  284. package/styles/motion.css +125 -0
  285. package/styles/radio-button.css +1 -0
  286. package/styles/radio-group.css +1 -0
  287. package/styles/select.css +1 -0
  288. package/styles/skeleton.css +8 -0
  289. package/styles/switch.css +1 -0
  290. package/styles/table.css +1 -0
  291. package/styles/text-area.css +1 -0
  292. package/styles/text-input.css +1 -0
  293. package/styles/time-picker.css +5 -5
  294. package/test/component-motion-settings.test.mjs +35 -0
  295. package/test/content-overlay-motion.test.mjs +36 -0
  296. package/test/form-layout.test.mjs +28 -0
  297. package/test/isolation.test.mjs +2 -2
  298. package/test/motion.test.mjs +215 -0
  299. package/dist/chunk-2TCNAQC6.js +0 -1
  300. package/dist/chunk-CTLZIWMX.js +0 -1
  301. package/dist/chunk-MHHJZYXW.js +0 -1
  302. package/dist/chunk-XDYBNO4P.js +0 -1
  303. /package/dist/{chunk-H7ETPJVW.js → chunk-3PODK22R.js} +0 -0
  304. /package/dist/{chunk-SIU7DSOA.js → chunk-KBWB4OIF.js} +0 -0
@@ -0,0 +1,123 @@
1
+ import type { MotionController, MotionOptions, MotionResult } from './controller.js';
2
+ import { preserveStyles } from './structure.js';
3
+
4
+ const overlayHosts = new WeakMap<HTMLElement, { count: number; restore(): void }>();
5
+ function overlayFor(host: HTMLElement, className: string) {
6
+ const win = host.ownerDocument.defaultView;
7
+ if (!win || !host.isConnected) throw Error('Mount optional motion on a connected element.');
8
+ let ownership = overlayHosts.get(host);
9
+ if (!ownership) {
10
+ ownership = { count: 0, restore: preserveStyles(host, ['position']) }; overlayHosts.set(host, ownership);
11
+ if (win.getComputedStyle(host).position === 'static') host.style.position = 'relative';
12
+ }
13
+ ownership.count++; let released = false;
14
+ const restore = () => { if (released) return; released = true; if (--ownership!.count === 0) { ownership!.restore(); overlayHosts.delete(host); } };
15
+ const overlay = host.ownerDocument.createElement('span');
16
+ overlay.className = className; overlay.setAttribute('aria-hidden', 'true'); overlay.inert = true; host.append(overlay);
17
+ return { overlay, restore, win };
18
+ }
19
+ const rippleOwners = new WeakSet<HTMLElement>();
20
+ /** Responds to an actual click; never synthesizes activation or changes focus. */
21
+ export function createRipple(element: HTMLElement, motion: MotionController, options: MotionOptions & {listen?:boolean} = {}) {
22
+ const {listen=true,...timing}=options;
23
+ if (rippleOwners.has(element)) throw Error('This element already has ripple feedback.');
24
+ const { overlay, restore } = overlayFor(element, 'blu-motion-ripple');
25
+ rippleOwners.add(element);
26
+ let current: HTMLElement | undefined, dead = false;
27
+ const clear = () => { if (current) { motion.cancel(current); current.remove(); current = undefined; } };
28
+ // Presentation only: this also supports controls replaced during their click.
29
+ // It never dispatches a click or changes application state.
30
+ const play = (point?: {clientX:number;clientY:number}) => {
31
+ if (dead || !element.isConnected || element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true' || element.closest('[inert]')) return;
32
+ clear();
33
+ if (motion.mode !== 'full' || element.closest('.blu-motion-off, [data-blu-motion="off"], [data-blu-motion="reduced"]')) return;
34
+ const bounds = overlay.getBoundingClientRect(), width = overlay.clientWidth, height = overlay.clientHeight;
35
+ if (!width || !height || !bounds.width || !bounds.height) return;
36
+ const x = !point ? width / 2 : Math.min(width, Math.max(0, (point.clientX - bounds.left) * width / bounds.width));
37
+ const y = !point ? height / 2 : Math.min(height, Math.max(0, (point.clientY - bounds.top) * height / bounds.height));
38
+ const diameter = Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) * 2;
39
+ const wave = element.ownerDocument.createElement('span'); wave.className = 'blu-motion-ripple__wave';
40
+ wave.style.cssText = `width:${diameter}px;height:${diameter}px;left:${x-diameter/2}px;top:${y-diameter/2}px`;
41
+ overlay.append(wave); current = wave;
42
+ void motion.animate(wave, [{ transform: 'scale(0)', opacity: .4 }, { transform: 'scale(.8)', opacity: .25, offset: .65 }, { transform: 'scale(1)', opacity: 0 }], { duration: 1000, easing: 'ease-out', ...timing }).finally(() => {
43
+ wave.remove(); if (current === wave) current = undefined;
44
+ });
45
+ };
46
+ const click = (event: MouseEvent) => { if(!event.defaultPrevented)play(event.detail ? event : undefined); };
47
+ if(listen)element.addEventListener('click', click);
48
+ return { element: overlay, play, destroy() { if (dead) return; dead = true; element.removeEventListener('click', click); clear(); overlay.remove(); restore(); rippleOwners.delete(element); } };
49
+ }
50
+
51
+ export interface ShimmerOptions { duration?: number; paused?: boolean }
52
+ const shimmerOwners = new WeakSet<HTMLElement>();
53
+ /** Attach to a decorative skeleton only. The caller owns busy/status semantics. */
54
+ export function createShimmer(element: HTMLElement, options: ShimmerOptions = {}) {
55
+ const validate = (value = 1800) => { if (!Number.isFinite(value) || value < 1200 || value > 6000) throw Error('Shimmer duration must be 1200–6000 ms.'); return value; };
56
+ let duration = validate(options.duration), paused = options.paused ?? false, visible = false, dead = false;
57
+ if (shimmerOwners.has(element)) throw Error('This element already has a shimmer.');
58
+ const { overlay, restore, win } = overlayFor(element, 'blu-motion-shimmer');
59
+ const beam = element.ownerDocument.createElement('span'); beam.className = 'blu-motion-shimmer__beam'; overlay.append(beam);
60
+ shimmerOwners.add(element);
61
+ const sync = () => { overlay.style.setProperty('--blu-shimmer-duration', duration + 'ms'); overlay.dataset.paused = String(paused || !visible || element.ownerDocument.hidden); };
62
+ const observer = win.IntersectionObserver ? new win.IntersectionObserver(entries => { visible = entries[0].isIntersecting; sync(); }) : null;
63
+ if (observer) observer.observe(element); else visible = true;
64
+ element.ownerDocument.addEventListener('visibilitychange', sync); sync();
65
+ return { element: overlay, update(next: ShimmerOptions) {
66
+ if (dead) throw Error('Shimmer is destroyed.');
67
+ if (next.duration !== undefined) duration = validate(next.duration);
68
+ if (next.paused !== undefined) paused = next.paused;
69
+ sync();
70
+ }, destroy() { if (dead) return; dead = true; observer?.disconnect(); element.ownerDocument.removeEventListener('visibilitychange', sync); overlay.remove(); restore(); shimmerOwners.delete(element); } };
71
+ }
72
+
73
+ export type MotionIcon = 'chevron' | 'plus-minus' | 'menu-close' | 'save-check';
74
+ const ns = 'http://www.w3.org/2000/svg';
75
+ /** Owns an empty decorative icon slot only. Labels and aria-expanded/pressed belong to the control. */
76
+ export function createIconTransition(host: HTMLElement, motion: MotionController, kind: MotionIcon, initial = false) {
77
+ if (!host.isConnected || !host.ownerDocument.defaultView) throw Error('Mount icon motion on a connected element.');
78
+ if (host.childNodes.length) throw Error('Icon motion requires an empty decorative slot.');
79
+ if (!['chevron', 'plus-minus', 'menu-close', 'save-check'].includes(kind)) throw Error('Unknown motion icon.');
80
+ const doc = host.ownerDocument, win = doc.defaultView!;
81
+ const svg = doc.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); svg.classList.add('blu-motion-icon');
82
+ const paths = kind === 'chevron' ? ['M6 9l6 6 6-6'] : kind === 'plus-minus' ? ['M5 12h14', 'M12 5v14'] : kind === 'menu-close' ? ['M4 6h16', 'M4 12h16', 'M4 18h16'] : ['M4 3h13l3 3v15H4z M8 3v6h8V3 M8 21v-7h8v7', 'M5 12l4 4L19 6'];
83
+ const parts = paths.map(d => { const p = doc.createElementNS(ns, 'path'); p.setAttribute('d', d); p.style.transformOrigin = '12px 12px'; svg.append(p); return p; }); host.append(svg);
84
+ let active = initial, dead = false;
85
+ const state = (on: boolean, index: number) => {
86
+ if (kind === 'chevron') return { transform: `rotate(${on ? 180 : 0}deg)`, opacity: '1' };
87
+ if (kind === 'plus-minus') return { transform: `rotate(${on && index === 1 ? 90 : 0}deg)`, opacity: on && index === 1 ? '0' : '1' };
88
+ if (kind === 'menu-close') return { transform: !on ? 'none' : index === 0 ? 'rotate(45deg) translateY(6px)' : index === 2 ? 'rotate(-45deg) translateY(-6px)' : 'scaleX(.5)', opacity: on && index === 1 ? '0' : '1' };
89
+ return { transform: `scale(${on === (index === 1) ? 1 : .85})`, opacity: on === (index === 1) ? '1' : '0' };
90
+ };
91
+ parts.forEach((part, i) => Object.assign(part.style, state(active, i)));
92
+ return { element: svg, async setActive(next: boolean, options: MotionOptions = {}): Promise<MotionResult> {
93
+ if (dead) return 'cancelled';
94
+ if (next === active) return 'finished'; active = next;
95
+ const results = await Promise.all(parts.map((part, i) => {
96
+ const css = win.getComputedStyle(part), from = { transform: css.transform, opacity: css.opacity };
97
+ motion.cancel(part); const to = state(next, i); Object.assign(part.style, to);
98
+ return motion.animate(part, [from, to], { duration: 450, easing: 'ease-in-out', ...options });
99
+ }));
100
+ return results.includes('cancelled') ? 'cancelled' : 'finished';
101
+ }, destroy() { if (dead) return; dead = true; parts.forEach(part => motion.cancel(part)); svg.remove(); } };
102
+ }
103
+
104
+ /** Persistent selection decoration; never replaces a focus outline. */
105
+ export function createEmphasis(element: HTMLElement, motion: MotionController, kind: 'underline' | 'border' = 'underline') {
106
+ if (!['underline', 'border'].includes(kind)) throw Error('Unknown emphasis style.');
107
+ const { overlay, restore, win } = overlayFor(element, 'blu-motion-emphasis blu-motion-emphasis--' + kind);
108
+ let target: HTMLElement | SVGElement = overlay, resize: ResizeObserver | undefined;
109
+ if (kind === 'border') {
110
+ const svg = element.ownerDocument.createElementNS(ns, 'svg'), rect = element.ownerDocument.createElementNS(ns, 'rect');
111
+ rect.setAttribute('pathLength', '1'); rect.setAttribute('vector-effect', 'non-scaling-stroke'); svg.append(rect); overlay.append(svg); target = rect;
112
+ const measure = () => { const { width, height } = overlay.getBoundingClientRect(); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); rect.setAttribute('x', '1'); rect.setAttribute('y', '1'); rect.setAttribute('width', String(Math.max(0, width - 2))); rect.setAttribute('height', String(Math.max(0, height - 2))); rect.setAttribute('rx', win.getComputedStyle(element).borderTopLeftRadius); };
113
+ resize = new win.ResizeObserver(measure); resize.observe(element); measure(); target.style.strokeDasharray = '1'; target.style.strokeDashoffset = '1';
114
+ } else target.style.transform = 'scaleX(0)';
115
+ let active = false, dead = false;
116
+ return { element: overlay, async setActive(next: boolean, options: MotionOptions = {}): Promise<MotionResult> {
117
+ if (dead) return 'cancelled';
118
+ if (active === next) return 'finished'; active = next;
119
+ const css = win.getComputedStyle(target), property = kind === 'border' ? 'strokeDashoffset' : 'transform', from = css[property];
120
+ motion.cancel(target); const to = kind === 'border' ? (next ? '0' : '1') : `scaleX(${next ? 1 : 0})`; target.style[property] = to;
121
+ return motion.animate(target, [{ [property]: from }, { [property]: to }], { duration: 750, easing: 'ease-in-out', ...options });
122
+ }, destroy() { if (dead) return; dead = true; motion.cancel(target); resize?.disconnect(); overlay.remove(); restore(); } };
123
+ }
@@ -0,0 +1,19 @@
1
+ import type { MotionController, MotionResult } from './controller.js';
2
+ import { preserveStyles, type TransitionOptions } from './structure.js';
3
+ /** A persistent crop change inside an overflow-hidden frame; does not fade the photo away. */
4
+ export function createPhotoZoom(image: HTMLImageElement, motion: MotionController, maximum = 1.2) {
5
+ if (!Number.isFinite(maximum) || maximum < 1 || maximum > 2) throw new Error('Photo zoom must be between 1 and 2.');
6
+ const restore = preserveStyles(image, ['transform', 'transform-origin']);
7
+ let revision = 0, destroyed = false;
8
+ image.style.transformOrigin = 'center';
9
+ return {
10
+ async setZoomed(zoomed: boolean, options: TransitionOptions = {}): Promise<MotionResult> {
11
+ if (destroyed) return 'cancelled';
12
+ const own = ++revision, from = image.ownerDocument.defaultView!.getComputedStyle(image).transform;
13
+ motion.cancel(image); image.style.transform = `scale(${zoomed ? maximum : 1})`;
14
+ await motion.animate(image, [{ transform: from }, { transform: image.style.transform }], { duration: 1000, easing: 'ease-in-out', ...options });
15
+ return !destroyed && own === revision ? 'finished' : 'cancelled';
16
+ },
17
+ destroy() { if (destroyed) return; destroyed = true; ++revision; motion.cancel(image); restore(); },
18
+ };
19
+ }
@@ -0,0 +1,44 @@
1
+ import type { MotionController, MotionEffect, MotionOptions, MotionResult } from './controller.js';
2
+
3
+ export interface PresenceOptions extends Omit<MotionOptions, 'phase'> {
4
+ effect?: MotionEffect;
5
+ /** Required when hiding an element that currently contains keyboard focus. */
6
+ focusFallback?: HTMLElement;
7
+ }
8
+ /** Owns hidden/inert for a mounted element. Use for panels, cards and menus; no router dependency. */
9
+ export function createMotionPresence(element: HTMLElement, motion: MotionController) {
10
+ const originalHidden = element.hidden, originalInert = element.inert;
11
+ let revision = 0, destroyed = false;
12
+ return {
13
+ async show(options: PresenceOptions = {}): Promise<MotionResult> {
14
+ if (destroyed) return 'cancelled';
15
+ const own = ++revision;
16
+ motion.cancel(element);
17
+ element.hidden = false;
18
+ element.inert = originalInert;
19
+ await motion.play(element, options.effect || 'fade', { ...options, phase: 'enter' });
20
+ return !destroyed && revision === own ? 'finished' : 'cancelled';
21
+ },
22
+ async hide(options: PresenceOptions = {}): Promise<MotionResult> {
23
+ if (destroyed) return 'cancelled';
24
+ if (element.contains(element.ownerDocument.activeElement)) {
25
+ const fallback = options.focusFallback;
26
+ if (!fallback?.isConnected || element.contains(fallback)) throw new Error('Provide a connected focusFallback outside the closing element.');
27
+ fallback.focus({ preventScroll: true });
28
+ if (element.contains(element.ownerDocument.activeElement)) throw new Error('focusFallback must be focusable.');
29
+ }
30
+ const own = ++revision;
31
+ motion.cancel(element);
32
+ element.inert = true;
33
+ if (!element.hidden) await motion.play(element, options.effect || 'fade', { ...options, phase: 'exit' });
34
+ if (destroyed || revision !== own) return 'cancelled';
35
+ element.hidden = true;
36
+ return 'finished';
37
+ },
38
+ destroy() {
39
+ if (destroyed) return;
40
+ destroyed = true; ++revision; motion.cancel(element);
41
+ element.hidden = originalHidden; element.inert = originalInert;
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,103 @@
1
+ import type { MotionController, MotionResult } from './controller.js';
2
+ import { preserveStyles, type TransitionOptions } from './structure.js';
3
+
4
+ function sharedNodes(root: HTMLElement) {
5
+ const nodes = new Map<string, HTMLElement>();
6
+ for (const node of Array.from(root.querySelectorAll<HTMLElement>('[data-shared-key]'))) {
7
+ const key = node.dataset.sharedKey!;
8
+ if (!key || nodes.has(key)) throw new Error('Shared elements need unique, nonempty data-shared-key values per view.');
9
+ if (node.matches('button, input, select, textarea, a, [tabindex], [contenteditable], canvas, video, iframe') || node.querySelector('button, input, select, textarea, a, [tabindex], [contenteditable], canvas, video, iframe, [data-shared-key]')) throw new Error('Shared elements must be decorative text, image or SVG wrappers, without interactive or nested shared content.');
10
+ nodes.set(key, node);
11
+ }
12
+ return nodes;
13
+ }
14
+
15
+ /** Crossfade real views concurrently; optional decorative shared elements connect matching views. */
16
+ function createSceneTransition(host: HTMLElement, motion: MotionController, shared: boolean) {
17
+ let revision = 0, destroyed = false, stop = () => {};
18
+ let activeVisuals = new Map<string, HTMLElement>();
19
+ return {
20
+ async show(next: HTMLElement, options: TransitionOptions = {}): Promise<MotionResult> {
21
+ if (destroyed) return 'cancelled';
22
+ if (next.parentNode || next === host || next.contains(host)) throw new Error('Provide a detached next view.');
23
+ // Validate before changing the current view or cancelling a successful pending transition.
24
+ const nextShared = shared ? sharedNodes(next) : new Map<string, HTMLElement>();
25
+ const own = ++revision, interruptedRects = new Map(Array.from(activeVisuals, ([key, visual]) => [key, visual.getBoundingClientRect()])); stop();
26
+ options = { duration: 280, ...options };
27
+ const old = host.firstElementChild as HTMLElement | null;
28
+ const oldShared = shared && old ? sharedNodes(old) : new Map<string, HTMLElement>();
29
+ const initialRects = new Map(Array.from(oldShared, ([key, node]) => [key, interruptedRects.get(key) || node.getBoundingClientRect()]));
30
+ const focused = host.ownerDocument.activeElement as HTMLElement | null, focusInside = !!old?.contains(focused);
31
+ const focusKey = focused?.dataset.focusKey, top = host.scrollTop, left = host.scrollLeft;
32
+ const restoreHost = preserveStyles(host, ['display', 'position', 'isolation', 'box-sizing', 'align-items']);
33
+ const restoreNext = preserveStyles(next, ['grid-area', 'min-width']);
34
+ const restoreOld = old ? preserveStyles(old, ['grid-area', 'min-width']) : () => {};
35
+ const oldInert = old?.inert, oldAria = old?.getAttribute('aria-hidden');
36
+ const oldHeight = host.getBoundingClientRect().height;
37
+ const targets: (HTMLElement | SVGElement)[] = [host, next]; if (old) targets.push(old);
38
+ const restoreVisuals: (() => void)[] = [], overlays: HTMLElement[] = [];
39
+ let cleaned = false;
40
+ const finish = () => {
41
+ if (cleaned) return; cleaned = true;
42
+ for (const target of targets) motion.cancel(target);
43
+ for (const overlay of overlays) overlay.remove();
44
+ activeVisuals.clear();
45
+ for (const restore of restoreVisuals) restore();
46
+ old?.remove(); restoreOld(); restoreNext(); restoreHost();
47
+ if (old) { old.inert = oldInert!; if (oldAria === null) old.removeAttribute('aria-hidden'); else old.setAttribute('aria-hidden', oldAria!); }
48
+ };
49
+ stop = finish;
50
+ try {
51
+ host.style.display = 'grid'; host.style.alignItems = 'start'; host.style.position = 'relative'; host.style.isolation = 'isolate'; host.style.boxSizing = 'border-box';
52
+ next.style.gridArea = '1 / 1'; next.style.minWidth = '0';
53
+ if (old) { old.style.gridArea = '1 / 1'; old.style.minWidth = '0'; }
54
+ host.append(next);
55
+ // Only the incoming view is interactive/announced. External navigation retains focus.
56
+ if (focusInside) {
57
+ const match = focusKey ? Array.from(next.querySelectorAll<HTMLElement>('[data-focus-key]')).find(node => node.dataset.focusKey === focusKey) : null;
58
+ const destination = match || next.querySelector<HTMLElement>('[data-view-heading]') || next;
59
+ const originalTabindex = destination.getAttribute('tabindex');
60
+ if (!match) destination.tabIndex = -1;
61
+ destination.focus({ preventScroll: true });
62
+ if (!match) { if (originalTabindex === null) destination.removeAttribute('tabindex'); else destination.setAttribute('tabindex', originalTabindex); }
63
+ }
64
+ if (old) { old.inert = true; old.setAttribute('aria-hidden', 'true'); }
65
+ host.scrollTop = top; host.scrollLeft = left;
66
+ const pending: Promise<MotionResult>[] = [];
67
+ const hostRect = host.getBoundingClientRect();
68
+ for (const [key, incoming] of nextShared) {
69
+ const outgoing = oldShared.get(key), first = initialRects.get(key), last = incoming.getBoundingClientRect();
70
+ if (!outgoing || !first?.width || !first.height || !last.width || !last.height || motion.mode !== 'full') continue;
71
+ // The clone is strictly presentational: no IDs, focus targets or event listeners.
72
+ const clone = outgoing.cloneNode(true) as HTMLElement;
73
+ for (const node of [clone, ...Array.from(clone.querySelectorAll<HTMLElement>('*'))]) {
74
+ node.removeAttribute('id'); node.removeAttribute('name'); node.removeAttribute('data-shared-key');
75
+ }
76
+ clone.inert = true; clone.setAttribute('aria-hidden', 'true'); clone.setAttribute('data-motion-overlay', '');
77
+ clone.style.cssText += `;position:absolute;pointer-events:none;margin:0;left:${first.left-hostRect.left+host.scrollLeft-host.clientLeft}px;top:${first.top-hostRect.top+host.scrollTop-host.clientTop}px;width:${first.width}px;height:${first.height}px;box-sizing:border-box;transform-origin:top left;z-index:10;`;
78
+ host.append(clone); overlays.push(clone); targets.push(clone); activeVisuals.set(key, clone);
79
+ for (const node of [outgoing, incoming]) { restoreVisuals.push(preserveStyles(node, ['visibility'])); node.style.visibility = 'hidden'; }
80
+ const style = host.ownerDocument.defaultView!.getComputedStyle(incoming);
81
+ pending.push(motion.animate(clone, [
82
+ { transform: 'none', width: first.width + 'px', height: first.height + 'px', borderRadius: host.ownerDocument.defaultView!.getComputedStyle(outgoing).borderRadius },
83
+ { transform: `translate(${last.left-first.left}px, ${last.top-first.top}px)`, width: last.width + 'px', height: last.height + 'px', borderRadius: style.borderRadius },
84
+ ], { easing: 'ease-in-out', ...options }));
85
+ }
86
+ // Both animations start in the same turn: this is an overlapping crossfade, not fade-out then fade-in.
87
+ if (old) pending.push(motion.play(old, 'fade', { ...options, phase: 'exit' }));
88
+ pending.push(motion.play(next, 'fade', { ...options, phase: 'enter' }));
89
+ // Reserve the incoming view's actual height, including host padding/borders.
90
+ if (old) {
91
+ const css = host.ownerDocument.defaultView!.getComputedStyle(host);
92
+ const chrome = parseFloat(css.paddingTop) + parseFloat(css.paddingBottom) + parseFloat(css.borderTopWidth) + parseFloat(css.borderBottomWidth);
93
+ pending.push(motion.animate(host, [{ height: oldHeight + 'px' }, { height: next.getBoundingClientRect().height + chrome + 'px' }], options));
94
+ }
95
+ await Promise.all(pending);
96
+ } finally { if (revision === own) { finish(); stop = () => {}; } }
97
+ return !destroyed && revision === own ? 'finished' : 'cancelled';
98
+ },
99
+ destroy() { if (destroyed) return; destroyed = true; ++revision; stop(); stop = () => {}; },
100
+ };
101
+ }
102
+ export function createCrossfade(host: HTMLElement, motion: MotionController) { return createSceneTransition(host, motion, false); }
103
+ export function createSharedElementTransition(host: HTMLElement, motion: MotionController) { return createSceneTransition(host, motion, true); }
@@ -0,0 +1,15 @@
1
+ import type {MotionController, MotionResult} from './controller.js';
2
+ /** Enhance an already-rendered container. The host owns visibility, DOM and navigation. */
3
+ export function createScreenMotion(scope:HTMLElement,motion:MotionController){
4
+ const seen=new WeakMap<HTMLElement,string>(),active=new Map<HTMLElement,number>();let dead=false,revision=0;
5
+ return {
6
+ enter(element:HTMLElement,key:string):Promise<MotionResult>{
7
+ if(dead)return Promise.resolve('cancelled');
8
+ if(!scope.contains(element))throw Error('Screen content must be inside its scope.');
9
+ if(seen.get(element)===key)return Promise.resolve('finished');
10
+ seen.set(element,key);const token=++revision;active.set(element,token);
11
+ return motion.animate(element,[{opacity:0,translate:'0px 12px'},{opacity:1,translate:'0px 0px'}],{duration:650,easing:'ease-out'},'fade').finally(()=>{if(active.get(element)===token)active.delete(element);});
12
+ },
13
+ destroy(){if(dead)return;dead=true;for(const el of active.keys())motion.cancel(el);active.clear();}
14
+ };
15
+ }
@@ -0,0 +1,49 @@
1
+ import type { MotionController, MotionEffect, MotionOptions } from './controller.js';
2
+ export interface ScrollRevealOptions extends Omit<MotionOptions, 'phase'> {
3
+ effect?: MotionEffect;
4
+ once?: boolean;
5
+ threshold?: number;
6
+ root?: Element | Document | null;
7
+ rootMargin?: string;
8
+ onReveal?: (element: HTMLElement) => void;
9
+ }
10
+
11
+ /** Progressive enhancement: nothing is hidden before the observer runs or when JS is unavailable. */
12
+ export function createScrollReveal(targets: Iterable<HTMLElement>, motion: MotionController, options: ScrollRevealOptions = {}) {
13
+ const elements = Array.from(new Set(targets));
14
+ const win = elements[0]?.ownerDocument.defaultView;
15
+ const inView = new Set<HTMLElement>(), played = new Set<HTMLElement>();
16
+ const pending = new Map<HTMLElement, object>();
17
+ let destroyed = false;
18
+ const threshold = Number.isFinite(options.threshold) ? Math.min(1, Math.max(0, options.threshold!)) : .15;
19
+ const once = options.once !== false;
20
+ const Observer = win?.IntersectionObserver;
21
+ // Unsupported/SSR documents keep their complete content without scroll-event polling.
22
+ const observer = Observer ? new Observer(entries => {
23
+ if (destroyed) return;
24
+ for (const entry of entries) {
25
+ const element = entry.target as HTMLElement;
26
+ const visible = entry.isIntersecting && entry.intersectionRatio >= threshold;
27
+ if (!visible) { inView.delete(element); continue; }
28
+ if (!element.isConnected || inView.has(element) || (once && played.has(element))) continue;
29
+ inView.add(element); played.add(element);
30
+ if (once) observer!.unobserve(element);
31
+ const token = {};
32
+ pending.set(element, token);
33
+ // A callback can update UI/counters without controlling visibility or triggering another reveal.
34
+ const effect = motion.play(element, options.effect || 'fade', { ...options, phase: 'enter' });
35
+ options.onReveal?.(element);
36
+ void effect.finally(() => { if (pending.get(element) === token) pending.delete(element); });
37
+ }
38
+ }, { root: options.root || null, rootMargin: options.rootMargin || '0px', threshold }) : null;
39
+ for (const element of elements) observer?.observe(element);
40
+ return {
41
+ destroy() {
42
+ if (destroyed) return;
43
+ destroyed = true;
44
+ observer?.disconnect();
45
+ for (const element of pending.keys()) motion.cancel(element);
46
+ pending.clear(); inView.clear(); played.clear();
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,28 @@
1
+ import { validateComponentMotion, type ComponentMotionSettings } from './component-settings.js';
2
+
3
+ /** Settings only: factories keep ownership of their components and business state. */
4
+ export function createMotionSettingsGroup(initial: ComponentMotionSettings = {}) {
5
+ let defaults = validateComponentMotion(initial);
6
+ const members = new Set<() => void>();
7
+ return {
8
+ get settings() { return { ...defaults }; },
9
+ configure(next: ComponentMotionSettings = {}) {
10
+ defaults = validateComponentMotion(next);
11
+ for (const apply of members) apply();
12
+ },
13
+ bind(update: (settings: ComponentMotionSettings) => void) {
14
+ let overrides: ComponentMotionSettings = {}, dead = false;
15
+ const apply = () => update({ ...defaults, ...overrides });
16
+ members.add(apply);
17
+ return {
18
+ setMotion(next: ComponentMotionSettings = {}) {
19
+ if (dead) throw Error('Component motion is destroyed.');
20
+ const valid = validateComponentMotion(next);
21
+ if (valid.mode === 'inherit') delete valid.mode;
22
+ overrides = valid; apply();
23
+ },
24
+ destroy() { dead = true; members.delete(apply); },
25
+ };
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { MotionController, MotionEffect, MotionOptions, MotionResult } from './controller.js';
2
+ import { boundedTime } from './controller.js';
3
+ export type StaggerOrder = 'forward' | 'reverse' | 'center' | 'rows' | 'columns';
4
+ export interface StaggerOptions extends MotionOptions {
5
+ gap?: number;
6
+ order?: StaggerOrder;
7
+ /** Used for row/column sequences. Supply the current rendered column count. */
8
+ columns?: number;
9
+ }
10
+
11
+ /** Logical ranks only: never reorder the DOM or change reading/tab order. */
12
+ export function staggerRanks(count: number, order: StaggerOrder = 'forward', columns = 1): number[] {
13
+ if (!Number.isInteger(count) || count < 0) throw Error('Stagger count must be a nonnegative integer.');
14
+ if (!['forward', 'reverse', 'center', 'rows', 'columns'].includes(order)) throw Error('Unknown stagger order.');
15
+ if (!Number.isInteger(columns) || columns < 1) throw Error('Stagger columns must be a positive integer.');
16
+ return Array.from({ length: count }, (_, i) => order === 'reverse' ? count - 1 - i
17
+ : order === 'center' ? Math.floor(Math.abs(i - (count - 1) / 2))
18
+ : order === 'rows' ? Math.floor(i / columns) : order === 'columns' ? i % columns : i);
19
+ }
20
+
21
+ /** A bounded entrance/exit sequence. Reduced/Off modes suppress delays in the controller. */
22
+ export function playStagger(motion: MotionController, elements: Iterable<HTMLElement>, effect: MotionEffect = 'fade', options: StaggerOptions = {}): Promise<MotionResult[]> {
23
+ const gap = boundedTime(options.gap, 45, 150), base = boundedTime(options.delay, 0, 500);
24
+ const targets = Array.from(elements), ranks = staggerRanks(targets.length, options.order, options.columns);
25
+ return Promise.all(targets.map((element, index) => motion.play(element, effect, { ...options, delay: Math.min(500, base + ranks[index] * gap) })));
26
+ }
@@ -0,0 +1,75 @@
1
+ import type { MotionController, MotionOptions, MotionResult } from './controller.js';
2
+
3
+ export type TransitionOptions = Omit<MotionOptions, 'phase'>;
4
+ /** Save only owned declarations, preserving unrelated inline styles and priorities. */
5
+ export function preserveStyles(element: HTMLElement | SVGElement, properties: string[]) {
6
+ const values = properties.map(key => [key, element.style.getPropertyValue(key), element.style.getPropertyPriority(key)]);
7
+ return () => { for (const [key, value, priority] of values) { if (value) element.style.setProperty(key, value, priority); else element.style.removeProperty(key); } };
8
+ }
9
+ export function moveFocusOut(element: HTMLElement, fallback?: HTMLElement) {
10
+ if (!element.contains(element.ownerDocument.activeElement)) return;
11
+ if (!fallback?.isConnected || element.contains(fallback)) throw new Error('Provide a connected focusFallback outside the closing element.');
12
+ fallback.focus({ preventScroll: true });
13
+ if (element.contains(element.ownerDocument.activeElement)) throw new Error('focusFallback must be focusable.');
14
+ }
15
+
16
+ /** Animate an unpadded outer wrapper; put padding/borders in its child. Handles auto dimensions. */
17
+ export function createSizeTransition(element: HTMLElement, motion: MotionController, axis: 'height' | 'width' = 'height') {
18
+ const original = { hidden: element.hidden, inert: element.inert };
19
+ let revision = 0, destroyed = false, release = () => {};
20
+ async function run(open: boolean, change: (() => void) | undefined, options: TransitionOptions & { focusFallback?: HTMLElement }): Promise<MotionResult> {
21
+ if (destroyed) return 'cancelled';
22
+ if (!open) moveFocusOut(element, options.focusFallback);
23
+ const from = element.hidden ? 0 : element.getBoundingClientRect()[axis], own = ++revision;
24
+ motion.cancel(element); release();
25
+ element.hidden = false; element.inert = open ? original.inert : true;
26
+ change?.();
27
+ const to = open ? element.getBoundingClientRect()[axis] : 0;
28
+ const restoreOuter = preserveStyles(element, ['overflow', 'box-sizing', 'will-change', axis]);
29
+ // A horizontal reveal clips a stable inner layout instead of rewrapping text every frame.
30
+ const child = axis === 'width' ? element.firstElementChild as HTMLElement | null : null;
31
+ const restoreChild = child ? preserveStyles(child, ['width', 'min-width', 'max-width', 'box-sizing']) : () => {};
32
+ if (child) { const width = child.getBoundingClientRect().width; child.style.boxSizing = 'border-box'; child.style.width = width + 'px'; child.style.minWidth = width + 'px'; child.style.maxWidth = width + 'px'; }
33
+ const restore = () => { restoreChild(); restoreOuter(); }; release = restore;
34
+ element.style.overflow = 'hidden'; element.style.boxSizing = 'border-box'; element.style.willChange = axis;
35
+ element.style[axis] = to + 'px';
36
+ try { await motion.animate(element, [{ [axis]: from + 'px' }, { [axis]: to + 'px' }], { duration: 450, easing: 'ease-in-out', ...options }); }
37
+ finally { if (revision === own) { restore(); release = () => {}; } }
38
+ if (destroyed || revision !== own) return 'cancelled';
39
+ element.hidden = !open;
40
+ return 'finished';
41
+ }
42
+ return {
43
+ setOpen(open: boolean, options: TransitionOptions & { focusFallback?: HTMLElement } = {}) { return run(open, undefined, options); },
44
+ resize(change: () => void, options: TransitionOptions = {}) { return run(true, change, options); },
45
+ destroy() { if (destroyed) return; destroyed = true; ++revision; motion.cancel(element); release(); element.hidden = original.hidden; element.inert = original.inert; },
46
+ };
47
+ }
48
+
49
+ /** FLIP movement for retained DOM nodes. The caller owns data changes and removed nodes. */
50
+ export function createLayoutTransition(container: HTMLElement, motion: MotionController) {
51
+ let revision = 0, destroyed = false;
52
+ const active = new Set<HTMLElement>();
53
+ return {
54
+ async update(elements: HTMLElement[], change: () => void, options: TransitionOptions = {}): Promise<MotionResult> {
55
+ if (destroyed) return 'cancelled';
56
+ if (elements.some(el => !container.contains(el))) throw new Error('Layout targets must belong to the container.');
57
+ const own = ++revision, before = new Map(elements.map(el => [el, el.getBoundingClientRect()]));
58
+ const focused = container.ownerDocument.activeElement as HTMLElement | null;
59
+ for (const el of active) motion.cancel(el); active.clear();
60
+ change();
61
+ if (focused?.isConnected && container.contains(focused) && focused !== container.ownerDocument.activeElement) focused.focus({ preventScroll: true });
62
+ const pending = elements.filter(el => container.contains(el)).map(el => {
63
+ const first = before.get(el)!, last = el.getBoundingClientRect(), x = first.left - last.left, y = first.top - last.top;
64
+ if (Math.abs(x) < .5 && Math.abs(y) < .5) return Promise.resolve();
65
+ const rest = el.ownerDocument.defaultView!.getComputedStyle(el).transform;
66
+ active.add(el);
67
+ return motion.animate(el, [{ transform: `translate(${x}px, ${y}px) ${rest === 'none' ? '' : rest}` }, { transform: rest }], options);
68
+ });
69
+ await Promise.all(pending);
70
+ if (destroyed || revision !== own) return 'cancelled';
71
+ active.clear(); return 'finished';
72
+ },
73
+ destroy() { destroyed = true; ++revision; for (const el of active) motion.cancel(el); active.clear(); },
74
+ };
75
+ }
@@ -0,0 +1,105 @@
1
+ import type { MotionController, MotionResult } from './controller.js';
2
+ import { preserveStyles } from './structure.js';
3
+ import { enhanceNavigation } from '../web-motion/navigation.js';
4
+
5
+ export interface MotionTab { key: string; tab: HTMLButtonElement; panel: HTMLElement }
6
+ export interface MotionTabsOptions {
7
+ /** Automatic selects with arrows. Manual selects with Enter/Space. */
8
+ activation?: 'automatic' | 'manual';
9
+ orientation?: 'horizontal' | 'vertical';
10
+ onChange?: (key: string) => void;
11
+ }
12
+
13
+ /** Local web helper for app-owned tabs. Panels remain mounted, preserving input state.
14
+ * Supply unique tab/panel IDs, a labelled tablist and one shared panel container. */
15
+ export function createMotionTabs(tablist: HTMLElement, items: MotionTab[], motion: MotionController, options: MotionTabsOptions = {}) {
16
+ const host = items[0]?.panel.parentElement;
17
+ if (!items.length || !host || new Set(items.map(i => i.key)).size !== items.length || items.some(i => !i.key || !i.tab.id || !i.panel.id || !tablist.contains(i.tab) || i.panel.parentElement !== host)) throw Error('Tabs need unique keys, tab/panel IDs and a shared panel container.');
18
+ if (new Set(items.flatMap(i => [i.tab.id, i.panel.id])).size !== items.length * 2) throw Error('Tab and panel IDs must be unique.');
19
+ const enabled = (i: MotionTab) => !i.tab.disabled && i.tab.getAttribute('aria-disabled') !== 'true';
20
+ let selected = items.find(i => enabled(i) && i.tab.getAttribute('aria-selected') === 'true') || items.find(enabled);
21
+ if (!selected) throw Error('Tabs require an enabled tab.');
22
+ let dead = false, revision = 0, release = () => {};
23
+ const win = host.ownerDocument.defaultView!;
24
+ const attributes = (el: HTMLElement, keys: string[]) => {
25
+ const saved = keys.map(k => [k, el.getAttribute(k)]);
26
+ return () => { for (const [key, value] of saved) { if (value === null) el.removeAttribute(key!); else el.setAttribute(key!, value!); } };
27
+ };
28
+ const restores = [attributes(tablist, ['role', 'aria-orientation'])];
29
+ tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', options.orientation || 'horizontal');
30
+ for (const item of items) {
31
+ restores.push(attributes(item.tab, ['role', 'aria-controls', 'aria-selected', 'tabindex', 'type']), attributes(item.panel, ['role', 'aria-labelledby', 'aria-hidden', 'hidden', 'inert', 'tabindex']));
32
+ item.tab.type = 'button'; item.tab.setAttribute('role', 'tab'); item.tab.setAttribute('aria-controls', item.panel.id);
33
+ item.panel.setAttribute('role', 'tabpanel'); item.panel.setAttribute('aria-labelledby', item.tab.id); item.panel.tabIndex = 0;
34
+ }
35
+ function semantics() {
36
+ for (const item of items) {
37
+ const active = item === selected;
38
+ item.tab.setAttribute('aria-selected', String(active)); item.tab.tabIndex = active ? 0 : -1;
39
+ item.panel.inert = !active; item.panel.setAttribute('aria-hidden', String(!active));
40
+ }
41
+ }
42
+ semantics(); for (const item of items) item.panel.hidden = item !== selected;
43
+ const indicator = enhanceNavigation(tablist, motion, 'tabs');
44
+ async function select(key: string): Promise<MotionResult> {
45
+ const next = items.find(i => i.key === key);
46
+ if (dead || !next || !enabled(next)) return 'cancelled';
47
+ if (next === selected) { semantics(); return 'finished'; }
48
+ const own = ++revision, old = selected!;
49
+ const height = host!.getBoundingClientRect().height;
50
+ const from = win.getComputedStyle(old.panel).opacity;
51
+ const incoming = next.panel.hidden ? '0' : win.getComputedStyle(next.panel).opacity;
52
+ release(); selected = next;
53
+ if (items.some(i => i !== next && i.panel.contains(host!.ownerDocument.activeElement))) next.tab.focus({ preventScroll: true });
54
+ semantics();
55
+ for (const item of items) item.panel.hidden = item !== next && item !== old;
56
+ const restoreHost = preserveStyles(host!, ['position', 'height', 'overflow', 'box-sizing']);
57
+ const restoreOld = preserveStyles(old.panel, ['position', 'inset', 'width', 'box-sizing']);
58
+ let cleaned = false;
59
+ const finish = () => {
60
+ if (cleaned) return; cleaned = true;
61
+ motion.cancel(host!); motion.cancel(old.panel); motion.cancel(next.panel);
62
+ restoreOld(); restoreHost(); for (const item of items) item.panel.hidden = item !== selected;
63
+ };
64
+ release = finish;
65
+ try {
66
+ host!.style.position = 'relative'; host!.style.overflow = 'hidden'; host!.style.boxSizing = 'border-box';
67
+ old.panel.style.position = 'absolute'; old.panel.style.inset = '0 auto auto 0'; old.panel.style.width = '100%'; old.panel.style.boxSizing = 'border-box';
68
+ const targetHeight = host!.getBoundingClientRect().height;
69
+ host!.style.height = targetHeight + 'px';
70
+ const tasks = [
71
+ motion.animate(old.panel, [{ opacity: from }, { opacity: 0 }], { duration: 450, phase: 'exit', easing: 'ease-in-out' }, 'fade'),
72
+ motion.animate(next.panel, [{ opacity: incoming }, { opacity: 1 }], { duration: 450, easing: 'ease-in-out' }, 'fade'),
73
+ motion.animate(host!, [{ height: height + 'px' }, { height: targetHeight + 'px' }], { duration: 450, easing: 'ease-in-out' }),
74
+ ];
75
+ options.onChange?.(key);
76
+ await Promise.all(tasks);
77
+ } finally { if (revision === own) { finish(); release = () => {}; } }
78
+ return !dead && revision === own ? 'finished' : 'cancelled';
79
+ }
80
+ const click = (event: MouseEvent) => {
81
+ const item = items.find(i => i.tab.contains(event.target as Node));
82
+ if (item && enabled(item)) { item.tab.focus({ preventScroll: true }); void select(item.key); }
83
+ };
84
+ const keyboard = (event: KeyboardEvent) => {
85
+ const current = items.find(i => i.tab === event.target); if (!current) return;
86
+ const available = items.filter(enabled), index = available.indexOf(current);
87
+ const vertical = options.orientation === 'vertical', rtl = win.getComputedStyle(tablist).direction === 'rtl';
88
+ const back = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft', forward = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight';
89
+ let next: MotionTab | undefined;
90
+ if (event.key === back) next = available[(index - 1 + available.length) % available.length];
91
+ if (event.key === forward) next = available[(index + 1) % available.length];
92
+ if (event.key === 'Home') next = available[0];
93
+ if (event.key === 'End') next = available.at(-1);
94
+ if (next) {
95
+ event.preventDefault(); for (const item of items) item.tab.tabIndex = item === next ? 0 : -1;
96
+ next.tab.focus(); if (options.activation !== 'manual') void select(next.key);
97
+ }
98
+ };
99
+ tablist.addEventListener('click', click); tablist.addEventListener('keydown', keyboard);
100
+ return { get activeKey() { return selected!.key; }, select, destroy() {
101
+ if (dead) return; dead = true; ++revision; release(); indicator.destroy();
102
+ tablist.removeEventListener('click', click); tablist.removeEventListener('keydown', keyboard);
103
+ for (const restore of restores.reverse()) restore();
104
+ } };
105
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared opt-in defaults in milliseconds. Explicit zero remains instant. */
2
+ export const motionTiming = Object.freeze({ fast: 280, normal: 450, slow: 750, layout: 650, highlight: 1400, reduced: 200, maxDuration: 2000, maxDelay: 500 });