@yewdiel21/motion-kit 0.10.0 → 0.14.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 (365) hide show
  1. package/README.md +155 -93
  2. package/dist/auto/index.d.ts +53 -3
  3. package/dist/auto/index.js +101 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-IKQ5FODN.js → chunk-6P4P7NU6.js} +2 -218
  6. package/dist/chunk-6P4P7NU6.js.map +1 -0
  7. package/dist/chunk-CU6SXW3E.js +20334 -0
  8. package/dist/chunk-CU6SXW3E.js.map +1 -0
  9. package/dist/{chunk-XAKC2QY7.js → chunk-EY4BJKC4.js} +3 -28
  10. package/dist/chunk-EY4BJKC4.js.map +1 -0
  11. package/dist/chunk-QP4QJOEN.js +72 -0
  12. package/dist/chunk-QP4QJOEN.js.map +1 -0
  13. package/dist/css/components/activity-feed.css +240 -0
  14. package/dist/css/components/attachment.css +266 -0
  15. package/dist/css/components/breadcrumb.css +110 -0
  16. package/dist/css/components/chart-tooltip.css +168 -0
  17. package/dist/css/components/chart.css +296 -0
  18. package/dist/css/components/code-block.css +264 -0
  19. package/dist/css/components/color-area.css +82 -0
  20. package/dist/css/components/color-field.css +67 -0
  21. package/dist/css/components/color-picker.css +131 -0
  22. package/dist/css/components/color-slider.css +113 -0
  23. package/dist/css/components/command-search.css +252 -0
  24. package/dist/css/components/context-menu.css +23 -0
  25. package/dist/css/components/credit-card.css +288 -0
  26. package/dist/css/components/data-table.css +591 -0
  27. package/dist/css/components/date-range-picker.css +168 -0
  28. package/dist/css/components/date-time-picker.css +178 -0
  29. package/dist/css/components/drawer.css +58 -0
  30. package/dist/css/components/editable-table.css +201 -0
  31. package/dist/css/components/empty-state.css +61 -0
  32. package/dist/css/components/file-explorer.css +633 -0
  33. package/dist/css/components/filter-builder.css +232 -0
  34. package/dist/css/components/hold-confirm.css +87 -0
  35. package/dist/css/components/hover-card.css +13 -7
  36. package/dist/css/components/image-preview.css +195 -0
  37. package/dist/css/components/json-viewer.css +272 -0
  38. package/dist/css/components/like-button.css +78 -0
  39. package/dist/css/components/link-preview.css +275 -0
  40. package/dist/css/components/loading-button.css +77 -0
  41. package/dist/css/components/multi-select.css +267 -0
  42. package/dist/css/components/notification-bell.css +127 -0
  43. package/dist/css/components/password-strength.css +183 -0
  44. package/dist/css/components/popover.css +25 -0
  45. package/dist/css/components/progress.css +140 -0
  46. package/dist/css/components/questionnaire.css +217 -0
  47. package/dist/css/components/range-calendar.css +186 -0
  48. package/dist/css/components/range-slider.css +192 -0
  49. package/dist/css/components/resizable-panel.css +243 -0
  50. package/dist/css/components/rich-text-editor.css +284 -0
  51. package/dist/css/components/scratch-card.css +75 -0
  52. package/dist/css/components/skeleton.css +108 -0
  53. package/dist/css/components/split-view.css +19 -0
  54. package/dist/css/components/star-rating.css +80 -0
  55. package/dist/css/components/stat-card.css +164 -0
  56. package/dist/css/components/swipe-actions.css +105 -0
  57. package/dist/css/components/tag-input.css +244 -0
  58. package/dist/css/components/time-field.css +88 -0
  59. package/dist/css/components/timeline.css +219 -0
  60. package/dist/css/components/tooltip.css +79 -0
  61. package/dist/css/components/tree-view.css +210 -0
  62. package/dist/css/components/virtual-list.css +195 -0
  63. package/dist/css/tokens.css +185 -181
  64. package/dist/elements/index.d.ts +674 -5
  65. package/dist/elements/index.js +1361 -8
  66. package/dist/elements/index.js.map +1 -1
  67. package/dist/{engine-GDGwQnJE.d.ts → engine-Cn6IhE3z.d.ts} +1 -1
  68. package/dist/engine-QECQnGz1.d.ts +33 -0
  69. package/dist/index.css +9104 -189
  70. package/dist/index.d.ts +89 -5
  71. package/dist/index.js +243 -9
  72. package/dist/index.js.map +1 -1
  73. package/dist/react/index.d.ts +579 -23
  74. package/dist/react/index.js +2042 -7
  75. package/dist/react/index.js.map +1 -1
  76. package/dist/{reduced-BmBzKH94.d.ts → reduced-BBUusld6.d.ts} +9 -2
  77. package/dist/reveal-DEvp6U6T.d.ts +2381 -0
  78. package/dist/{store-DYb3tTsZ.d.ts → store-DjkTBX7f.d.ts} +2 -2
  79. package/package.json +1 -1
  80. package/registry.json +406 -1
  81. package/src/auto/auto-init.ts +98 -0
  82. package/src/components/activity-feed/README.md +56 -0
  83. package/src/components/activity-feed/activity-feed.css +239 -0
  84. package/src/components/activity-feed/element.ts +36 -0
  85. package/src/components/activity-feed/engine.ts +351 -0
  86. package/src/components/activity-feed/react.tsx +32 -0
  87. package/src/components/attachment/README.md +27 -0
  88. package/src/components/attachment/attachment.css +265 -0
  89. package/src/components/attachment/element.ts +50 -0
  90. package/src/components/attachment/engine.ts +469 -0
  91. package/src/components/attachment/react.tsx +65 -0
  92. package/src/components/breadcrumb/README.md +38 -0
  93. package/src/components/breadcrumb/breadcrumb.css +109 -0
  94. package/src/components/breadcrumb/element.ts +27 -0
  95. package/src/components/breadcrumb/engine.ts +249 -0
  96. package/src/components/breadcrumb/react.tsx +30 -0
  97. package/src/components/chart/README.md +24 -0
  98. package/src/components/chart/chart.css +295 -0
  99. package/src/components/chart/element.ts +41 -0
  100. package/src/components/chart/engine.ts +811 -0
  101. package/src/components/chart/react.tsx +50 -0
  102. package/src/components/chart-tooltip/README.md +53 -0
  103. package/src/components/chart-tooltip/chart-tooltip.css +167 -0
  104. package/src/components/chart-tooltip/element.ts +37 -0
  105. package/src/components/chart-tooltip/engine.ts +442 -0
  106. package/src/components/chart-tooltip/react.tsx +47 -0
  107. package/src/components/code-block/README.md +19 -0
  108. package/src/components/code-block/code-block.css +263 -0
  109. package/src/components/code-block/element.ts +34 -0
  110. package/src/components/code-block/engine.ts +254 -0
  111. package/src/components/code-block/highlight.ts +244 -0
  112. package/src/components/code-block/react.tsx +94 -0
  113. package/src/components/color-area/README.md +50 -0
  114. package/src/components/color-area/color-area.css +81 -0
  115. package/src/components/color-area/element.ts +26 -0
  116. package/src/components/color-area/engine.ts +246 -0
  117. package/src/components/color-area/react.tsx +42 -0
  118. package/src/components/color-field/README.md +43 -0
  119. package/src/components/color-field/color-field.css +66 -0
  120. package/src/components/color-field/element.ts +26 -0
  121. package/src/components/color-field/engine.ts +174 -0
  122. package/src/components/color-field/react.tsx +42 -0
  123. package/src/components/color-picker/README.md +45 -0
  124. package/src/components/color-picker/color-picker.css +130 -0
  125. package/src/components/color-picker/element.ts +45 -0
  126. package/src/components/color-picker/engine.ts +314 -0
  127. package/src/components/color-picker/react.tsx +47 -0
  128. package/src/components/color-slider/README.md +55 -0
  129. package/src/components/color-slider/color-slider.css +112 -0
  130. package/src/components/color-slider/element.ts +26 -0
  131. package/src/components/color-slider/engine.ts +234 -0
  132. package/src/components/color-slider/react.tsx +45 -0
  133. package/src/components/command-search/README.md +15 -0
  134. package/src/components/command-search/command-search.css +251 -0
  135. package/src/components/command-search/element.ts +36 -0
  136. package/src/components/command-search/engine.ts +356 -0
  137. package/src/components/command-search/match.ts +53 -0
  138. package/src/components/command-search/react.tsx +66 -0
  139. package/src/components/context-menu/README.md +31 -0
  140. package/src/components/context-menu/context-menu.css +22 -0
  141. package/src/components/context-menu/element.ts +40 -0
  142. package/src/components/context-menu/engine.ts +153 -0
  143. package/src/components/context-menu/react.tsx +57 -0
  144. package/src/components/credit-card/README.md +20 -0
  145. package/src/components/credit-card/card.ts +77 -0
  146. package/src/components/credit-card/credit-card.css +287 -0
  147. package/src/components/credit-card/element.ts +33 -0
  148. package/src/components/credit-card/engine.ts +410 -0
  149. package/src/components/credit-card/react.tsx +36 -0
  150. package/src/components/data-table/README.md +83 -0
  151. package/src/components/data-table/data-table.css +590 -0
  152. package/src/components/data-table/element.ts +54 -0
  153. package/src/components/data-table/engine.ts +1059 -0
  154. package/src/components/data-table/react.tsx +94 -0
  155. package/src/components/date-range-picker/README.md +51 -0
  156. package/src/components/date-range-picker/date-range-picker.css +167 -0
  157. package/src/components/date-range-picker/element.ts +52 -0
  158. package/src/components/date-range-picker/engine.ts +352 -0
  159. package/src/components/date-range-picker/react.tsx +57 -0
  160. package/src/components/date-time-picker/README.md +48 -0
  161. package/src/components/date-time-picker/date-time-picker.css +177 -0
  162. package/src/components/date-time-picker/element.ts +47 -0
  163. package/src/components/date-time-picker/engine.ts +350 -0
  164. package/src/components/date-time-picker/react.tsx +54 -0
  165. package/src/components/dialog/README.md +103 -102
  166. package/src/components/dialog/element.ts +123 -118
  167. package/src/components/dialog/engine.ts +449 -432
  168. package/src/components/drawer/README.md +28 -0
  169. package/src/components/drawer/drawer.css +57 -0
  170. package/src/components/drawer/element.ts +21 -0
  171. package/src/components/drawer/engine.ts +56 -0
  172. package/src/components/drawer/react.tsx +85 -0
  173. package/src/components/editable-table/README.md +67 -0
  174. package/src/components/editable-table/editable-table.css +200 -0
  175. package/src/components/editable-table/element.ts +45 -0
  176. package/src/components/editable-table/engine.ts +775 -0
  177. package/src/components/editable-table/react.tsx +79 -0
  178. package/src/components/empty-state/README.md +41 -0
  179. package/src/components/empty-state/element.ts +23 -0
  180. package/src/components/empty-state/empty-state.css +60 -0
  181. package/src/components/empty-state/engine.ts +232 -0
  182. package/src/components/empty-state/react.tsx +34 -0
  183. package/src/components/file-explorer/README.md +67 -0
  184. package/src/components/file-explorer/element.ts +39 -0
  185. package/src/components/file-explorer/engine.ts +1307 -0
  186. package/src/components/file-explorer/file-explorer.css +632 -0
  187. package/src/components/file-explorer/react.tsx +77 -0
  188. package/src/components/filter-builder/README.md +47 -0
  189. package/src/components/filter-builder/element.ts +34 -0
  190. package/src/components/filter-builder/engine.ts +365 -0
  191. package/src/components/filter-builder/filter-builder.css +231 -0
  192. package/src/components/filter-builder/react.tsx +36 -0
  193. package/src/components/hold-confirm/README.md +13 -0
  194. package/src/components/hold-confirm/element.ts +24 -0
  195. package/src/components/hold-confirm/engine.ts +216 -0
  196. package/src/components/hold-confirm/hold-confirm.css +86 -0
  197. package/src/components/hold-confirm/react.tsx +28 -0
  198. package/src/components/hover-card/README.md +6 -2
  199. package/src/components/hover-card/engine.ts +68 -6
  200. package/src/components/hover-card/hover-card.css +13 -7
  201. package/src/components/image-preview/README.md +16 -0
  202. package/src/components/image-preview/element.ts +26 -0
  203. package/src/components/image-preview/engine.ts +325 -0
  204. package/src/components/image-preview/image-preview.css +194 -0
  205. package/src/components/image-preview/react.tsx +43 -0
  206. package/src/components/json-viewer/README.md +56 -0
  207. package/src/components/json-viewer/element.ts +37 -0
  208. package/src/components/json-viewer/engine.ts +440 -0
  209. package/src/components/json-viewer/json-viewer.css +271 -0
  210. package/src/components/json-viewer/react.tsx +56 -0
  211. package/src/components/like-button/README.md +13 -0
  212. package/src/components/like-button/element.ts +47 -0
  213. package/src/components/like-button/engine.ts +145 -0
  214. package/src/components/like-button/like-button.css +77 -0
  215. package/src/components/like-button/react.tsx +39 -0
  216. package/src/components/link-preview/README.md +19 -0
  217. package/src/components/link-preview/element.ts +29 -0
  218. package/src/components/link-preview/engine.ts +192 -0
  219. package/src/components/link-preview/link-preview.css +274 -0
  220. package/src/components/link-preview/react.tsx +101 -0
  221. package/src/components/loading-button/README.md +45 -0
  222. package/src/components/loading-button/element.ts +34 -0
  223. package/src/components/loading-button/engine.ts +201 -0
  224. package/src/components/loading-button/loading-button.css +76 -0
  225. package/src/components/loading-button/react.tsx +43 -0
  226. package/src/components/multi-select/README.md +48 -0
  227. package/src/components/multi-select/element.ts +28 -0
  228. package/src/components/multi-select/engine.ts +294 -0
  229. package/src/components/multi-select/multi-select.css +266 -0
  230. package/src/components/multi-select/react.tsx +39 -0
  231. package/src/components/notification-bell/README.md +14 -0
  232. package/src/components/notification-bell/element.ts +48 -0
  233. package/src/components/notification-bell/engine.ts +202 -0
  234. package/src/components/notification-bell/notification-bell.css +126 -0
  235. package/src/components/notification-bell/react.tsx +57 -0
  236. package/src/components/password-strength/README.md +17 -0
  237. package/src/components/password-strength/element.ts +22 -0
  238. package/src/components/password-strength/engine.ts +212 -0
  239. package/src/components/password-strength/password-strength.css +182 -0
  240. package/src/components/password-strength/react.tsx +36 -0
  241. package/src/components/password-strength/score.ts +62 -0
  242. package/src/components/popover/README.md +44 -0
  243. package/src/components/popover/element.ts +31 -0
  244. package/src/components/popover/engine.ts +114 -0
  245. package/src/components/popover/popover.css +24 -0
  246. package/src/components/popover/react.tsx +50 -0
  247. package/src/components/progress/README.md +17 -0
  248. package/src/components/progress/element.ts +30 -0
  249. package/src/components/progress/engine.ts +173 -0
  250. package/src/components/progress/progress.css +139 -0
  251. package/src/components/progress/react.tsx +35 -0
  252. package/src/components/questionnaire/README.md +32 -0
  253. package/src/components/questionnaire/element.ts +118 -0
  254. package/src/components/questionnaire/engine.ts +255 -0
  255. package/src/components/questionnaire/questionnaire.css +216 -0
  256. package/src/components/questionnaire/react.tsx +140 -0
  257. package/src/components/range-calendar/README.md +58 -0
  258. package/src/components/range-calendar/element.ts +42 -0
  259. package/src/components/range-calendar/engine.ts +390 -0
  260. package/src/components/range-calendar/range-calendar.css +185 -0
  261. package/src/components/range-calendar/react.tsx +56 -0
  262. package/src/components/range-slider/README.md +23 -0
  263. package/src/components/range-slider/element.ts +53 -0
  264. package/src/components/range-slider/engine.ts +316 -0
  265. package/src/components/range-slider/range-slider.css +191 -0
  266. package/src/components/range-slider/react.tsx +66 -0
  267. package/src/components/resizable-panel/README.md +61 -0
  268. package/src/components/resizable-panel/element.ts +36 -0
  269. package/src/components/resizable-panel/engine.ts +551 -0
  270. package/src/components/resizable-panel/react.tsx +74 -0
  271. package/src/components/resizable-panel/resizable-panel.css +242 -0
  272. package/src/components/rich-text-editor/README.md +57 -0
  273. package/src/components/rich-text-editor/element.ts +34 -0
  274. package/src/components/rich-text-editor/engine.ts +673 -0
  275. package/src/components/rich-text-editor/react.tsx +60 -0
  276. package/src/components/rich-text-editor/rich-text-editor.css +283 -0
  277. package/src/components/scratch-card/README.md +15 -0
  278. package/src/components/scratch-card/element.ts +30 -0
  279. package/src/components/scratch-card/engine.ts +257 -0
  280. package/src/components/scratch-card/react.tsx +40 -0
  281. package/src/components/scratch-card/scratch-card.css +74 -0
  282. package/src/components/skeleton/README.md +15 -0
  283. package/src/components/skeleton/element.ts +32 -0
  284. package/src/components/skeleton/engine.ts +104 -0
  285. package/src/components/skeleton/react.tsx +41 -0
  286. package/src/components/skeleton/skeleton.css +107 -0
  287. package/src/components/split-view/README.md +50 -0
  288. package/src/components/split-view/element.ts +34 -0
  289. package/src/components/split-view/engine.ts +114 -0
  290. package/src/components/split-view/react.tsx +63 -0
  291. package/src/components/split-view/split-view.css +18 -0
  292. package/src/components/star-rating/README.md +14 -0
  293. package/src/components/star-rating/element.ts +26 -0
  294. package/src/components/star-rating/engine.ts +217 -0
  295. package/src/components/star-rating/react.tsx +33 -0
  296. package/src/components/star-rating/star-rating.css +79 -0
  297. package/src/components/stat-card/README.md +42 -0
  298. package/src/components/stat-card/element.ts +40 -0
  299. package/src/components/stat-card/engine.ts +283 -0
  300. package/src/components/stat-card/react.tsx +36 -0
  301. package/src/components/stat-card/stat-card.css +163 -0
  302. package/src/components/swipe-actions/README.md +19 -0
  303. package/src/components/swipe-actions/element.ts +26 -0
  304. package/src/components/swipe-actions/engine.ts +260 -0
  305. package/src/components/swipe-actions/react.tsx +80 -0
  306. package/src/components/swipe-actions/swipe-actions.css +104 -0
  307. package/src/components/tag-input/README.md +42 -0
  308. package/src/components/tag-input/element.ts +39 -0
  309. package/src/components/tag-input/engine.ts +313 -0
  310. package/src/components/tag-input/react.tsx +54 -0
  311. package/src/components/tag-input/tag-input.css +243 -0
  312. package/src/components/time-field/README.md +54 -0
  313. package/src/components/time-field/element.ts +38 -0
  314. package/src/components/time-field/engine.ts +110 -0
  315. package/src/components/time-field/react.tsx +45 -0
  316. package/src/components/time-field/time-field.css +87 -0
  317. package/src/components/timeline/README.md +57 -0
  318. package/src/components/timeline/element.ts +31 -0
  319. package/src/components/timeline/engine.ts +300 -0
  320. package/src/components/timeline/react.tsx +59 -0
  321. package/src/components/timeline/timeline.css +218 -0
  322. package/src/components/tooltip/README.md +46 -0
  323. package/src/components/tooltip/element.ts +22 -0
  324. package/src/components/tooltip/engine.ts +288 -0
  325. package/src/components/tooltip/react.tsx +30 -0
  326. package/src/components/tooltip/tooltip.css +78 -0
  327. package/src/components/tree-view/README.md +61 -0
  328. package/src/components/tree-view/element.ts +32 -0
  329. package/src/components/tree-view/engine.ts +569 -0
  330. package/src/components/tree-view/react.tsx +78 -0
  331. package/src/components/tree-view/tree-view.css +209 -0
  332. package/src/components/virtual-list/README.md +81 -0
  333. package/src/components/virtual-list/element.ts +42 -0
  334. package/src/components/virtual-list/engine.ts +678 -0
  335. package/src/components/virtual-list/react.tsx +101 -0
  336. package/src/components/virtual-list/virtual-list.css +194 -0
  337. package/src/core/chips.ts +196 -0
  338. package/src/core/color.ts +304 -0
  339. package/src/core/date-range.ts +67 -0
  340. package/src/core/dates.ts +11 -1
  341. package/src/core/files.ts +107 -0
  342. package/src/core/filter.ts +229 -0
  343. package/src/core/gsap.ts +80 -65
  344. package/src/core/listbox.ts +332 -0
  345. package/src/core/mark.ts +29 -0
  346. package/src/core/position.ts +89 -0
  347. package/src/core/relative-time.ts +77 -0
  348. package/src/core/segment-field.ts +373 -0
  349. package/src/core/segments.ts +153 -0
  350. package/src/core/tree.ts +116 -0
  351. package/src/core/virtual.ts +79 -0
  352. package/src/css/tokens.css +186 -182
  353. package/src/elements/index.ts +147 -0
  354. package/src/index.ts +72 -0
  355. package/src/motion/micro.ts +248 -0
  356. package/src/motion/sheet.ts +88 -65
  357. package/src/motion/surface.ts +9 -0
  358. package/src/react/index.ts +57 -0
  359. package/dist/chunk-IKQ5FODN.js.map +0 -1
  360. package/dist/chunk-J6OCFNGU.js +0 -177
  361. package/dist/chunk-J6OCFNGU.js.map +0 -1
  362. package/dist/chunk-TFNRIZHU.js +0 -3977
  363. package/dist/chunk-TFNRIZHU.js.map +0 -1
  364. package/dist/chunk-XAKC2QY7.js.map +0 -1
  365. package/dist/reveal-jsX-uwUz.d.ts +0 -625
@@ -0,0 +1,678 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { requestFrame } from '../../core/frame';
3
+ import { reduced } from '../../core/reduced';
4
+ import { createScope } from '../../core/scope';
5
+ import { inert, type Instance } from '../../core/types';
6
+ import { fromJson } from '../../core/dates';
7
+ import { createSizes, type VirtualRange } from '../../core/virtual';
8
+ import { blurIn } from '../../motion/micro';
9
+
10
+ /*
11
+ * Virtual list: thousands of rows with only those in view (and a few around
12
+ * them) in the page. Rows are recycled as they scroll; their heights are one
13
+ * fixed value or measured as they show (an estimate for the rest), and the
14
+ * scroll stays put when a measured row above changes size. While scrolling, a
15
+ * pill rides the right edge with the position ("1 240 de 10 000") and slips
16
+ * away out of a blur when the scroll stops. scrollToIndex() glides there
17
+ * (.6 s, signature; a long way jumps most of it first) and flashes the row.
18
+ * Selectable lists are a listbox: a ring glides between rows with the
19
+ * keyboard (↑ ↓, Re Pág / Av Pág, Inicio, Fin, typing jumps), and a chosen
20
+ * row's check pops. onEndReached() brings more rows at the end (a loading row
21
+ * meanwhile, "No hay más elementos" when it answers with none). The first rows
22
+ * come in out of a blur; new data flows in softly.
23
+ */
24
+
25
+ export type VirtualKey = string | number;
26
+
27
+ export interface VirtualListOptions<T = unknown> {
28
+ items?: T[];
29
+ /** Every row this tall (fastest); without it rows are measured. */
30
+ itemHeight?: number;
31
+ /** Height of a row not measured yet (default: itemHeight, else 48). */
32
+ estimatedHeight?: number;
33
+ /** Rows drawn beyond each edge (default 6). */
34
+ overscan?: number;
35
+ /** Draw a row into its element (recycled: draw all of it every time). Default: its text. */
36
+ renderItem?: (item: T, index: number, element: HTMLElement) => void;
37
+ /** A row's text: the default drawing, typing to jump and announcements (default: label, title, name or the value). */
38
+ itemText?: (item: T) => string;
39
+ /** A stable key for a row (default: its id, else its index). */
40
+ getKey?: (item: T, index: number) => VirtualKey;
41
+ /** Rows can be chosen: true or 'single', or 'multiple' (default false). */
42
+ selectable?: boolean | 'single' | 'multiple';
43
+ selected?: VirtualKey[];
44
+ /** Accessible name (default 'Lista'). */
45
+ label?: string;
46
+ emptyLabel?: string;
47
+ locale?: string;
48
+ onSelect?: (keys: VirtualKey[], items: T[]) => void;
49
+ /** Enter or a double click on a row. */
50
+ onActivate?: (item: T, index: number) => void;
51
+ /** More rows at the end: resolve with them, with [] when there are no more, or with nothing when you add them yourself (setItems). */
52
+ onEndReached?: () => Promise<T[] | void> | T[] | void;
53
+ /** Draw the rows yourself (frameworks): told which ones; put them in `container` as [data-mk-vl-item][data-index] and call refresh(). */
54
+ onRange?: (range: VirtualRange) => void;
55
+ }
56
+
57
+ export interface VirtualScrollOptions {
58
+ align?: 'auto' | 'start' | 'center' | 'end';
59
+ animate?: boolean;
60
+ }
61
+
62
+ export interface VirtualListInstance<T = unknown> extends Instance {
63
+ setItems(items: T[]): void;
64
+ /** Add rows at the end. */
65
+ append(items: T[]): void;
66
+ scrollToIndex(index: number, options?: VirtualScrollOptions): void;
67
+ /** Position and measure the drawn rows again (after drawing them yourself, or when their content changed). */
68
+ refresh(): void;
69
+ select(keys: VirtualKey[]): void;
70
+ /** Change how rows are drawn (vanilla). */
71
+ setRenderer(renderItem: (item: T, index: number, element: HTMLElement) => void): void;
72
+ readonly items: T[];
73
+ readonly range: VirtualRange;
74
+ readonly selected: VirtualKey[];
75
+ /** Where the rows live. */
76
+ readonly container: HTMLElement;
77
+ }
78
+
79
+ const FOOT_HEIGHT = 48;
80
+ let sequence = 0;
81
+
82
+ /** label, title, name or text of an object; the value itself otherwise. */
83
+ const defaultText = (item: unknown): string => {
84
+ if (item === null || item === undefined) return '';
85
+ if (typeof item !== 'object') return String(item);
86
+ const record = item as Record<string, unknown>;
87
+ const text = record.label ?? record.title ?? record.name ?? record.text;
88
+ return text === undefined ? JSON.stringify(item) : String(text);
89
+ };
90
+
91
+ export function createVirtualList<T = unknown>(root: HTMLElement | null, options: VirtualListOptions<T> = {}): VirtualListInstance<T> {
92
+ if (!root) {
93
+ return inert({
94
+ setItems() {}, append() {}, scrollToIndex() {}, refresh() {}, select() {}, setRenderer() {},
95
+ items: [] as T[], range: { start: 0, end: 0 }, selected: [], container: null as unknown as HTMLElement,
96
+ });
97
+ }
98
+ registerMotion();
99
+ const scope = createScope();
100
+ const uid = `mk-vl-${++sequence}`;
101
+ const locale = options.locale ?? root.closest('[lang]')?.getAttribute('lang') ?? 'es';
102
+ // A framework may render the viewport and the spacer itself (and the rows inside, see onRange).
103
+ const adopted = root.querySelector<HTMLElement>(':scope > [data-mk-vl-viewport]');
104
+ // Rows written as a list inside the root are its text rows.
105
+ const authored = [...root.querySelectorAll(':scope > ul > li, :scope > ol > li')].map((li) => li.textContent?.trim() ?? '') as T[];
106
+ // JSON text too (data-mk-items through autoInit).
107
+ let items: T[] = [...(fromJson(options.items) ?? authored)];
108
+ const fixed = options.itemHeight && options.itemHeight > 0 ? options.itemHeight : null;
109
+ const estimate = options.estimatedHeight ?? fixed ?? 48;
110
+ const overscan = Math.max(0, options.overscan ?? 6);
111
+ let renderItem = options.renderItem;
112
+ const external = Boolean(options.onRange) && !renderItem;
113
+ const selectable = options.selectable === true ? 'single' : options.selectable || null;
114
+ const keyOf = (item: T, index: number): VirtualKey => {
115
+ if (options.getKey) return options.getKey(item, index);
116
+ const id = item && typeof item === 'object' ? (item as { id?: unknown }).id : undefined;
117
+ return typeof id === 'string' || typeof id === 'number' ? id : index;
118
+ };
119
+ const textOf = (item: T) => options.itemText?.(item) ?? defaultText(item);
120
+ const measured = new Map<VirtualKey, number>();
121
+ const heights = () => items.map((item, i) => fixed ?? measured.get(keyOf(item, i)) ?? estimate);
122
+ let sizes = createSizes(heights());
123
+ let selected = new Set<VirtualKey>(fromJson(options.selected) ?? []);
124
+ let active = -1;
125
+ let anchor = -1;
126
+ let range: VirtualRange = { start: 0, end: 0 };
127
+ let number: Intl.NumberFormat;
128
+ try {
129
+ number = new Intl.NumberFormat(locale);
130
+ } catch {
131
+ number = new Intl.NumberFormat();
132
+ }
133
+ const effects = new Set<gsap.core.Animation>();
134
+ const keep = (animation: gsap.core.Animation | null) => {
135
+ if (!animation) return;
136
+ effects.add(animation);
137
+ // Finished ones leave as new ones arrive: the animation's own onComplete stays untouched.
138
+ for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
139
+ };
140
+
141
+ // ── The parts ────────────────────────────────────────────────────────────
142
+ const holder = document.createElement('div');
143
+ holder.hidden = true;
144
+ holder.append(...[...root.childNodes].filter((node) => node !== adopted));
145
+ const viewport = adopted ?? document.createElement('div');
146
+ viewport.dataset.mkVlViewport = '';
147
+ scope.attr(viewport, 'tabindex', '0');
148
+ scope.attr(viewport, 'role', selectable ? 'listbox' : 'list');
149
+ scope.attr(viewport, 'aria-label', options.label ?? 'Lista');
150
+ if (selectable === 'multiple') scope.attr(viewport, 'aria-multiselectable', 'true');
151
+ const spacer = viewport.querySelector<HTMLElement>(':scope > [data-mk-vl-spacer]') ?? document.createElement('div');
152
+ spacer.dataset.mkVlSpacer = '';
153
+ const spacerStyle = spacer.getAttribute('style');
154
+ const cursor = document.createElement('span');
155
+ cursor.dataset.mkVlCursor = '';
156
+ cursor.setAttribute('aria-hidden', 'true');
157
+ const foot = document.createElement('div');
158
+ foot.dataset.mkVlFoot = '';
159
+ foot.setAttribute('role', 'presentation');
160
+ foot.hidden = true;
161
+ spacer.prepend(cursor);
162
+ spacer.append(foot);
163
+ if (!spacer.parentElement) viewport.append(spacer);
164
+ const empty = document.createElement('p');
165
+ empty.dataset.mkVlEmpty = '';
166
+ empty.textContent = options.emptyLabel ?? 'Sin elementos';
167
+ const pill = document.createElement('div');
168
+ pill.dataset.mkVlPill = '';
169
+ pill.setAttribute('aria-hidden', 'true');
170
+ pill.hidden = true;
171
+ const status = document.createElement('span');
172
+ status.className = 'mk-sr-only';
173
+ status.setAttribute('role', 'status');
174
+ if (!adopted) root.append(viewport);
175
+ root.append(empty, pill, status);
176
+ scope.add(() => {
177
+ for (const effect of effects) effect.kill();
178
+ effects.clear();
179
+ gsap.killTweensOf([viewport, cursor, pill]);
180
+ for (const element of drawn.values()) element.remove();
181
+ for (const part of [cursor, foot, empty, pill, status]) part.remove();
182
+ // What the framework rendered stays; only what the engine put on it goes.
183
+ if (adopted) {
184
+ viewport.removeAttribute('aria-activedescendant');
185
+ if (spacerStyle === null) spacer.removeAttribute('style');
186
+ else spacer.setAttribute('style', spacerStyle);
187
+ } else viewport.remove();
188
+ root.append(...holder.childNodes);
189
+ });
190
+ scope.attr(root, 'data-mk-ready', '');
191
+
192
+ // ── Drawing ──────────────────────────────────────────────────────────────
193
+ const drawn = new Map<number, HTMLElement>();
194
+ const pool: HTMLElement[] = [];
195
+ const shows = new WeakMap<HTMLElement, T>();
196
+ const observed = new WeakSet<HTMLElement>();
197
+ let refill = false;
198
+ let firstPaint = true;
199
+ let tweening = false;
200
+ let flashIndex = -1;
201
+
202
+ const fill = (element: HTMLElement, item: T, index: number) => {
203
+ element.replaceChildren();
204
+ if (renderItem) renderItem(item, index, element);
205
+ else element.textContent = textOf(item);
206
+ };
207
+ const rows = () => [...spacer.querySelectorAll<HTMLElement>(':scope > [data-mk-vl-item]')];
208
+
209
+ let told: VirtualRange | null = null;
210
+ /** Vanilla: recycle the elements of the rows that left for the rows that came. Frameworks: tell them which rows. */
211
+ const draw = () => {
212
+ if (external) {
213
+ if (told?.start === range.start && told.end === range.end) return;
214
+ told = { ...range };
215
+ options.onRange?.({ ...range });
216
+ return;
217
+ }
218
+ for (const [index, element] of drawn) {
219
+ if (index >= range.start && index < range.end && index < items.length) continue;
220
+ drawn.delete(index);
221
+ element.remove();
222
+ pool.push(element);
223
+ }
224
+ for (let index = range.start; index < range.end; index++) {
225
+ const item = items[index]!;
226
+ let element = drawn.get(index);
227
+ if (!element) {
228
+ element = pool.pop() ?? document.createElement('div');
229
+ element.dataset.mkVlItem = '';
230
+ drawn.set(index, element);
231
+ spacer.append(element);
232
+ }
233
+ if (refill || element.dataset.index !== String(index) || shows.get(element) !== item) {
234
+ element.dataset.index = String(index);
235
+ shows.set(element, item);
236
+ fill(element, item, index);
237
+ }
238
+ }
239
+ refill = false;
240
+ };
241
+
242
+ let resizeObserver: ResizeObserver | null = null;
243
+ /** Place the drawn rows, give them their roles and states, and measure them. */
244
+ const paint = () => {
245
+ let changed = false;
246
+ for (const element of rows()) {
247
+ const index = Number(element.dataset.index);
248
+ if (!Number.isInteger(index) || index < 0 || index >= items.length) continue;
249
+ const item = items[index]!;
250
+ element.id = `${uid}-${index}`;
251
+ element.setAttribute('role', selectable ? 'option' : 'listitem');
252
+ element.setAttribute('aria-setsize', String(items.length));
253
+ element.setAttribute('aria-posinset', String(index + 1));
254
+ if (selectable) element.setAttribute('aria-selected', String(selected.has(keyOf(item, index))));
255
+ element.toggleAttribute('data-mk-vl-active', index === active);
256
+ if (index !== flashIndex) element.removeAttribute('data-mk-vl-flash');
257
+ if (fixed) element.style.height = `${fixed}px`;
258
+ else {
259
+ if (resizeObserver && !observed.has(element)) {
260
+ observed.add(element);
261
+ resizeObserver.observe(element);
262
+ }
263
+ const height = element.getBoundingClientRect().height;
264
+ if (height > 0 && sizes.set(index, height)) {
265
+ measured.set(keyOf(item, index), height);
266
+ changed = true;
267
+ }
268
+ }
269
+ }
270
+ // Placed with top: transforms stay free for the rows' own motion (entrances clear them).
271
+ for (const element of rows()) {
272
+ const index = Number(element.dataset.index);
273
+ if (Number.isInteger(index) && index < items.length) element.style.top = `${sizes.offset(index)}px`;
274
+ }
275
+ return changed;
276
+ };
277
+
278
+ const viewHeight = () => viewport.clientHeight || estimate * 10;
279
+ const layoutSpacer = () => {
280
+ const total = sizes.total();
281
+ spacer.style.height = `${total + (foot.hidden ? 0 : FOOT_HEIGHT)}px`;
282
+ foot.style.top = `${total}px`;
283
+ empty.hidden = items.length > 0 || !foot.hidden;
284
+ };
285
+
286
+ const update = (force = false) => {
287
+ if (scope.disposed) return;
288
+ // Measuring rows above the first one in view must not move what is in view.
289
+ const top = viewport.scrollTop;
290
+ const first = sizes.indexAt(top);
291
+ const within = top - sizes.offset(first);
292
+ const next = sizes.range(top, viewHeight(), overscan);
293
+ if (force || next.start !== range.start || next.end !== range.end) {
294
+ range = next;
295
+ draw();
296
+ }
297
+ const changed = paint();
298
+ layoutSpacer();
299
+ if (changed) {
300
+ if (!tweening && items.length && Math.abs(sizes.offset(first) + within - viewport.scrollTop) > 0.5) viewport.scrollTop = sizes.offset(first) + within;
301
+ // Measured rows may change what is in view.
302
+ const after = sizes.range(viewport.scrollTop, viewHeight(), overscan);
303
+ if (after.start !== range.start || after.end !== range.end) {
304
+ range = after;
305
+ draw();
306
+ paint();
307
+ layoutSpacer();
308
+ }
309
+ }
310
+ placeCursor(false);
311
+ if (active >= 0) {
312
+ const element = rows().find((row) => Number(row.dataset.index) === active);
313
+ if (element) viewport.setAttribute('aria-activedescendant', element.id);
314
+ else viewport.removeAttribute('aria-activedescendant');
315
+ }
316
+ if (firstPaint && rows().length) {
317
+ firstPaint = false;
318
+ if (!reduced()) keep(blurIn(rows().slice(0, 16), { from: 'rise', stagger: 0.025, duration: 0.4 }));
319
+ }
320
+ reachEnd();
321
+ };
322
+ let pending: (() => void) | null = null;
323
+ const schedule = () => {
324
+ if (pending) return;
325
+ pending = requestFrame(() => {
326
+ pending = null;
327
+ update();
328
+ });
329
+ };
330
+ scope.add(() => pending?.());
331
+
332
+ if (typeof ResizeObserver === 'function') {
333
+ resizeObserver = scope.observe(new ResizeObserver(() => schedule()));
334
+ resizeObserver.observe(viewport);
335
+ }
336
+
337
+ // ── The position pill ────────────────────────────────────────────────────
338
+ let pillTimer: ReturnType<typeof setTimeout> | undefined;
339
+ const hidePill = () => {
340
+ if (pill.hidden) return;
341
+ if (reduced()) {
342
+ pill.hidden = true;
343
+ return;
344
+ }
345
+ keep(gsap.to(pill, { opacity: 0, x: 8, filter: 'blur(6px)', duration: 0.3, ease: 'power2.in', onComplete: () => void (pill.hidden = true) }));
346
+ };
347
+ const showPill = () => {
348
+ if (items.length < 40 || viewport.scrollHeight <= viewport.clientHeight + 1) return;
349
+ const first = Math.min(items.length - 1, sizes.indexAt(viewport.scrollTop + 1));
350
+ pill.textContent = `${number.format(first + 1)} de ${number.format(items.length)}`;
351
+ const wasHidden = pill.hidden;
352
+ pill.hidden = false;
353
+ const max = viewport.scrollHeight - viewport.clientHeight;
354
+ const room = Math.max(0, viewport.clientHeight - pill.offsetHeight - 16);
355
+ pill.style.top = `${viewport.offsetTop + 8 + (max > 0 ? viewport.scrollTop / max : 0) * room}px`;
356
+ if (wasHidden && !reduced()) {
357
+ gsap.killTweensOf(pill);
358
+ keep(gsap.fromTo(pill, { opacity: 0, x: 8, filter: 'blur(6px)' }, { opacity: 1, x: 0, filter: 'blur(0px)', duration: 0.25, ease: 'power2.out', clearProps: 'filter' }));
359
+ } else if (wasHidden) gsap.set(pill, { opacity: 1, x: 0 });
360
+ else if (Number(gsap.getProperty(pill, 'opacity')) < 1) {
361
+ gsap.killTweensOf(pill);
362
+ gsap.set(pill, { opacity: 1, x: 0, filter: 'none' });
363
+ }
364
+ clearTimeout(pillTimer);
365
+ pillTimer = setTimeout(hidePill, 900);
366
+ };
367
+ scope.add(() => clearTimeout(pillTimer));
368
+ scope.on(viewport, 'scroll', () => {
369
+ schedule();
370
+ showPill();
371
+ }, { passive: true });
372
+
373
+ // ── Going to a row ───────────────────────────────────────────────────────
374
+ const flash = (index: number) => {
375
+ flashIndex = index;
376
+ const element = rows().find((row) => Number(row.dataset.index) === index);
377
+ if (!element) return;
378
+ element.removeAttribute('data-mk-vl-flash');
379
+ // Restart the highlight.
380
+ void element.offsetWidth;
381
+ element.setAttribute('data-mk-vl-flash', '');
382
+ scope.timeout(() => {
383
+ if (flashIndex === index) flashIndex = -1;
384
+ element.removeAttribute('data-mk-vl-flash');
385
+ }, 1300);
386
+ };
387
+ const targetTop = (index: number, align: VirtualScrollOptions['align']) => {
388
+ const top = sizes.offset(index);
389
+ const height = sizes.get(index);
390
+ const view = viewport.clientHeight;
391
+ const max = Math.max(0, sizes.total() + (foot.hidden ? 0 : FOOT_HEIGHT) - view);
392
+ let target: number;
393
+ if (align === 'start') target = top;
394
+ else if (align === 'end') target = top + height - view;
395
+ else if (align === 'center') target = top + height / 2 - view / 2;
396
+ else if (top < viewport.scrollTop) target = top;
397
+ else if (top + height > viewport.scrollTop + view) target = top + height - view;
398
+ else target = viewport.scrollTop;
399
+ return Math.max(0, Math.min(max, target));
400
+ };
401
+ const scrollToIndex = (index: number, { align = 'auto', animate = true }: VirtualScrollOptions = {}) => {
402
+ if (!items.length || scope.disposed) return;
403
+ const at = Math.max(0, Math.min(items.length - 1, Math.round(index)));
404
+ gsap.killTweensOf(viewport);
405
+ tweening = false;
406
+ const target = targetTop(at, align);
407
+ const distance = target - viewport.scrollTop;
408
+ if (!animate || reduced() || Math.abs(distance) < 2) {
409
+ viewport.scrollTop = target;
410
+ update();
411
+ flash(at);
412
+ return;
413
+ }
414
+ // A long way: most of it at once, the last screens gliding.
415
+ const screen = viewport.clientHeight || estimate * 10;
416
+ if (Math.abs(distance) > screen * 3) {
417
+ viewport.scrollTop = target - Math.sign(distance) * screen * 2;
418
+ update();
419
+ }
420
+ tweening = true;
421
+ keep(
422
+ gsap.to(viewport, {
423
+ scrollTop: target,
424
+ duration: 0.6,
425
+ ease: EASE.signature,
426
+ onComplete: () => {
427
+ tweening = false;
428
+ // Rows measured on the way may have moved it.
429
+ const settled = targetTop(at, align);
430
+ if (Math.abs(settled - viewport.scrollTop) > 1) viewport.scrollTop = settled;
431
+ update();
432
+ flash(at);
433
+ },
434
+ }),
435
+ );
436
+ };
437
+
438
+ // ── Choosing ─────────────────────────────────────────────────────────────
439
+ const placeCursor = (animate: boolean) => {
440
+ if (!selectable || active < 0 || active >= items.length) {
441
+ cursor.style.opacity = '0';
442
+ return;
443
+ }
444
+ const target = { y: sizes.offset(active), height: sizes.get(active) };
445
+ cursor.style.opacity = '';
446
+ if (animate && !reduced()) {
447
+ keep(gsap.to(cursor, { ...target, duration: 0.25, ease: EASE.swatch, overwrite: 'auto' }));
448
+ return;
449
+ }
450
+ // A glide on its way ends where it should.
451
+ if (gsap.isTweening(cursor)) return;
452
+ gsap.set(cursor, target);
453
+ };
454
+ const announce = () => {
455
+ const keys = [...selected];
456
+ const chosen = items.filter((item, i) => selected.has(keyOf(item, i)));
457
+ options.onSelect?.(keys, chosen);
458
+ root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { keys, items: chosen } }));
459
+ };
460
+ const choose = (index: number, mode: 'only' | 'toggle' | 'range') => {
461
+ if (!selectable || index < 0 || index >= items.length) return;
462
+ const key = keyOf(items[index]!, index);
463
+ const before = selected.has(key);
464
+ const previous = selected;
465
+ if (selectable === 'single' || mode === 'only') selected = new Set([key]);
466
+ else if (mode === 'toggle') {
467
+ selected = new Set(selected);
468
+ if (before) selected.delete(key);
469
+ else selected.add(key);
470
+ } else {
471
+ const from = anchor < 0 ? index : anchor;
472
+ selected = new Set(selected);
473
+ for (let i = Math.min(from, index); i <= Math.max(from, index); i++) selected.add(keyOf(items[i]!, i));
474
+ }
475
+ if (mode !== 'range') anchor = index;
476
+ paint();
477
+ const element = rows().find((row) => Number(row.dataset.index) === index);
478
+ // The check pops on the row that was just chosen.
479
+ if (element && selected.has(key) && !before) {
480
+ element.removeAttribute('data-mk-vl-just');
481
+ void element.offsetWidth;
482
+ element.setAttribute('data-mk-vl-just', '');
483
+ scope.timeout(() => element.removeAttribute('data-mk-vl-just'), 500);
484
+ }
485
+ status.textContent = `${textOf(items[index]!)} ${selected.has(key) ? 'seleccionado' : 'sin seleccionar'}`;
486
+ if (previous.size !== selected.size || [...selected].some((each) => !previous.has(each))) announce();
487
+ };
488
+ const setActive = (index: number, animate: boolean) => {
489
+ if (!items.length) return;
490
+ const at = Math.max(0, Math.min(items.length - 1, index));
491
+ const far = active < 0 || Math.abs(at - active) > 1;
492
+ active = at;
493
+ // The ring lives among the rows, so scrolling does not move it: it can glide first.
494
+ placeCursor(animate && !far);
495
+ // Keep it in view (no glide: the keyboard repeats fast).
496
+ const target = targetTop(at, 'auto');
497
+ if (Math.abs(target - viewport.scrollTop) > 0.5) viewport.scrollTop = target;
498
+ update();
499
+ };
500
+
501
+ scope.on(viewport, 'click', (event) => {
502
+ const mouse = event as MouseEvent;
503
+ const element = (mouse.target as Element | null)?.closest<HTMLElement>('[data-mk-vl-item]');
504
+ if (!element || !spacer.contains(element) || !selectable) return;
505
+ const index = Number(element.dataset.index);
506
+ active = index;
507
+ placeCursor(true);
508
+ if (selectable === 'multiple' && mouse.shiftKey) choose(index, 'range');
509
+ else choose(index, selectable === 'multiple' ? 'toggle' : 'only');
510
+ update();
511
+ });
512
+ scope.on(viewport, 'dblclick', (event) => {
513
+ const element = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-vl-item]');
514
+ if (!element || !spacer.contains(element)) return;
515
+ const index = Number(element.dataset.index);
516
+ options.onActivate?.(items[index]!, index);
517
+ });
518
+
519
+ let typed = '';
520
+ let typedAt = 0;
521
+ scope.on(viewport, 'keydown', (event) => {
522
+ const key = event as KeyboardEvent;
523
+ if (!selectable || !items.length || key.altKey || key.ctrlKey || key.metaKey) return;
524
+ const page = Math.max(1, Math.floor(viewHeight() / estimate) - 1);
525
+ const from = active < 0 ? Math.min(items.length - 1, sizes.indexAt(viewport.scrollTop + 1)) : active;
526
+ let next: number | null = null;
527
+ switch (key.key) {
528
+ case 'ArrowDown':
529
+ next = active < 0 ? from : from + 1;
530
+ break;
531
+ case 'ArrowUp':
532
+ next = active < 0 ? from : from - 1;
533
+ break;
534
+ case 'PageDown':
535
+ next = from + page;
536
+ break;
537
+ case 'PageUp':
538
+ next = from - page;
539
+ break;
540
+ case 'Home':
541
+ next = 0;
542
+ break;
543
+ case 'End':
544
+ next = items.length - 1;
545
+ break;
546
+ case 'Enter':
547
+ if (active < 0) return;
548
+ key.preventDefault();
549
+ choose(active, selectable === 'multiple' ? 'toggle' : 'only');
550
+ options.onActivate?.(items[active]!, active);
551
+ return;
552
+ case ' ':
553
+ if (active < 0) return;
554
+ key.preventDefault();
555
+ choose(active, selectable === 'multiple' ? 'toggle' : 'only');
556
+ return;
557
+ }
558
+ if (next !== null) {
559
+ key.preventDefault();
560
+ setActive(next, true);
561
+ if (key.shiftKey && selectable === 'multiple') choose(active, 'range');
562
+ return;
563
+ }
564
+ // Typing jumps to the next row that starts with what was typed.
565
+ if (key.key.length === 1 && /\S/.test(key.key)) {
566
+ const now = Date.now();
567
+ typed = now - typedAt > 600 ? key.key.toLowerCase() : typed + key.key.toLowerCase();
568
+ typedAt = now;
569
+ const start = Math.max(0, active) + (typed.length === 1 ? 1 : 0);
570
+ for (let step = 0; step < items.length; step++) {
571
+ const index = (start + step) % items.length;
572
+ if (textOf(items[index]!).toLowerCase().startsWith(typed)) {
573
+ setActive(index, false);
574
+ break;
575
+ }
576
+ }
577
+ }
578
+ });
579
+
580
+ // ── More at the end ──────────────────────────────────────────────────────
581
+ let loadingMore = false;
582
+ let finished = false;
583
+ const append = (more: T[]) => {
584
+ if (!more.length) return;
585
+ const before = items.length;
586
+ items = [...items, ...more];
587
+ sizes = createSizes(heights());
588
+ update(true);
589
+ if (!reduced()) keep(blurIn(rows().filter((row) => Number(row.dataset.index) >= before), { from: 'rise', stagger: 0.03, duration: 0.4 }));
590
+ };
591
+ const reachEnd = () => {
592
+ if (!options.onEndReached || loadingMore || finished || range.end < items.length - Math.max(2, overscan)) return;
593
+ loadingMore = true;
594
+ foot.hidden = false;
595
+ foot.removeAttribute('data-mk-vl-end');
596
+ foot.setAttribute('aria-busy', 'true');
597
+ foot.textContent = 'Cargando…';
598
+ layoutSpacer();
599
+ Promise.resolve()
600
+ .then(() => options.onEndReached!())
601
+ .then((more) => {
602
+ if (scope.disposed) return;
603
+ loadingMore = false;
604
+ foot.removeAttribute('aria-busy');
605
+ // The app added them itself (setItems).
606
+ if (!more) {
607
+ foot.hidden = true;
608
+ layoutSpacer();
609
+ return;
610
+ }
611
+ if (!more.length) {
612
+ finished = true;
613
+ foot.setAttribute('data-mk-vl-end', '');
614
+ foot.textContent = items.length ? 'No hay más elementos' : '';
615
+ foot.hidden = !items.length;
616
+ status.textContent = 'No hay más elementos';
617
+ layoutSpacer();
618
+ return;
619
+ }
620
+ foot.hidden = true;
621
+ status.textContent = `${number.format(more.length)} más`;
622
+ append(more);
623
+ })
624
+ .catch(() => {
625
+ if (scope.disposed) return;
626
+ loadingMore = false;
627
+ finished = true;
628
+ foot.removeAttribute('aria-busy');
629
+ foot.textContent = 'No se pudo cargar';
630
+ });
631
+ };
632
+
633
+ update(true);
634
+
635
+ return {
636
+ setItems(next) {
637
+ if (scope.disposed) return;
638
+ const changed = next !== items;
639
+ items = [...next];
640
+ sizes = createSizes(heights());
641
+ if (active >= items.length) active = items.length - 1;
642
+ finished = false;
643
+ if (!loadingMore) foot.hidden = true;
644
+ refill = true;
645
+ update(true);
646
+ if (changed && !reduced()) keep(blurIn(rows().slice(0, 12), { from: 'soft', stagger: 0.015, duration: 0.3 }));
647
+ },
648
+ append(more) {
649
+ if (!scope.disposed) append(more);
650
+ },
651
+ scrollToIndex,
652
+ refresh() {
653
+ if (scope.disposed) return;
654
+ refill = true;
655
+ update(!external);
656
+ },
657
+ select(keys) {
658
+ selected = new Set(keys);
659
+ paint();
660
+ },
661
+ setRenderer(next) {
662
+ renderItem = next;
663
+ refill = true;
664
+ update(true);
665
+ },
666
+ get items() {
667
+ return [...items];
668
+ },
669
+ get range() {
670
+ return { ...range };
671
+ },
672
+ get selected() {
673
+ return [...selected];
674
+ },
675
+ container: spacer,
676
+ destroy: () => scope.dispose(),
677
+ };
678
+ }