@yewdiel21/motion-kit 0.0.0-stage → 0.9.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 (391) hide show
  1. package/NOTICE +38 -0
  2. package/README.md +80 -2
  3. package/bin/motion-kit.mjs +242 -0
  4. package/css.d.ts +2 -0
  5. package/dist/auto/index.d.ts +63 -0
  6. package/dist/auto/index.js +207 -0
  7. package/dist/auto/index.js.map +1 -0
  8. package/dist/chunk-2BQGFOA7.js +177 -0
  9. package/dist/chunk-2BQGFOA7.js.map +1 -0
  10. package/dist/chunk-34XYOXHG.js +2374 -0
  11. package/dist/chunk-34XYOXHG.js.map +1 -0
  12. package/dist/chunk-6OJAP6ZL.js +832 -0
  13. package/dist/chunk-6OJAP6ZL.js.map +1 -0
  14. package/dist/chunk-ABNRDDO7.js +28 -0
  15. package/dist/chunk-ABNRDDO7.js.map +1 -0
  16. package/dist/chunk-FS2HJVVY.js +3320 -0
  17. package/dist/chunk-FS2HJVVY.js.map +1 -0
  18. package/dist/chunk-J27KH2A2.js +157 -0
  19. package/dist/chunk-J27KH2A2.js.map +1 -0
  20. package/dist/chunk-KLY5BRRI.js +18 -0
  21. package/dist/chunk-KLY5BRRI.js.map +1 -0
  22. package/dist/chunk-Q4LVFNLN.js +1617 -0
  23. package/dist/chunk-Q4LVFNLN.js.map +1 -0
  24. package/dist/chunk-RFDIZYFX.js +304 -0
  25. package/dist/chunk-RFDIZYFX.js.map +1 -0
  26. package/dist/chunk-TGV27ZWM.js +17 -0
  27. package/dist/chunk-TGV27ZWM.js.map +1 -0
  28. package/dist/confetti-BSX1CLLV.d.ts +41 -0
  29. package/dist/css/components/chat-background.css +87 -0
  30. package/dist/css/components/chat.css +237 -0
  31. package/dist/css/components/cursor.css +178 -0
  32. package/dist/css/components/dialog.css +259 -0
  33. package/dist/css/components/island.css +301 -0
  34. package/dist/css/components/menu.css +105 -0
  35. package/dist/css/components/nav.css +1054 -0
  36. package/dist/css/components/steps.css +50 -0
  37. package/dist/css/components/theme-picker.css +115 -0
  38. package/dist/css/components/toast.css +115 -0
  39. package/dist/css/controls.css +950 -0
  40. package/dist/css/effects.css +587 -0
  41. package/dist/css/fonts.css +3 -0
  42. package/dist/css/themes/black-green.css +126 -0
  43. package/dist/css/themes/black.css +126 -0
  44. package/dist/css/themes/blue.css +126 -0
  45. package/dist/css/themes/brown.css +126 -0
  46. package/dist/css/themes/cold-blue.css +126 -0
  47. package/dist/css/themes/funny.css +126 -0
  48. package/dist/css/themes/gray.css +126 -0
  49. package/dist/css/themes/green.css +126 -0
  50. package/dist/css/themes/modern-1.css +126 -0
  51. package/dist/css/themes/neogreen.css +126 -0
  52. package/dist/css/themes/new.css +126 -0
  53. package/dist/css/themes/new2.css +126 -0
  54. package/dist/css/themes/oled.css +126 -0
  55. package/dist/css/themes/orange.css +126 -0
  56. package/dist/css/themes/pastel-pink.css +126 -0
  57. package/dist/css/themes/pink.css +126 -0
  58. package/dist/css/themes/purple.css +126 -0
  59. package/dist/css/themes/red.css +126 -0
  60. package/dist/css/themes/super-blue.css +126 -0
  61. package/dist/css/themes.css +2431 -0
  62. package/dist/css/tokens.css +452 -0
  63. package/dist/elements/index.d.ts +183 -0
  64. package/dist/elements/index.js +472 -0
  65. package/dist/elements/index.js.map +1 -0
  66. package/dist/engine-CUr9QijM.d.ts +46 -0
  67. package/dist/gl/index.d.ts +61 -0
  68. package/dist/gl/index.js +18 -0
  69. package/dist/gl/index.js.map +1 -0
  70. package/dist/index.css +5296 -0
  71. package/dist/index.d.ts +440 -0
  72. package/dist/index.js +390 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/react/index.d.ts +476 -0
  75. package/dist/react/index.js +969 -0
  76. package/dist/react/index.js.map +1 -0
  77. package/dist/reduced-BM4Ls0g5.d.ts +566 -0
  78. package/dist/reveal-WFUWDrei.d.ts +416 -0
  79. package/dist/store-Bqy_obnD.d.ts +195 -0
  80. package/dist/theme/generate.d.ts +18 -0
  81. package/dist/theme/generate.js +64 -0
  82. package/dist/theme/generate.js.map +1 -0
  83. package/dist/theme/index.d.ts +17 -0
  84. package/dist/theme/index.js +32 -0
  85. package/dist/theme/index.js.map +1 -0
  86. package/dist/theme-BFMoCzi6.d.ts +158 -0
  87. package/dist/types-BkS_raL8.d.ts +7 -0
  88. package/package.json +114 -3
  89. package/registry.json +469 -0
  90. package/skills/motion-kit/SKILL.md +189 -0
  91. package/skills/motion-kit/reference/components.md +86 -0
  92. package/skills/motion-kit/reference/design.md +92 -0
  93. package/skills/motion-kit/reference/recipes.md +172 -0
  94. package/src/auto/auto-init.ts +151 -0
  95. package/src/auto/index.ts +3 -0
  96. package/src/auto/parse-options.ts +68 -0
  97. package/src/components/chat/README.md +90 -0
  98. package/src/components/chat/chat.css +236 -0
  99. package/src/components/chat/element.ts +98 -0
  100. package/src/components/chat/engine.ts +295 -0
  101. package/src/components/chat/react.tsx +196 -0
  102. package/src/components/chat-background/README.md +50 -0
  103. package/src/components/chat-background/audio.ts +56 -0
  104. package/src/components/chat-background/chat-background.css +86 -0
  105. package/src/components/chat-background/element.ts +31 -0
  106. package/src/components/chat-background/engine.ts +108 -0
  107. package/src/components/chat-background/react.tsx +36 -0
  108. package/src/components/cursor/README.md +53 -0
  109. package/src/components/cursor/cursor.css +177 -0
  110. package/src/components/cursor/element.ts +23 -0
  111. package/src/components/cursor/engine.ts +254 -0
  112. package/src/components/cursor/react.tsx +26 -0
  113. package/src/components/dialog/README.md +106 -0
  114. package/src/components/dialog/dialog.css +258 -0
  115. package/src/components/dialog/element.ts +118 -0
  116. package/src/components/dialog/engine.ts +432 -0
  117. package/src/components/dialog/react.tsx +200 -0
  118. package/src/components/icon.tsx +10 -0
  119. package/src/components/icons.ts +24 -0
  120. package/src/components/island/README.md +91 -0
  121. package/src/components/island/element.ts +45 -0
  122. package/src/components/island/engine.ts +200 -0
  123. package/src/components/island/island.css +300 -0
  124. package/src/components/island/react.tsx +115 -0
  125. package/src/components/menu/README.md +67 -0
  126. package/src/components/menu/element.ts +55 -0
  127. package/src/components/menu/engine.ts +142 -0
  128. package/src/components/menu/menu.css +104 -0
  129. package/src/components/menu/react.tsx +82 -0
  130. package/src/components/nav/README.md +85 -0
  131. package/src/components/nav/element.ts +39 -0
  132. package/src/components/nav/engine.ts +164 -0
  133. package/src/components/nav/nav.css +1053 -0
  134. package/src/components/nav/react.tsx +90 -0
  135. package/src/components/steps/README.md +70 -0
  136. package/src/components/steps/element.ts +43 -0
  137. package/src/components/steps/engine.ts +185 -0
  138. package/src/components/steps/react.tsx +91 -0
  139. package/src/components/steps/steps.css +49 -0
  140. package/src/components/theme-picker/README.md +57 -0
  141. package/src/components/theme-picker/element.ts +20 -0
  142. package/src/components/theme-picker/engine.ts +91 -0
  143. package/src/components/theme-picker/react.tsx +31 -0
  144. package/src/components/theme-picker/theme-picker.css +114 -0
  145. package/src/components/toast/README.md +60 -0
  146. package/src/components/toast/element.ts +25 -0
  147. package/src/components/toast/engine.ts +483 -0
  148. package/src/components/toast/react.tsx +22 -0
  149. package/src/components/toast/store.ts +122 -0
  150. package/src/components/toast/toast.css +114 -0
  151. package/src/core/channels.ts +165 -0
  152. package/src/core/curves.ts +35 -0
  153. package/src/core/frame.ts +12 -0
  154. package/src/core/gsap.ts +65 -0
  155. package/src/core/loop.ts +44 -0
  156. package/src/core/math.ts +9 -0
  157. package/src/core/physics-spring.ts +59 -0
  158. package/src/core/pointer.ts +63 -0
  159. package/src/core/reduced.ts +21 -0
  160. package/src/core/scope.ts +70 -0
  161. package/src/core/scroll-track.ts +106 -0
  162. package/src/core/split.ts +68 -0
  163. package/src/core/spring.ts +22 -0
  164. package/src/core/styles.ts +31 -0
  165. package/src/core/timing.ts +204 -0
  166. package/src/core/types.ts +20 -0
  167. package/src/core/visible.ts +19 -0
  168. package/src/css/base.css +142 -0
  169. package/src/css/controls/README.md +122 -0
  170. package/src/css/controls/burger.css +67 -0
  171. package/src/css/controls/button.css +226 -0
  172. package/src/css/controls/checkbox.css +53 -0
  173. package/src/css/controls/chip.css +85 -0
  174. package/src/css/controls/field.css +124 -0
  175. package/src/css/controls/glass.css +220 -0
  176. package/src/css/controls/icon.css +26 -0
  177. package/src/css/controls/segmented.css +60 -0
  178. package/src/css/controls/switch.css +72 -0
  179. package/src/css/default-theme.css +123 -0
  180. package/src/css/effects/README.md +134 -0
  181. package/src/css/effects/anim.css +224 -0
  182. package/src/css/effects/fade.css +15 -0
  183. package/src/css/effects/float.css +64 -0
  184. package/src/css/effects/frost.css +62 -0
  185. package/src/css/effects/grain.css +58 -0
  186. package/src/css/effects/loading.css +60 -0
  187. package/src/css/effects/success.css +22 -0
  188. package/src/css/effects/text.css +67 -0
  189. package/src/css/fonts.css +3 -0
  190. package/src/css/states.css +51 -0
  191. package/src/css/themes/black-green.css +126 -0
  192. package/src/css/themes/black.css +126 -0
  193. package/src/css/themes/blue.css +126 -0
  194. package/src/css/themes/brown.css +126 -0
  195. package/src/css/themes/cold-blue.css +126 -0
  196. package/src/css/themes/funny.css +126 -0
  197. package/src/css/themes/gray.css +126 -0
  198. package/src/css/themes/green.css +126 -0
  199. package/src/css/themes/modern-1.css +126 -0
  200. package/src/css/themes/neogreen.css +126 -0
  201. package/src/css/themes/new.css +126 -0
  202. package/src/css/themes/new2.css +126 -0
  203. package/src/css/themes/oled.css +126 -0
  204. package/src/css/themes/orange.css +126 -0
  205. package/src/css/themes/pastel-pink.css +126 -0
  206. package/src/css/themes/pink.css +126 -0
  207. package/src/css/themes/purple.css +126 -0
  208. package/src/css/themes/red.css +126 -0
  209. package/src/css/themes/super-blue.css +126 -0
  210. package/src/css/tokens.css +182 -0
  211. package/src/elements/base.ts +76 -0
  212. package/src/elements/index.ts +48 -0
  213. package/src/gl/index.ts +15 -0
  214. package/src/gl/liquid-blend/README.md +23 -0
  215. package/src/gl/liquid-blend/create-liquid-blend.ts +7 -0
  216. package/src/gl/liquid-blend/shader.ts +9 -0
  217. package/src/gl/matcap/README.md +23 -0
  218. package/src/gl/matcap/create-matcap.ts +7 -0
  219. package/src/gl/matcap/shader.ts +16 -0
  220. package/src/gl/particle-hero/README.md +41 -0
  221. package/src/gl/particle-hero/create-particle-hero.ts +544 -0
  222. package/src/gl/particle-hero/particle-hero.css +19 -0
  223. package/src/gl/particle-hero/shaders.ts +84 -0
  224. package/src/gl/shader-blur/README.md +23 -0
  225. package/src/gl/shader-blur/create-shader-blur.ts +7 -0
  226. package/src/gl/shader-blur/shader.ts +11 -0
  227. package/src/gl/shader-grain/README.md +23 -0
  228. package/src/gl/shader-grain/create-shader-grain.ts +7 -0
  229. package/src/gl/shader-grain/shader.ts +6 -0
  230. package/src/gl/shared/create-shader-effect.ts +186 -0
  231. package/src/gl/shared/shader.css +3 -0
  232. package/src/gl/shared/shaders.ts +30 -0
  233. package/src/index.ts +177 -0
  234. package/src/motion/anchor.ts +45 -0
  235. package/src/motion/chat.ts +97 -0
  236. package/src/motion/confetti.ts +130 -0
  237. package/src/motion/count.ts +95 -0
  238. package/src/motion/details.ts +70 -0
  239. package/src/motion/drag-dismiss.ts +231 -0
  240. package/src/motion/effects.ts +81 -0
  241. package/src/motion/entrances.ts +27 -0
  242. package/src/motion/floating.ts +389 -0
  243. package/src/motion/flow.ts +115 -0
  244. package/src/motion/indicator.ts +44 -0
  245. package/src/motion/layout.ts +39 -0
  246. package/src/motion/liquid.ts +227 -0
  247. package/src/motion/morph.ts +540 -0
  248. package/src/motion/reveal.ts +112 -0
  249. package/src/motion/sheet.ts +65 -0
  250. package/src/motion/surface.ts +108 -0
  251. package/src/react/animated-number.tsx +30 -0
  252. package/src/react/flip-group.tsx +44 -0
  253. package/src/react/frost.tsx +33 -0
  254. package/src/react/index.ts +80 -0
  255. package/src/react/slot-text.tsx +44 -0
  256. package/src/react/theme-provider.tsx +42 -0
  257. package/src/react/use-card-dock.ts +9 -0
  258. package/src/react/use-cascade.ts +9 -0
  259. package/src/react/use-contrast.ts +9 -0
  260. package/src/react/use-counter.ts +8 -0
  261. package/src/react/use-curved-carousel.ts +10 -0
  262. package/src/react/use-depth-grid.ts +9 -0
  263. package/src/react/use-details.ts +7 -0
  264. package/src/react/use-dock-pill.ts +10 -0
  265. package/src/react/use-drag-ruler.ts +10 -0
  266. package/src/react/use-fill-text.ts +9 -0
  267. package/src/react/use-fit-text.ts +8 -0
  268. package/src/react/use-flow.ts +8 -0
  269. package/src/react/use-frame-zoom.ts +9 -0
  270. package/src/react/use-infinite-reel.ts +8 -0
  271. package/src/react/use-letters.ts +9 -0
  272. package/src/react/use-liquid-blend.ts +8 -0
  273. package/src/react/use-marquee.ts +9 -0
  274. package/src/react/use-mask-text.ts +9 -0
  275. package/src/react/use-matcap.ts +8 -0
  276. package/src/react/use-parallax.ts +9 -0
  277. package/src/react/use-particle-hero.ts +9 -0
  278. package/src/react/use-pile.ts +8 -0
  279. package/src/react/use-pop.ts +8 -0
  280. package/src/react/use-radial-menu.ts +10 -0
  281. package/src/react/use-recipe.ts +38 -0
  282. package/src/react/use-reflow.ts +9 -0
  283. package/src/react/use-repel-bento.ts +8 -0
  284. package/src/react/use-reveal.ts +8 -0
  285. package/src/react/use-rotator.ts +10 -0
  286. package/src/react/use-shader-blur.ts +8 -0
  287. package/src/react/use-shader-grain.ts +8 -0
  288. package/src/react/use-slot-text.ts +17 -0
  289. package/src/react/use-snap-strip.ts +9 -0
  290. package/src/react/use-sortable.ts +14 -0
  291. package/src/react/use-spring-cards.ts +8 -0
  292. package/src/react/use-stretch-footer.ts +20 -0
  293. package/src/react/use-tabs.ts +10 -0
  294. package/src/react/use-text-roll.ts +9 -0
  295. package/src/react/use-ticket-menu.ts +15 -0
  296. package/src/react/use-words.ts +8 -0
  297. package/src/recipes/card-dock/README.md +36 -0
  298. package/src/recipes/card-dock/card-dock.css +178 -0
  299. package/src/recipes/card-dock/create-card-dock.ts +97 -0
  300. package/src/recipes/cascade/README.md +33 -0
  301. package/src/recipes/cascade/create-cascade.ts +111 -0
  302. package/src/recipes/contrast/README.md +29 -0
  303. package/src/recipes/contrast/create-contrast.ts +68 -0
  304. package/src/recipes/counter/README.md +42 -0
  305. package/src/recipes/counter/create-counter.ts +84 -0
  306. package/src/recipes/curved-carousel/README.md +50 -0
  307. package/src/recipes/curved-carousel/carousel-math.ts +20 -0
  308. package/src/recipes/curved-carousel/create-curved-carousel.ts +257 -0
  309. package/src/recipes/curved-carousel/curved-carousel.css +50 -0
  310. package/src/recipes/depth-grid/README.md +30 -0
  311. package/src/recipes/depth-grid/create-depth-grid.ts +120 -0
  312. package/src/recipes/details/README.md +31 -0
  313. package/src/recipes/details/create-details.ts +27 -0
  314. package/src/recipes/dock-pill/README.md +23 -0
  315. package/src/recipes/dock-pill/create-dock-pill.ts +68 -0
  316. package/src/recipes/dock-pill/dock-pill.css +5 -0
  317. package/src/recipes/drag-ruler/README.md +21 -0
  318. package/src/recipes/drag-ruler/create-drag-ruler.ts +95 -0
  319. package/src/recipes/drag-ruler/drag-ruler.css +16 -0
  320. package/src/recipes/fill-text/README.md +27 -0
  321. package/src/recipes/fill-text/create-fill-text.ts +93 -0
  322. package/src/recipes/fit-text/README.md +37 -0
  323. package/src/recipes/fit-text/create-fit-text.ts +55 -0
  324. package/src/recipes/fit-text/fit-text.css +6 -0
  325. package/src/recipes/flow/README.md +46 -0
  326. package/src/recipes/flow/create-flow.ts +55 -0
  327. package/src/recipes/frame-zoom/README.md +29 -0
  328. package/src/recipes/frame-zoom/create-frame-zoom.ts +91 -0
  329. package/src/recipes/infinite-reel/README.md +23 -0
  330. package/src/recipes/infinite-reel/create-infinite-reel.ts +140 -0
  331. package/src/recipes/infinite-reel/infinite-reel.css +8 -0
  332. package/src/recipes/letters/README.md +30 -0
  333. package/src/recipes/letters/create-letters.ts +113 -0
  334. package/src/recipes/marquee/README.md +32 -0
  335. package/src/recipes/marquee/create-marquee.ts +74 -0
  336. package/src/recipes/marquee/marquee.css +41 -0
  337. package/src/recipes/mask-text/README.md +39 -0
  338. package/src/recipes/mask-text/create-mask-text.ts +134 -0
  339. package/src/recipes/parallax/README.md +31 -0
  340. package/src/recipes/parallax/create-parallax.ts +74 -0
  341. package/src/recipes/pile/README.md +48 -0
  342. package/src/recipes/pile/create-pile.ts +50 -0
  343. package/src/recipes/pile/pile-math.ts +27 -0
  344. package/src/recipes/pile/pile.css +63 -0
  345. package/src/recipes/pill/README.md +30 -0
  346. package/src/recipes/pill/pill.css +48 -0
  347. package/src/recipes/pop/README.md +36 -0
  348. package/src/recipes/pop/create-pop.ts +52 -0
  349. package/src/recipes/radial-menu/README.md +44 -0
  350. package/src/recipes/radial-menu/create-radial-menu.ts +96 -0
  351. package/src/recipes/radial-menu/radial-menu.css +45 -0
  352. package/src/recipes/reflow/README.md +47 -0
  353. package/src/recipes/reflow/create-reflow.ts +49 -0
  354. package/src/recipes/repel-bento/README.md +21 -0
  355. package/src/recipes/repel-bento/create-repel-bento.ts +65 -0
  356. package/src/recipes/repel-bento/repel-bento.css +3 -0
  357. package/src/recipes/reveal/README.md +36 -0
  358. package/src/recipes/reveal/create-reveal.ts +48 -0
  359. package/src/recipes/rotator/README.md +21 -0
  360. package/src/recipes/rotator/create-rotator.ts +66 -0
  361. package/src/recipes/rotator/rotator.css +3 -0
  362. package/src/recipes/slot-text/README.md +44 -0
  363. package/src/recipes/slot-text/create-slot-text.ts +244 -0
  364. package/src/recipes/slot-text/slot-text.css +42 -0
  365. package/src/recipes/snap-strip/README.md +31 -0
  366. package/src/recipes/snap-strip/create-snap-strip.ts +133 -0
  367. package/src/recipes/snap-strip/snap-strip.css +23 -0
  368. package/src/recipes/sortable/README.md +88 -0
  369. package/src/recipes/sortable/create-sortable.ts +863 -0
  370. package/src/recipes/sortable/sortable.css +40 -0
  371. package/src/recipes/spring-cards/README.md +48 -0
  372. package/src/recipes/spring-cards/create-spring-cards.ts +140 -0
  373. package/src/recipes/spring-cards/spring-cards.css +23 -0
  374. package/src/recipes/stretch-footer/README.md +44 -0
  375. package/src/recipes/stretch-footer/create-stretch-footer.ts +69 -0
  376. package/src/recipes/stretch-footer/stretch-footer.css +28 -0
  377. package/src/recipes/tabs/README.md +39 -0
  378. package/src/recipes/tabs/create-tabs.ts +91 -0
  379. package/src/recipes/tabs/tabs.css +19 -0
  380. package/src/recipes/text-roll/README.md +35 -0
  381. package/src/recipes/text-roll/create-text-roll.ts +137 -0
  382. package/src/recipes/text-roll/text-roll.css +21 -0
  383. package/src/recipes/ticket-menu/README.md +24 -0
  384. package/src/recipes/ticket-menu/create-ticket-menu.ts +6 -0
  385. package/src/recipes/words/README.md +43 -0
  386. package/src/recipes/words/create-words.ts +114 -0
  387. package/src/theme/css-format.ts +32 -0
  388. package/src/theme/generate.ts +72 -0
  389. package/src/theme/index.ts +15 -0
  390. package/src/theme/presets.ts +79 -0
  391. package/src/theme/theme.ts +119 -0
@@ -0,0 +1,863 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { clearStyles } from '../../core/styles';
5
+ import { TIMING } from '../../core/timing';
6
+ import { inert, warnOnce, type Instance } from '../../core/types';
7
+ import { captureLayout, reflowLayout } from '../../motion/layout';
8
+
9
+ /*
10
+ * Stepbro's drag reorder (Tasks useDragReorder, "transform" mode). Past 6 px
11
+ * the item lifts (0.18 s) into a floating copy that follows the pointer on a
12
+ * spring (1100/38, heavier when bigger) and tilts with its speed (spring
13
+ * 170/13, up to 14°); the item it left is a hidden slot. The others never
14
+ * change place in the DOM while dragging: they slide (0.62 s, spring .52) to
15
+ * open a gap where the item would land. On release the copy lands in its new
16
+ * place (0.42 s, spring .62) and the list is reordered: by the engine
17
+ * (`mutate`, vanilla) or by the app (`onReorder`, then the engine waits for the
18
+ * DOM to change). Edges auto-scroll (56 px, up to 16 px a frame); Escape
19
+ * cancels; on touch only [data-mk-sort-handle] drags, so the list still
20
+ * scrolls. Keyboard (added): Space/Enter lifts, arrows move, Space/Enter drops,
21
+ * Escape cancels, each step announced.
22
+ * Parts: [data-mk-sortable-group] (optional; data-mk-sortable-locked refuses
23
+ * drops), [data-mk-item] with data-mk-id, [data-mk-sort-handle].
24
+ */
25
+
26
+ export interface SortableLabels {
27
+ lifted?: (position: number, total: number) => string;
28
+ moved?: (position: number, total: number, group: string) => string;
29
+ dropped?: (position: number, total: number) => string;
30
+ cancelled?: string;
31
+ }
32
+
33
+ export interface SortableOptions {
34
+ /** Reorder the DOM on drop (default). Off when a framework renders the list: answer onReorder. */
35
+ mutate?: boolean;
36
+ /** Drag only from [data-mk-sort-handle] (always so on touch screens). */
37
+ handle?: boolean;
38
+ autoScroll?: boolean;
39
+ /** The floating copy's spring (Stepbro: 1100 / 38). */
40
+ follow?: { stiffness?: number; damping?: number };
41
+ labels?: SortableLabels;
42
+ onReorder?: (detail: ReorderDetail) => void;
43
+ }
44
+
45
+ export interface ReorderDetail {
46
+ id: string;
47
+ item: HTMLElement;
48
+ fromGroup: string;
49
+ toGroup: string;
50
+ fromIndex: number;
51
+ toIndex: number;
52
+ groupChanged: boolean;
53
+ /** Neighbours at the destination (Stepbro sends one to its API as the anchor). */
54
+ afterId: string | null;
55
+ beforeId: string | null;
56
+ }
57
+
58
+ export interface SortableInstance extends Instance {
59
+ /** Stop a drag in progress: the item flies back. */
60
+ cancel(): void;
61
+ readonly dragging: boolean;
62
+ }
63
+
64
+ const GROUP = '[data-mk-sortable-group]';
65
+ const ITEM = '[data-mk-item]';
66
+ const HANDLE = '[data-mk-sort-handle]';
67
+ const INTERACTIVE = [
68
+ 'a[href]',
69
+ 'button',
70
+ 'input',
71
+ 'select',
72
+ 'textarea',
73
+ '[contenteditable="true"]',
74
+ '[role="button"]',
75
+ '[role="slider"]',
76
+ '[data-clickable="true"]',
77
+ ].join(',');
78
+ const THRESHOLD = 6;
79
+ const TILT = { max: 14, vel: 2e-3, accel: 45e-5, stiffness: 170, damping: 13, hang: 0.4, refArea: 24000 };
80
+ const EDGE = 56;
81
+ const MAX_SCROLL = 16;
82
+ const TRANSFORMS = ['transform', 'translate', 'rotate', 'scale'];
83
+
84
+ const LABELS: Required<SortableLabels> = {
85
+ lifted: (position, total) =>
86
+ `Elemento levantado, posición ${position} de ${total}. Flechas para mover, Espacio para soltar, Escape para cancelar.`,
87
+ moved: (position, total, group) => (group ? `${group}, posición ${position} de ${total}.` : `Posición ${position} de ${total}.`),
88
+ dropped: (position, total) => `Elemento soltado en la posición ${position} de ${total}.`,
89
+ cancelled: 'Movimiento cancelado.',
90
+ };
91
+
92
+ interface Measured {
93
+ el: HTMLElement;
94
+ top: number;
95
+ height: number;
96
+ }
97
+
98
+ interface MeasuredGroup {
99
+ el: HTMLElement;
100
+ rect: DOMRect;
101
+ locked: boolean;
102
+ items: Measured[];
103
+ }
104
+
105
+ interface Layer {
106
+ el: HTMLElement;
107
+ ox: number;
108
+ oy: number;
109
+ }
110
+
111
+ interface Press {
112
+ item: HTMLElement;
113
+ group: HTMLElement;
114
+ index: number;
115
+ startX: number;
116
+ startY: number;
117
+ grabX: number;
118
+ grabY: number;
119
+ width: number;
120
+ height: number;
121
+ }
122
+
123
+ export function createSortable(root: HTMLElement | null, options: SortableOptions = {}): SortableInstance {
124
+ if (!root) {
125
+ warnOnce('sortable: missing root');
126
+ return inert({ cancel() {}, dragging: false });
127
+ }
128
+ registerMotion();
129
+ const scope = createScope();
130
+ const { mutate = true, autoScroll = true } = options;
131
+ const follow = { stiffness: 1100, damping: 38, ...options.follow };
132
+ const labels = { ...LABELS, ...options.labels };
133
+
134
+ // ── Structure ─────────────────────────────────────────────────────────────
135
+ const groups = () => {
136
+ const list = [...root.querySelectorAll<HTMLElement>(GROUP)];
137
+ if (root.matches(GROUP)) list.unshift(root);
138
+ else if (!list.length) list.push(root);
139
+ return list;
140
+ };
141
+ const ownerOf = (item: HTMLElement) => {
142
+ const group = item.parentElement?.closest<HTMLElement>(GROUP);
143
+ return group && root.contains(group) ? group : root;
144
+ };
145
+ const itemsOf = (group: HTMLElement) => [...group.querySelectorAll<HTMLElement>(ITEM)].filter((item) => ownerOf(item) === group);
146
+ const keyOf = (group: HTMLElement) => group.dataset.mkSortableGroup ?? '';
147
+ const isLocked = (group: HTMLElement) => group.hasAttribute('data-mk-sortable-locked') && group.dataset.mkSortableLocked !== 'false';
148
+ const idOf = (item: HTMLElement) => {
149
+ if (item.dataset.mkId !== undefined) return item.dataset.mkId;
150
+ warnOnce('sortable: give each [data-mk-item] a data-mk-id');
151
+ return String(itemsOf(ownerOf(item)).indexOf(item));
152
+ };
153
+ const findItem = (id: string) => [...root.querySelectorAll<HTMLElement>(ITEM)].find((item) => item.dataset.mkId === id) ?? null;
154
+ // Reading a transform makes GSAP write translate/rotate/scale: none inline; untouch() takes them off.
155
+ const touched = new Set<HTMLElement>();
156
+ const transformY = (el: HTMLElement) => {
157
+ touched.add(el);
158
+ return Number(gsap.getProperty(el, 'y')) || 0;
159
+ };
160
+ const transformX = (el: HTMLElement) => {
161
+ touched.add(el);
162
+ return Number(gsap.getProperty(el, 'x')) || 0;
163
+ };
164
+ const untouch = () => {
165
+ const idle = [...touched].filter((el) => !gsap.isTweening(el));
166
+ for (const el of idle) touched.delete(el);
167
+ clearStyles(idle, TRANSFORMS);
168
+ };
169
+
170
+ const makeDetail = (item: HTMLElement, from: { group: HTMLElement; index: number }, to: { group: HTMLElement; index: number }) => {
171
+ const others = itemsOf(to.group).filter((el) => el !== item);
172
+ const after = others[to.index - 1];
173
+ const before = others[to.index];
174
+ const detail: ReorderDetail = {
175
+ id: idOf(item),
176
+ item,
177
+ fromGroup: keyOf(from.group),
178
+ toGroup: keyOf(to.group),
179
+ fromIndex: from.index,
180
+ toIndex: to.index,
181
+ groupChanged: from.group !== to.group,
182
+ afterId: after ? idOf(after) : null,
183
+ beforeId: after ? null : before ? idOf(before) : null,
184
+ };
185
+ return detail;
186
+ };
187
+
188
+ const moveDom = (item: HTMLElement, group: HTMLElement, index: number) => {
189
+ const focused = item.contains(document.activeElement) ? (document.activeElement as HTMLElement) : null;
190
+ const others = itemsOf(group).filter((el) => el !== item);
191
+ const before = others[index];
192
+ if (before) before.before(item);
193
+ else if (others.length) others[others.length - 1]!.after(item);
194
+ else group.append(item);
195
+ focused?.focus({ preventScroll: true });
196
+ };
197
+
198
+ /** Resolves when the app has changed the list (or false after `ms` without a change). */
199
+ const waiting = new Set<() => void>();
200
+ scope.add(() => {
201
+ for (const stop of [...waiting]) stop();
202
+ });
203
+ const domChange = (ms = 400) =>
204
+ new Promise<boolean>((resolve) => {
205
+ let done = false;
206
+ const finish = (changed: boolean) => {
207
+ if (done) return;
208
+ done = true;
209
+ waiting.delete(stop);
210
+ observer.disconnect();
211
+ clearTimeout(timer);
212
+ resolve(changed);
213
+ };
214
+ const stop = () => finish(false);
215
+ waiting.add(stop);
216
+ const observer = new MutationObserver((records) => {
217
+ if (
218
+ records.some((record) =>
219
+ [...record.addedNodes, ...record.removedNodes].some((node) => node instanceof HTMLElement && node.matches(ITEM)),
220
+ )
221
+ )
222
+ finish(true);
223
+ });
224
+ observer.observe(root, { childList: true, subtree: true });
225
+ const timer = setTimeout(stop, ms);
226
+ });
227
+
228
+ // ── Live region (keyboard) ────────────────────────────────────────────────
229
+ let live: HTMLElement | null = null;
230
+ const announce = (text: string) => {
231
+ if (!live) {
232
+ live = document.createElement('div');
233
+ live.setAttribute('aria-live', 'assertive');
234
+ live.setAttribute('role', 'status');
235
+ Object.assign(live.style, {
236
+ position: 'absolute',
237
+ width: '1px',
238
+ height: '1px',
239
+ overflow: 'hidden',
240
+ clipPath: 'inset(50%)',
241
+ whiteSpace: 'nowrap',
242
+ });
243
+ document.body.append(live);
244
+ const created = live;
245
+ scope.add(() => created.remove());
246
+ }
247
+ live.textContent = text;
248
+ };
249
+
250
+ // ── Pointer drag ──────────────────────────────────────────────────────────
251
+ const pointer = { x: 0, y: 0 };
252
+ let press: Press | null = null;
253
+ let dragging = false;
254
+ let floating = { x: 0, y: 0, width: 0, tilt: 0, lift: 0, fade: 0 };
255
+ let layers: Layer[] = [];
256
+ let lifting: gsap.core.Tween | null = null;
257
+ let landing: gsap.core.Timeline | null = null;
258
+ let layout: { groups: MeasuredGroup[]; source: MeasuredGroup; dragTop: number; shift: number } | null = null;
259
+ const shifted = new Map<HTMLElement, number>();
260
+ let placement: { group: HTMLElement; index: number } | null = null;
261
+ let physics: {
262
+ vx: number;
263
+ vy: number;
264
+ tiltV: number;
265
+ fvx: number;
266
+ fvy: number;
267
+ armX: number;
268
+ armY: number;
269
+ mass: number;
270
+ x: number;
271
+ y: number;
272
+ t: number;
273
+ } | null = null;
274
+ let scrollers: Array<{ el: HTMLElement | null; y: boolean; x: boolean }> = [];
275
+ let draggedEls = new Set<HTMLElement>();
276
+
277
+ const render = () => {
278
+ const { x, y, width, tilt, lift, fade } = floating;
279
+ layers.forEach((layer, i) => {
280
+ const style = layer.el.style;
281
+ style.transform = `translate3d(${x - layer.ox}px, ${y - layer.oy}px, 0) rotate(${tilt}deg) scale(${1 + lift * 0.03})`;
282
+ style.width = `${width}px`;
283
+ style.setProperty('--mk-lift', lift.toFixed(3));
284
+ if (layers.length > 1) style.opacity = String(i === 0 ? 1 - fade : fade);
285
+ });
286
+ };
287
+
288
+ /** A copy of `source` floating in `parent` (position fixed), so the parent's CSS still styles it. */
289
+ const makeLayer = (source: HTMLElement, parent: HTMLElement, grabX: number, grabY: number): Layer => {
290
+ const el = source.cloneNode(true) as HTMLElement;
291
+ for (const name of ['data-mk-item', 'data-mk-id', 'data-mk-dragging', 'data-mk-sort-grabbed', 'data-flip-id', 'id', 'tabindex'])
292
+ el.removeAttribute(name);
293
+ for (const child of el.querySelectorAll('[id]')) child.removeAttribute('id');
294
+ el.dataset.mkSortableClone = '';
295
+ el.setAttribute('aria-hidden', 'true');
296
+ el.inert = true;
297
+ Object.assign(el.style, {
298
+ position: 'fixed',
299
+ top: '0',
300
+ left: '0',
301
+ margin: '0',
302
+ boxSizing: 'border-box',
303
+ zIndex: '9999',
304
+ pointerEvents: 'none',
305
+ willChange: 'transform',
306
+ transform: 'none',
307
+ transformOrigin: `${grabX}px ${grabY}px`,
308
+ translate: 'none',
309
+ rotate: 'none',
310
+ scale: 'none',
311
+ visibility: 'visible',
312
+ });
313
+ parent.append(el);
314
+ // A transformed ancestor would anchor `fixed` to itself: measure that offset once.
315
+ const box = el.getBoundingClientRect();
316
+ return { el, ox: box.left, oy: box.top };
317
+ };
318
+
319
+ const measure = () => {
320
+ if (!press) return;
321
+ const item = press.item;
322
+ const measured = groups().map((group) => ({
323
+ el: group,
324
+ rect: group.getBoundingClientRect(),
325
+ locked: isLocked(group),
326
+ items: itemsOf(group).map((el) => {
327
+ const box = el.getBoundingClientRect();
328
+ return { el, top: box.top - transformY(el), height: box.height };
329
+ }),
330
+ }));
331
+ const source = measured.find((group) => group.el === press!.group) ?? measured[0]!;
332
+ const index = source.items.findIndex((entry) => entry.el === item);
333
+ const dragged = source.items[index];
334
+ if (!dragged) return;
335
+ const next = source.items[index + 1];
336
+ const previous = source.items[index - 1];
337
+ // The gap an item leaves: its height plus the space to its neighbour (gap or margins).
338
+ const spacing = next
339
+ ? next.top - (dragged.top + dragged.height)
340
+ : previous
341
+ ? dragged.top - (previous.top + previous.height)
342
+ : parseFloat(getComputedStyle(source.el).rowGap) || 0;
343
+ layout = { groups: measured, source, dragTop: dragged.top, shift: dragged.height + Math.max(0, spacing) };
344
+ };
345
+
346
+ const applyShift = (target: MeasuredGroup, index: number) => {
347
+ if (!layout || !press) return;
348
+ const item = press.item;
349
+ const next = new Map<HTMLElement, number>();
350
+ for (const entry of layout.source.items) if (entry.top > layout.dragTop) next.set(entry.el, -layout.shift);
351
+ target.items
352
+ .filter((entry) => entry.el !== item)
353
+ .forEach((entry, i) => {
354
+ if (i >= index) next.set(entry.el, (next.get(entry.el) ?? 0) + layout!.shift);
355
+ });
356
+ for (const el of shifted.keys()) if (!next.has(el)) next.set(el, 0);
357
+ for (const [el, y] of next) {
358
+ if ((shifted.get(el) ?? 0) === y) continue;
359
+ shifted.set(el, y);
360
+ touched.add(el);
361
+ gsap.to(el, { y, duration: reduced() ? 0 : TIMING.sortable.shift, ease: EASE.layoutReflow, overwrite: 'auto' });
362
+ }
363
+ };
364
+
365
+ const unshift = (animated: boolean) => {
366
+ const els = [...shifted.keys()];
367
+ shifted.clear();
368
+ if (!els.length) return;
369
+ if (animated && !reduced()) {
370
+ gsap.to(els, {
371
+ y: 0,
372
+ duration: TIMING.sortable.shift,
373
+ ease: EASE.layoutReflow,
374
+ overwrite: 'auto',
375
+ onComplete: () => {
376
+ clearStyles(els, TRANSFORMS);
377
+ for (const el of els) touched.delete(el);
378
+ },
379
+ });
380
+ } else {
381
+ gsap.killTweensOf(els);
382
+ clearStyles(els, TRANSFORMS);
383
+ }
384
+ };
385
+
386
+ const place = () => {
387
+ if (!layout || !press) return;
388
+ const all = layout.groups;
389
+ const inside = (rect: DOMRect) => pointer.x >= rect.left && pointer.x <= rect.right;
390
+ const target =
391
+ all.length <= 1
392
+ ? all[0]
393
+ : (all.find(({ rect }) => inside(rect) && pointer.y >= rect.top && pointer.y <= rect.bottom) ??
394
+ all.find(({ rect }) => inside(rect)));
395
+ if (!target || target.locked) return;
396
+ const others = target.items.filter((entry) => entry.el !== press!.item);
397
+ let index = 0;
398
+ for (const entry of others) {
399
+ if (pointer.y < entry.top + entry.height / 2) break;
400
+ index++;
401
+ }
402
+ applyShift(target, index);
403
+ placement = { group: target.el, index };
404
+ };
405
+
406
+ const tick = () => {
407
+ if (!physics || !press) return;
408
+ const target = { x: pointer.x - press.grabX, y: pointer.y - press.grabY };
409
+ if (reduced()) {
410
+ floating.x = target.x;
411
+ floating.y = target.y;
412
+ render();
413
+ return;
414
+ }
415
+ const P = physics;
416
+ const now = performance.now();
417
+ const dt = Math.min((now - P.t) / 1000, 1 / 30);
418
+ if (dt <= 0) return;
419
+ P.t = now;
420
+ // Smoothed pointer velocity, then a torque from velocity and acceleration about the grab point.
421
+ const k = 1 - Math.exp(-dt / 0.05);
422
+ const ax = ((pointer.x - P.x) / dt - P.vx) * k;
423
+ const ay = ((pointer.y - P.y) / dt - P.vy) * k;
424
+ P.vx += ax;
425
+ P.vy += ay;
426
+ P.x = pointer.x;
427
+ P.y = pointer.y;
428
+ const torque = (P.vx * TILT.vel + (ax / dt) * TILT.accel) * P.armY - (P.vy * TILT.vel + (ay / dt) * TILT.accel) * P.armX;
429
+ const tilt = TILT.max * Math.tanh(torque / TILT.max);
430
+ P.tiltV += (((tilt - floating.tilt) * TILT.stiffness - P.tiltV * TILT.damping) * dt) / P.mass;
431
+ floating.tilt += P.tiltV * dt;
432
+ P.fvx += (((target.x - floating.x) * follow.stiffness - P.fvx * follow.damping) * dt) / P.mass;
433
+ P.fvy += (((target.y - floating.y) * follow.stiffness - P.fvy * follow.damping) * dt) / P.mass;
434
+ floating.x += P.fvx * dt;
435
+ floating.y += P.fvy * dt;
436
+ render();
437
+ };
438
+
439
+ const scrollStep = (distance: number) => Math.ceil(MAX_SCROLL * Math.min(1, distance / EDGE));
440
+ const scrollTick = () => {
441
+ for (const scroller of scrollers) {
442
+ const el = scroller.el;
443
+ const box = el ? el.getBoundingClientRect() : { top: 0, left: 0, bottom: innerHeight, right: innerWidth };
444
+ const top = el ? el.scrollTop : scrollY;
445
+ const left = el ? el.scrollLeft : scrollX;
446
+ const height = el ? el.clientHeight : innerHeight;
447
+ const width = el ? el.clientWidth : innerWidth;
448
+ const fullHeight = el ? el.scrollHeight : document.documentElement.scrollHeight;
449
+ const fullWidth = el ? el.scrollWidth : document.documentElement.scrollWidth;
450
+ let dy = 0;
451
+ let dx = 0;
452
+ if (scroller.y) {
453
+ if (pointer.y < box.top + EDGE && top > 0) dy = -scrollStep(box.top + EDGE - pointer.y);
454
+ else if (pointer.y > box.bottom - EDGE && top + height < fullHeight - 1) dy = scrollStep(pointer.y - (box.bottom - EDGE));
455
+ }
456
+ if (scroller.x) {
457
+ if (pointer.x < box.left + EDGE && left > 0) dx = -scrollStep(box.left + EDGE - pointer.x);
458
+ else if (pointer.x > box.right - EDGE && left + width < fullWidth - 1) dx = scrollStep(pointer.x - (box.right - EDGE));
459
+ }
460
+ if (dx || dy) {
461
+ if (el) el.scrollBy(dx, dy);
462
+ else window.scrollBy(dx, dy);
463
+ measure();
464
+ place();
465
+ return;
466
+ }
467
+ }
468
+ };
469
+ const startScroll = () => {
470
+ scrollers = [];
471
+ for (let el: HTMLElement | null = root; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
472
+ const style = getComputedStyle(el);
473
+ const y = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 1;
474
+ const x = /(auto|scroll)/.test(style.overflowX) && el.scrollWidth > el.clientWidth + 1;
475
+ if (x || y) scrollers.push({ el, x, y });
476
+ }
477
+ const page = document.documentElement;
478
+ scrollers.push({ el: null, y: page.scrollHeight > innerHeight + 1, x: page.scrollWidth > innerWidth + 1 });
479
+ gsap.ticker.add(scrollTick);
480
+ };
481
+
482
+ const start = () => {
483
+ if (!press) return;
484
+ const { item, group, startX, startY, grabX, grabY, width, height } = press;
485
+ dragging = true;
486
+ floating = { x: startX - grabX, y: startY - grabY, width, tilt: 0, lift: reduced() ? 1 : 0, fade: 0 };
487
+ layers = [makeLayer(item, group, grabX, grabY)];
488
+ draggedEls = new Set([item]);
489
+ item.setAttribute('data-mk-dragging', '');
490
+ root.setAttribute('data-mk-sorting', '');
491
+ if (!reduced()) lifting = gsap.to(floating, { lift: 1, duration: TIMING.sortable.lift, ease: 'power2.out', onUpdate: render });
492
+ const halfW = width / 2 || 1;
493
+ const halfH = height / 2 || 1;
494
+ physics = {
495
+ vx: 0,
496
+ vy: 0,
497
+ tiltV: 0,
498
+ fvx: 0,
499
+ fvy: 0,
500
+ armX: (halfW - grabX) / halfW,
501
+ armY: Math.max((halfH - grabY) / halfH, TILT.hang),
502
+ mass: Math.min(2.5, Math.max(1, Math.sqrt((width * height) / TILT.refArea))),
503
+ x: pointer.x,
504
+ y: pointer.y,
505
+ t: performance.now(),
506
+ };
507
+ gsap.ticker.add(tick);
508
+ measure();
509
+ if (autoScroll) startScroll();
510
+ placement = { group, index: press.index };
511
+ render();
512
+ };
513
+
514
+ const finish = () => {
515
+ landing = null;
516
+ lifting?.kill();
517
+ lifting = null;
518
+ for (const layer of layers) layer.el.remove();
519
+ layers = [];
520
+ for (const el of draggedEls) el.removeAttribute('data-mk-dragging');
521
+ draggedEls.clear();
522
+ root.removeAttribute('data-mk-sorting');
523
+ dragging = false;
524
+ untouch();
525
+ };
526
+
527
+ const land = (target: HTMLElement | null) => {
528
+ lifting?.kill();
529
+ lifting = null;
530
+ const item = target && target.isConnected ? target : null;
531
+ if (!item || reduced()) {
532
+ finish();
533
+ return;
534
+ }
535
+ if (!draggedEls.has(item)) {
536
+ item.setAttribute('data-mk-dragging', '');
537
+ draggedEls.add(item);
538
+ }
539
+ const box = item.getBoundingClientRect();
540
+ // Arriving somewhere else: cross-fade to a copy styled by the new place (Stepbro's "floatingTo").
541
+ if (layers.length === 1 && ownerOf(item) !== layers[0]!.el.parentElement) {
542
+ const origin = layers[0]!.el.style.transformOrigin.split(' ').map(parseFloat);
543
+ layers.push(makeLayer(item, ownerOf(item), origin[0] ?? 0, origin[1] ?? 0));
544
+ render();
545
+ }
546
+ landing = gsap.timeline({ onComplete: finish });
547
+ landing.to(
548
+ floating,
549
+ {
550
+ x: box.left - transformX(item),
551
+ y: box.top - transformY(item),
552
+ width: box.width,
553
+ tilt: 0,
554
+ lift: 0,
555
+ duration: TIMING.sortable.land,
556
+ ease: EASE.sortableLand,
557
+ onUpdate: render,
558
+ },
559
+ 0,
560
+ );
561
+ landing.to(floating, { fade: 1, duration: TIMING.sortable.landFade, ease: 'power1.inOut', onUpdate: render }, 0);
562
+ };
563
+
564
+ const stopTracking = () => {
565
+ window.removeEventListener('pointermove', onMove);
566
+ window.removeEventListener('pointerup', onUp);
567
+ window.removeEventListener('pointercancel', onCancel);
568
+ window.removeEventListener('keydown', onEscape);
569
+ gsap.ticker.remove(tick);
570
+ gsap.ticker.remove(scrollTick);
571
+ physics = null;
572
+ scrollers = [];
573
+ };
574
+
575
+ const swallowClick = () => {
576
+ const swallow = (event: Event) => {
577
+ event.preventDefault();
578
+ event.stopPropagation();
579
+ };
580
+ window.addEventListener('click', swallow, { capture: true, once: true });
581
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
582
+ };
583
+
584
+ const drop = async () => {
585
+ const current = press;
586
+ const target = placement;
587
+ const started = dragging;
588
+ stopTracking();
589
+ press = null;
590
+ placement = null;
591
+ layout = null;
592
+ if (!current || !started) return;
593
+ swallowClick();
594
+ const { item } = current;
595
+ const origin = { group: current.group, index: current.index };
596
+ if (!target || (target.group === origin.group && target.index === origin.index)) {
597
+ unshift(true);
598
+ land(item);
599
+ return;
600
+ }
601
+ const detail = makeDetail(item, origin, target);
602
+ if (!root.dispatchEvent(new CustomEvent<ReorderDetail>('mk:reorder', { bubbles: true, cancelable: true, detail }))) {
603
+ unshift(true);
604
+ land(item);
605
+ return;
606
+ }
607
+ if (mutate) {
608
+ moveDom(item, target.group, target.index);
609
+ unshift(false);
610
+ options.onReorder?.(detail);
611
+ land(item);
612
+ return;
613
+ }
614
+ const changed = domChange();
615
+ options.onReorder?.(detail);
616
+ const answered = await changed;
617
+ if (scope.disposed) return;
618
+ if (!answered) {
619
+ // The app kept the order: everything flies back.
620
+ unshift(true);
621
+ land(item.isConnected ? item : findItem(detail.id));
622
+ return;
623
+ }
624
+ unshift(false);
625
+ land(findItem(detail.id) ?? item);
626
+ };
627
+
628
+ const cancel = () => {
629
+ const current = press;
630
+ const started = dragging;
631
+ stopTracking();
632
+ press = null;
633
+ placement = null;
634
+ layout = null;
635
+ if (!current || !started) return;
636
+ unshift(true);
637
+ land(current.item);
638
+ };
639
+
640
+ function onMove(event: PointerEvent) {
641
+ if (!press) return;
642
+ pointer.x = event.clientX;
643
+ pointer.y = event.clientY;
644
+ if (!dragging) {
645
+ const dx = event.clientX - press.startX;
646
+ const dy = event.clientY - press.startY;
647
+ if (dx * dx + dy * dy < THRESHOLD * THRESHOLD) return;
648
+ start();
649
+ }
650
+ event.preventDefault();
651
+ place();
652
+ }
653
+ function onUp() {
654
+ void drop();
655
+ }
656
+ function onCancel() {
657
+ cancel();
658
+ }
659
+ function onEscape(event: KeyboardEvent) {
660
+ if (event.key === 'Escape') cancel();
661
+ }
662
+
663
+ scope.on(root, 'pointerdown', (event) => {
664
+ const down = event as PointerEvent;
665
+ const target = down.target as Element | null;
666
+ if (down.button > 0 || press || keyboard || !target) return;
667
+ const item = target.closest<HTMLElement>(ITEM);
668
+ if (!item || !root.contains(item)) return;
669
+ const onHandle = Boolean(target.closest(HANDLE));
670
+ const coarse = window.matchMedia?.('(pointer: coarse)').matches === true;
671
+ if ((options.handle || coarse) && !onHandle) return;
672
+ const control = target.closest(INTERACTIVE);
673
+ if (!onHandle && control && control !== item && item.contains(control)) return;
674
+ landing?.progress(1);
675
+ const group = ownerOf(item);
676
+ const box = item.getBoundingClientRect();
677
+ press = {
678
+ item,
679
+ group,
680
+ index: itemsOf(group).indexOf(item),
681
+ startX: down.clientX,
682
+ startY: down.clientY,
683
+ grabX: down.clientX - box.left,
684
+ grabY: down.clientY - box.top,
685
+ width: box.width,
686
+ height: box.height,
687
+ };
688
+ pointer.x = down.clientX;
689
+ pointer.y = down.clientY;
690
+ down.preventDefault();
691
+ window.addEventListener('pointermove', onMove, { passive: false });
692
+ window.addEventListener('pointerup', onUp);
693
+ window.addEventListener('pointercancel', onCancel);
694
+ window.addEventListener('keydown', onEscape);
695
+ });
696
+ // Links and images inside items must not start the browser's own drag.
697
+ scope.on(root, 'dragstart', (event) => {
698
+ if ((event.target as Element | null)?.closest?.(ITEM)) event.preventDefault();
699
+ });
700
+
701
+ // ── Keyboard ──────────────────────────────────────────────────────────────
702
+ let keyboard: { item: HTMLElement; startGroup: HTMLElement; startIndex: number } | null = null;
703
+ let moving = false;
704
+ let flipping: gsap.core.Timeline | null = null;
705
+
706
+ const positionOf = (item: HTMLElement) => {
707
+ const list = itemsOf(ownerOf(item));
708
+ return { position: list.indexOf(item) + 1, total: list.length };
709
+ };
710
+ const groupName = (group: HTMLElement) => group.getAttribute('aria-label') ?? (groups().length > 1 ? keyOf(group) : '');
711
+
712
+ /** Move an item by keyboard: reorder now (DOM or app) and let the list reflow. */
713
+ const commit = async (item: HTMLElement, group: HTMLElement, index: number) => {
714
+ const from = { group: ownerOf(item), index: itemsOf(ownerOf(item)).indexOf(item) };
715
+ if (from.group === group && from.index === index) return item;
716
+ const detail = makeDetail(item, from, { group, index });
717
+ if (!root.dispatchEvent(new CustomEvent<ReorderDetail>('mk:reorder', { bubbles: true, cancelable: true, detail }))) return item;
718
+ moving = true;
719
+ // Taken first: measuring can move the focus away (moving a node blurs it).
720
+ const focused = item.contains(document.activeElement) ? (document.activeElement as HTMLElement) : item;
721
+ const captured = captureLayout(root, ITEM);
722
+ let current: HTMLElement = item;
723
+ if (mutate) {
724
+ moveDom(item, group, index);
725
+ options.onReorder?.(detail);
726
+ } else {
727
+ const changed = domChange();
728
+ options.onReorder?.(detail);
729
+ await changed;
730
+ current = findItem(detail.id) ?? item;
731
+ }
732
+ const target = current === item ? focused : current;
733
+ if (target.isConnected && document.activeElement !== target) target.focus({ preventScroll: true });
734
+ moving = false;
735
+ if (scope.disposed) return current;
736
+ // Flip tags elements with auto ids; take them off once the reflow is over.
737
+ const untag = () => {
738
+ for (const el of root.querySelectorAll<HTMLElement>('[data-flip-id^="auto-"]')) {
739
+ el.removeAttribute('data-flip-id');
740
+ touched.add(el);
741
+ }
742
+ untouch();
743
+ };
744
+ flipping?.progress(1);
745
+ flipping = reflowLayout(root, ITEM, captured);
746
+ if (flipping) flipping.eventCallback('onComplete', untag);
747
+ else untag();
748
+ return current;
749
+ };
750
+
751
+ const release = (announcement?: string) => {
752
+ if (!keyboard) return;
753
+ keyboard.item.removeAttribute('data-mk-sort-grabbed');
754
+ keyboard = null;
755
+ if (announcement) announce(announcement);
756
+ };
757
+
758
+ scope.on(root, 'keydown', (event) => {
759
+ const key = event as KeyboardEvent;
760
+ const target = key.target as HTMLElement | null;
761
+ const item = target?.closest<HTMLElement>(ITEM);
762
+ if (!item || !root.contains(item) || dragging) return;
763
+ const handle = target!.closest(HANDLE);
764
+ if (target !== item && !(handle && item.contains(handle))) return;
765
+ const lift = key.key === ' ' || key.key === 'Enter';
766
+ if (keyboard && keyboard.item !== item) release();
767
+ if (!keyboard) {
768
+ if (!lift) return;
769
+ key.preventDefault();
770
+ keyboard = { item, startGroup: ownerOf(item), startIndex: itemsOf(ownerOf(item)).indexOf(item) };
771
+ item.setAttribute('data-mk-sort-grabbed', '');
772
+ const { position, total } = positionOf(item);
773
+ announce(labels.lifted(position, total));
774
+ return;
775
+ }
776
+ const grabbed = keyboard;
777
+ const group = ownerOf(item);
778
+ const list = itemsOf(group);
779
+ const index = list.indexOf(item);
780
+ let to: { group: HTMLElement; index: number } | null = null;
781
+ if (key.key === 'ArrowUp' || key.key === 'ArrowDown') {
782
+ const next = Math.min(list.length - 1, Math.max(0, index + (key.key === 'ArrowUp' ? -1 : 1)));
783
+ to = { group, index: next };
784
+ } else if (key.key === 'ArrowLeft' || key.key === 'ArrowRight') {
785
+ const open = groups().filter((candidate) => candidate === group || !isLocked(candidate));
786
+ const next = open[open.indexOf(group) + (key.key === 'ArrowLeft' ? -1 : 1)];
787
+ if (next) to = { group: next, index: Math.min(index, itemsOf(next).length) };
788
+ } else if (lift) {
789
+ key.preventDefault();
790
+ const { position, total } = positionOf(item);
791
+ release(labels.dropped(position, total));
792
+ return;
793
+ } else if (key.key === 'Escape') {
794
+ key.preventDefault();
795
+ void commit(item, grabbed.startGroup, grabbed.startIndex).then(() => release(labels.cancelled));
796
+ return;
797
+ } else return;
798
+ key.preventDefault();
799
+ if (!to) return;
800
+ void commit(item, to.group, to.index).then((moved) => {
801
+ if (!keyboard) return;
802
+ if (moved !== keyboard.item) {
803
+ keyboard.item.removeAttribute('data-mk-sort-grabbed');
804
+ keyboard.item = moved;
805
+ moved.setAttribute('data-mk-sort-grabbed', '');
806
+ }
807
+ const { position, total } = positionOf(moved);
808
+ announce(labels.moved(position, total, groupName(ownerOf(moved))));
809
+ });
810
+ });
811
+ // Space on a button handle would click it on keyup.
812
+ scope.on(root, 'keyup', (event) => {
813
+ if ((event as KeyboardEvent).key === ' ' && (event.target as Element | null)?.closest(HANDLE)) event.preventDefault();
814
+ });
815
+ scope.on(root, 'focusout', (event) => {
816
+ if (!keyboard || moving) return;
817
+ const next = (event as FocusEvent).relatedTarget as Node | null;
818
+ if (next && keyboard.item.contains(next)) return;
819
+ release();
820
+ });
821
+
822
+ // Items must take focus for the keyboard: those without a handle get tabindex 0.
823
+ const focusable = (item: HTMLElement) => {
824
+ if (item.querySelector(HANDLE) || item.hasAttribute('tabindex') || item.matches(INTERACTIVE)) return;
825
+ scope.attr(item, 'tabindex', '0');
826
+ };
827
+ for (const item of root.querySelectorAll<HTMLElement>(ITEM)) focusable(item);
828
+ scope
829
+ .observe(
830
+ new MutationObserver(() => {
831
+ for (const item of root.querySelectorAll<HTMLElement>(`${ITEM}:not([tabindex])`)) focusable(item);
832
+ }),
833
+ )
834
+ .observe(root, { childList: true, subtree: true });
835
+
836
+ root.dataset.mkReady = '';
837
+
838
+ return {
839
+ cancel,
840
+ get dragging() {
841
+ return dragging;
842
+ },
843
+ destroy() {
844
+ stopTracking();
845
+ press = null;
846
+ landing?.kill();
847
+ flipping?.progress(1);
848
+ unshift(false);
849
+ finish();
850
+ release();
851
+ scope.dispose();
852
+ delete root.dataset.mkReady;
853
+ },
854
+ };
855
+ }
856
+
857
+ /** Move one entry of an array (for onReorder in a single list): moveItem(list, fromIndex, toIndex). */
858
+ export function moveItem<T>(list: readonly T[], from: number, to: number): T[] {
859
+ const next = list.slice();
860
+ const [entry] = next.splice(from, 1);
861
+ if (entry !== undefined) next.splice(Math.min(Math.max(to, 0), next.length), 0, entry);
862
+ return next;
863
+ }