@react-x11/components 0.3.0 → 0.5.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 (314) hide show
  1. package/README.md +131 -4
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +11 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +26 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/qml/containers.d.ts +3 -0
  140. package/dist/qml/containers.d.ts.map +1 -0
  141. package/dist/qml/containers.js +331 -0
  142. package/dist/qml/containers.js.map +1 -0
  143. package/dist/qml/controls.d.ts +12 -0
  144. package/dist/qml/controls.d.ts.map +1 -0
  145. package/dist/qml/controls.js +21 -0
  146. package/dist/qml/controls.js.map +1 -0
  147. package/dist/qml/globals.d.ts +6 -0
  148. package/dist/qml/globals.d.ts.map +1 -0
  149. package/dist/qml/globals.js +25 -0
  150. package/dist/qml/globals.js.map +1 -0
  151. package/dist/qml/index.d.ts +15 -0
  152. package/dist/qml/index.d.ts.map +1 -0
  153. package/dist/qml/index.js +25 -0
  154. package/dist/qml/index.js.map +1 -0
  155. package/dist/qml/interaction.d.ts +12 -0
  156. package/dist/qml/interaction.d.ts.map +1 -0
  157. package/dist/qml/interaction.js +246 -0
  158. package/dist/qml/interaction.js.map +1 -0
  159. package/dist/qml/ir.d.ts +106 -0
  160. package/dist/qml/ir.d.ts.map +1 -0
  161. package/dist/qml/ir.js +7 -0
  162. package/dist/qml/ir.js.map +1 -0
  163. package/dist/qml/layouts.d.ts +3 -0
  164. package/dist/qml/layouts.d.ts.map +1 -0
  165. package/dist/qml/layouts.js +122 -0
  166. package/dist/qml/layouts.js.map +1 -0
  167. package/dist/qml/models.d.ts +14 -0
  168. package/dist/qml/models.d.ts.map +1 -0
  169. package/dist/qml/models.js +80 -0
  170. package/dist/qml/models.js.map +1 -0
  171. package/dist/qml/objects.d.ts +228 -0
  172. package/dist/qml/objects.d.ts.map +1 -0
  173. package/dist/qml/objects.js +1103 -0
  174. package/dist/qml/objects.js.map +1 -0
  175. package/dist/qml/parse.d.ts +10 -0
  176. package/dist/qml/parse.d.ts.map +1 -0
  177. package/dist/qml/parse.js +607 -0
  178. package/dist/qml/parse.js.map +1 -0
  179. package/dist/qml/qtquick.d.ts +7 -0
  180. package/dist/qml/qtquick.d.ts.map +1 -0
  181. package/dist/qml/qtquick.js +347 -0
  182. package/dist/qml/qtquick.js.map +1 -0
  183. package/dist/qml/react.d.ts +83 -0
  184. package/dist/qml/react.d.ts.map +1 -0
  185. package/dist/qml/react.js +323 -0
  186. package/dist/qml/react.js.map +1 -0
  187. package/dist/qml/resolver.d.ts +38 -0
  188. package/dist/qml/resolver.d.ts.map +1 -0
  189. package/dist/qml/resolver.js +64 -0
  190. package/dist/qml/resolver.js.map +1 -0
  191. package/dist/qml/slots.d.ts +65 -0
  192. package/dist/qml/slots.d.ts.map +1 -0
  193. package/dist/qml/slots.js +230 -0
  194. package/dist/qml/slots.js.map +1 -0
  195. package/dist/qml/states.d.ts +5 -0
  196. package/dist/qml/states.d.ts.map +1 -0
  197. package/dist/qml/states.js +276 -0
  198. package/dist/qml/states.js.map +1 -0
  199. package/dist/qml/view-utils.d.ts +42 -0
  200. package/dist/qml/view-utils.d.ts.map +1 -0
  201. package/dist/qml/view-utils.js +121 -0
  202. package/dist/qml/view-utils.js.map +1 -0
  203. package/dist/reorder/hx.d.ts +18 -0
  204. package/dist/reorder/hx.d.ts.map +1 -0
  205. package/dist/reorder/hx.js +10 -0
  206. package/dist/reorder/hx.js.map +1 -0
  207. package/dist/reorder/index.d.ts +314 -0
  208. package/dist/reorder/index.d.ts.map +1 -0
  209. package/dist/reorder/index.js +1539 -0
  210. package/dist/reorder/index.js.map +1 -0
  211. package/dist/reorder/model.d.ts +107 -0
  212. package/dist/reorder/model.d.ts.map +1 -0
  213. package/dist/reorder/model.js +162 -0
  214. package/dist/reorder/model.js.map +1 -0
  215. package/dist/richtext/node.d.ts +15 -5
  216. package/dist/richtext/node.d.ts.map +1 -1
  217. package/dist/richtext/node.js +40 -11
  218. package/dist/richtext/node.js.map +1 -1
  219. package/dist/richtext/runs.d.ts +35 -13
  220. package/dist/richtext/runs.d.ts.map +1 -1
  221. package/dist/richtext/runs.js +83 -27
  222. package/dist/richtext/runs.js.map +1 -1
  223. package/dist/tabs/hx.d.ts +18 -0
  224. package/dist/tabs/hx.d.ts.map +1 -0
  225. package/dist/tabs/hx.js +10 -0
  226. package/dist/tabs/hx.js.map +1 -0
  227. package/dist/tabs/index.d.ts +160 -0
  228. package/dist/tabs/index.d.ts.map +1 -0
  229. package/dist/tabs/index.js +1265 -0
  230. package/dist/tabs/index.js.map +1 -0
  231. package/dist/terminal/vt/fonts.d.ts +45 -15
  232. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  233. package/dist/terminal/vt/fonts.js +89 -19
  234. package/dist/terminal/vt/fonts.js.map +1 -1
  235. package/dist/terminal/vt/index.d.ts +1 -1
  236. package/dist/terminal/vt/index.d.ts.map +1 -1
  237. package/dist/terminal/vt/index.js +3 -3
  238. package/dist/terminal/vt/index.js.map +1 -1
  239. package/dist/terminal/vt/node.d.ts +42 -0
  240. package/dist/terminal/vt/node.d.ts.map +1 -1
  241. package/dist/terminal/vt/node.js +95 -11
  242. package/dist/terminal/vt/node.js.map +1 -1
  243. package/dist/terminal/vt/pty.d.ts +16 -0
  244. package/dist/terminal/vt/pty.d.ts.map +1 -1
  245. package/dist/terminal/vt/pty.js +186 -0
  246. package/dist/terminal/vt/pty.js.map +1 -1
  247. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  248. package/dist/terminal/vt/renderer.js +13 -3
  249. package/dist/terminal/vt/renderer.js.map +1 -1
  250. package/package.json +23 -3
  251. package/src/charts/components.ts +14 -7
  252. package/src/charts/node.ts +81 -26
  253. package/src/charts/render.ts +16 -6
  254. package/src/code-editor/node.ts +147 -53
  255. package/src/flow/draw.ts +104 -35
  256. package/src/flow/index.ts +33 -13
  257. package/src/flow/model.ts +4 -2
  258. package/src/flow/node.ts +158 -59
  259. package/src/flow/types.ts +46 -21
  260. package/src/formula/index.ts +4 -2
  261. package/src/formula/node.ts +24 -5
  262. package/src/html/controls.ts +4 -1
  263. package/src/html/css/cascade.ts +18 -7
  264. package/src/html/css/parse.ts +46 -21
  265. package/src/html/css/style.ts +6 -1
  266. package/src/html/css/values.ts +25 -9
  267. package/src/html/index.ts +1 -0
  268. package/src/html/layout/boxes.ts +19 -9
  269. package/src/html/layout/inline.ts +23 -1
  270. package/src/html/node.ts +110 -17
  271. package/src/html/paint.ts +17 -2
  272. package/src/index.ts +142 -0
  273. package/src/maps/clip.ts +184 -0
  274. package/src/maps/gzip.ts +66 -0
  275. package/src/maps/index.ts +316 -0
  276. package/src/maps/labels.ts +442 -0
  277. package/src/maps/mvt.ts +857 -0
  278. package/src/maps/node.ts +1819 -0
  279. package/src/maps/overlay.ts +639 -0
  280. package/src/maps/paint.ts +978 -0
  281. package/src/maps/proj.ts +641 -0
  282. package/src/maps/sources.ts +425 -0
  283. package/src/maps/style.ts +309 -0
  284. package/src/maps/styles.ts +1040 -0
  285. package/src/maps/tiles.ts +676 -0
  286. package/src/maps/types.ts +260 -0
  287. package/src/qml/containers.tsx +380 -0
  288. package/src/qml/controls.tsx +47 -0
  289. package/src/qml/globals.ts +33 -0
  290. package/src/qml/index.ts +67 -0
  291. package/src/qml/interaction.tsx +317 -0
  292. package/src/qml/ir.ts +80 -0
  293. package/src/qml/layouts.tsx +141 -0
  294. package/src/qml/models.ts +101 -0
  295. package/src/qml/objects.ts +1431 -0
  296. package/src/qml/parse.ts +624 -0
  297. package/src/qml/qtquick.tsx +413 -0
  298. package/src/qml/react.tsx +454 -0
  299. package/src/qml/resolver.ts +99 -0
  300. package/src/qml/slots.ts +249 -0
  301. package/src/qml/states.ts +318 -0
  302. package/src/qml/view-utils.ts +140 -0
  303. package/src/reorder/hx.ts +31 -0
  304. package/src/reorder/index.ts +2223 -0
  305. package/src/reorder/model.ts +232 -0
  306. package/src/richtext/node.ts +49 -14
  307. package/src/richtext/runs.ts +115 -30
  308. package/src/tabs/hx.ts +31 -0
  309. package/src/tabs/index.ts +1686 -0
  310. package/src/terminal/vt/fonts.ts +128 -36
  311. package/src/terminal/vt/index.ts +4 -2
  312. package/src/terminal/vt/node.ts +105 -12
  313. package/src/terminal/vt/pty.ts +243 -0
  314. package/src/terminal/vt/renderer.ts +13 -3
@@ -0,0 +1,2223 @@
1
+ // <ReorderList> — a list the user reorders by dragging its items, or by
2
+ // lifting one from the keyboard and walking it with the arrows: a todo list,
3
+ // a playlist, a settings dialog's columns, a kanban board when several lists
4
+ // share a `group`.
5
+ //
6
+ // This is the **sortable layer over react-x11's own drag and drop**, the
7
+ // position `@dnd-kit/sortable` holds over `@dnd-kit/core` — except that the
8
+ // engine is core's (docs/drag-and-drop.md there): the 4px threshold, click
9
+ // suppression, `:dragging`/`:drag-over`, the in-app payload arriving by
10
+ // reference on `e.items`, edge auto-scroll of any scroll container, the
11
+ // `<popup dragPreview>` a preview is drawn in, and the promotion of the same
12
+ // drag to XDND — or, on the cocoa backend, to NSDragging — when the pointer
13
+ // leaves the app. None of that is here. What is here is what a sortable
14
+ // preset is: the insertion arithmetic (`./model.ts`), the indicator, the
15
+ // keyboard model, and an event vocabulary that speaks list-and-index rather
16
+ // than node-and-pointer.
17
+ //
18
+ // `docs/prd-reorder.md` is the design record — the survey of dnd-kit,
19
+ // hello-pangea, pragmatic-drag-and-drop, React Aria and Framer's Reorder,
20
+ // and what each settled. The decisions a reader of this file would not
21
+ // guess, each with its paragraph there:
22
+ //
23
+ // - **The list is the only drop target; items are not.** One `dropAccept`
24
+ // on the root, one `onDragOver` that reads every item's rectangle and
25
+ // finds the closest edge, and the two items either side of the slot are
26
+ // told to draw or clear their indicator through a per-item setter — so a
27
+ // pointer motion re-renders at most two items and never the list.
28
+ // - **Membership is in the payload's type name.** Lists in a group accept
29
+ // `application/x-react-x11-reorder;scope=group:<name>`; a list on its own
30
+ // accepts `…scope=list:<its uid>`. That keeps "who takes this drop" a
31
+ // declarative fact core answers from `dropAccept` data with no React in
32
+ // the loop — and it has to be, because `e.accept()` in `onDragOver` can
33
+ // only override a node whose `dropAccept` already matched. `canDrop` is
34
+ // the *dynamic* half, and it works the other way round: it can only
35
+ // refuse (`ev.reject()`), which is a decision core lets a handler make.
36
+ // - **A move between lists is two local handlers**, `onInsert` on the list
37
+ // it landed in and `onRemove` on the list it left — React Aria's model,
38
+ // and the order core already fires them in (`onDrop`, then `onDragEnd`
39
+ // "always last"). The target writes where the item landed onto the live
40
+ // payload object, which is how the source can say `to`.
41
+ // - **The hover channel is module state, and it has to be.** `onDragOver`
42
+ // hands the target a `DragEvent`, which carries no `items` — only the
43
+ // drop does — so a target physically cannot write on the payload while
44
+ // the pointer is merely over it. `activeDrag` below is the one-gesture
45
+ // scratchpad the target writes and the source reads in its own `onDrag`,
46
+ // which is what makes `onDragUpdate` able to say *which list* the pointer
47
+ // is over. It is one object for one gesture, never read at import time.
48
+ // - **The indicator, not the slide.** Neighbours do not glide out of the
49
+ // way: this renderer has no transform, and a line at the closest edge
50
+ // answers the same question for a rectangle per item. The one thing that
51
+ // does move is the drop: `dropAnimation` flies a copy of the item from
52
+ // where the pointer let go to where the item landed. That copy is a box
53
+ // **inside the item**, not the ghost popup carried on — a popup outlives
54
+ // the gesture as a real window over the list, and the press that follows
55
+ // a drop by less than the animation lands on it instead of on the list.
56
+ // An absolutely positioned box with `pointerEvents: 'none'` cannot take
57
+ // an input the list should have had.
58
+ // - **The ghost popup is `transparent`.** It is a real window, and the card
59
+ // inside it is rounded, so on an opaque popup the four corners the radius
60
+ // gives up show the window's own ground — white on a light theme, near
61
+ // black on a dark one. Where no compositor runs the window fills itself
62
+ // square, which is what an opaque one looked like anyway.
63
+ // - **A drag's events bubble, so an item must check the source is its own.**
64
+ // `DragStart`, `onDrag` and `onDragEnd` dispatch capture → target →
65
+ // bubble like every other event, which means a list item that *contains*
66
+ // the dragged one — a board's column, holding the list the card is in —
67
+ // sees them all, and `useDragSource` inside it dutifully sets a position
68
+ // and would draw a second ghost. Worse, its handlers would claim the
69
+ // gesture: a second `dragStarted` on the outer list, and `activeDrag`
70
+ // overwritten with the column's payload, so the hover channel described
71
+ // the wrong item. Every source callback here therefore begins by asking
72
+ // whether the event's target is this item's own drag source (itself, or
73
+ // its handle) and returns if not. `preventDefault` is not the tool for
74
+ // that: on this event it cancels the whole drag.
75
+ // - **An in-window ghost lifts the whole path, not just its item.**
76
+ // `zIndex` sorts a node among its *siblings* — `paintOrder()` is per
77
+ // node, and there is no stacking context to escape — so a ghost lifted
78
+ // inside its item paints under the next item, its list under the next
79
+ // list, its column under the next column. Every `<ReorderList>` and
80
+ // `<ReorderItem>` therefore asks, for the length of the gesture, whether
81
+ // the drag is inside it (`node.contains(activeDrag.source)`) and lifts
82
+ // itself if so: a board's card, its column and the board all come
83
+ // forward together. They learn about it by subscribing, because a list
84
+ // two levels up has nothing to re-render it — its children are the
85
+ // application's own elements, unchanged.
86
+ // What that cannot reach is content outside the outermost list; a popup
87
+ // ghost has no such limit, which is why `'auto'` is one. (It was the
88
+ // in-window box on the cocoa backend for two releases, while a preview
89
+ // window there stopped the drop reaching the list — react-x11#488, fixed
90
+ // in 2.8.2 by giving the preview `ignoresMouseEvents` so the pointer
91
+ // passes through it.)
92
+ // - **Every item (or its handle) is a tab stop.** An item is arbitrary
93
+ // content, often with controls of its own; the Tree/Table model of one
94
+ // tab stop with a cursor would leave a button inside a card unreachable.
95
+ // - **A press on a control inside an item is not a drag.** Core arms a drag
96
+ // from the nearest draggable *ancestor* of whatever was pressed, so a
97
+ // press on a `<textinput>` in a card would drag the card after 4px. The
98
+ // item records what the press landed on and cancels the drag at the
99
+ // threshold — `onDragStart`'s `preventDefault()`, which core documents as
100
+ // "the gesture continues as ordinary mouse events". `dragFromInteractive`
101
+ // turns it off; a `<ReorderHandle>` makes the question moot.
102
+ //
103
+ // It is pure composition of `<box>`, `<popup>` and core's `<Icon>`, so
104
+ // there is no `registerElement` here and no side effect at import time.
105
+ import React, {
106
+ useCallback,
107
+ useContext,
108
+ useEffect,
109
+ useLayoutEffect,
110
+ useMemo,
111
+ useRef,
112
+ useState,
113
+ } from 'react';
114
+ import type { ReactElement, ReactNode, RefObject } from 'react';
115
+ import {
116
+ Icon,
117
+ announce,
118
+ useDragSource,
119
+ useSystemAppearance,
120
+ useTheme,
121
+ } from 'react-x11';
122
+ import type {
123
+ DragEndEvent,
124
+ DragEvent,
125
+ DragSourceEvent,
126
+ DragSourceProps,
127
+ DrawnNode,
128
+ DropAccept,
129
+ DropEvent,
130
+ KeyboardEvent,
131
+ MouseEvent,
132
+ Theme,
133
+ } from 'react-x11';
134
+ import { flattenStyle } from 'react-x11/style';
135
+ import type { Style } from 'react-x11/style';
136
+ import {
137
+ XK_DOWN,
138
+ XK_END,
139
+ XK_ESCAPE,
140
+ XK_HOME,
141
+ XK_LEFT,
142
+ XK_RETURN,
143
+ XK_RIGHT,
144
+ XK_UP,
145
+ } from 'react-x11/keysyms';
146
+
147
+ import { hx } from './hx.js';
148
+ // Shared with <Tree> and <Table> — internal, deliberately not a shared
149
+ // *module*; the header of src/internal/timers.ts says why. The drop flight
150
+ // needs both: a tick after the layout that placed the item, and a clock.
151
+ import {
152
+ afterLayout,
153
+ cancelAfterLayout,
154
+ cancelLater,
155
+ later,
156
+ } from '../internal/timers.js';
157
+ import type { DelayTick, LayoutTick } from '../internal/timers.js';
158
+ import {
159
+ closestSlot,
160
+ insertManyAtSlot,
161
+ moveManyToSlot,
162
+ slotMark,
163
+ } from './model.js';
164
+ import type {
165
+ ReorderEdge,
166
+ ReorderId,
167
+ ReorderOrientation,
168
+ ReorderRect,
169
+ } from './model.js';
170
+
171
+ export {
172
+ arrayMove,
173
+ closestSlot,
174
+ insertAtSlot,
175
+ insertManyAtSlot,
176
+ isNoopSlot,
177
+ moveManyToSlot,
178
+ moveToSlot,
179
+ slotMark,
180
+ } from './model.js';
181
+ export type {
182
+ ReorderEdge,
183
+ ReorderId,
184
+ ReorderOrientation,
185
+ ReorderPoint,
186
+ ReorderRect,
187
+ ReorderSlot,
188
+ } from './model.js';
189
+
190
+ const h = React.createElement;
191
+
192
+ /** What `style` props here accept, matching the rest of the package. */
193
+ type StyleInput = Style | Style[];
194
+
195
+ /**
196
+ * The private payload type every item offers. The scope suffix is what
197
+ * `dropAccept` matches on — see the header. Exported because it is the one
198
+ * string only this component writes (the tree-shaking guard keys on it),
199
+ * and because an app's own dropzone may want to refuse or take a list item
200
+ * by name.
201
+ */
202
+ export const REORDER_TYPE = 'application/x-react-x11-reorder';
203
+
204
+ function scopedType(scope: string): string {
205
+ return `${REORDER_TYPE};scope=${scope}`;
206
+ }
207
+
208
+ /** The indicator's thickness. Centred on the edge it marks. */
209
+ const INDICATOR = 2;
210
+ /** The default handle: two columns of core's `moreVertical` dots. */
211
+ const GRIP_DOT = 10;
212
+ /**
213
+ * How much of an item, at each end, still reads as an edge once `combine`
214
+ * is on — so the middle half of an item is where a merge lands. Small
215
+ * enough that reordering past a combining list is still comfortable.
216
+ */
217
+ const COMBINE_BAND = 0.25;
218
+ /** How long the ghost takes to fly to where the item landed. */
219
+ const DROP_MS = 180;
220
+ /** One step of that flight. Not a frame clock: this is a handful of ticks
221
+ * moving one override-redirect window, and core's own animation machinery
222
+ * drives style properties rather than a popup's geometry. */
223
+ const FLIGHT_STEP_MS = 16;
224
+
225
+ /** What the focus target tells a screen reader it can do. */
226
+ const HINT =
227
+ 'Draggable. Press Space to lift, the arrow keys to move, Space to drop.';
228
+
229
+ /** Every announcement, in one place, because none of it is localised yet. */
230
+ const SAY = {
231
+ lifted: (label: string, at: number, of: number, many: number): string =>
232
+ `Lifted ${label}${many > 1 ? ` and ${many - 1} more` : ''}, ` +
233
+ `position ${at} of ${of}. ` +
234
+ 'Use the arrow keys to move, Space to drop, Escape to cancel.',
235
+ pickedUp: (label: string, at: number, of: number, many: number): string =>
236
+ `Dragging ${label}${many > 1 ? ` and ${many - 1} more` : ''}, ` +
237
+ `position ${at} of ${of}.`,
238
+ moved: (label: string, at: number, of: number): string =>
239
+ `${label} moved to position ${at} of ${of}.`,
240
+ dropped: (label: string, at: number, of: number): string =>
241
+ `${label} dropped at position ${at} of ${of}.`,
242
+ droppedInto: (label: string, list: string | undefined): string =>
243
+ `${label} dropped${list ? ` in ${list}` : ' in another list'}.`,
244
+ combined: (label: string, into: string): string =>
245
+ `${label} dropped onto ${into}.`,
246
+ cancelled: (label: string, at: number): string =>
247
+ `Move cancelled. ${label} returned to position ${at}.`,
248
+ returned: (label: string): string => `${label} returned to where it was.`,
249
+ };
250
+
251
+ // --- the events -------------------------------------------------------------
252
+
253
+ /** Where a drag is being driven from. */
254
+ export type ReorderInput = 'pointer' | 'keyboard';
255
+
256
+ /** A move within one list. `items` is the ids in their new order — what an
257
+ * app holding ids sets its state to — and `from`/`to` are what one holding
258
+ * objects hands to `arrayMove`. `ids` is everything that moved, which is
259
+ * `[id]` unless a multi-drag carried a selection. */
260
+ export interface ReorderChange {
261
+ items: ReorderId[];
262
+ id: ReorderId;
263
+ ids: ReorderId[];
264
+ from: number;
265
+ to: number;
266
+ }
267
+
268
+ /** An item from another `<ReorderList>` in the group landed here. `items` is
269
+ * this list's ids with `id` already in place; `source` is where it came
270
+ * from — hello-pangea's `{ droppableId, index }`, by other names. `action`
271
+ * is `'copy'` when the source offered a copy, and then it keeps its own. */
272
+ export interface ReorderInsert {
273
+ items: ReorderId[];
274
+ id: ReorderId;
275
+ ids: ReorderId[];
276
+ index: number;
277
+ action: 'move' | 'copy';
278
+ source: { list: string | undefined; index: number };
279
+ event: DropEvent;
280
+ }
281
+
282
+ /** An item of this list was taken by something else: another list in the
283
+ * group (`to` says which, and where), or — `to: null` — a plain
284
+ * `dropAccept` node in the app, or another application, that accepted a
285
+ * `move`. Fires only for a move; a copy leaves the item where it is. */
286
+ export interface ReorderRemove {
287
+ items: ReorderId[];
288
+ id: ReorderId;
289
+ ids: ReorderId[];
290
+ index: number;
291
+ to: { list: string | undefined; index: number } | null;
292
+ event: DragEndEvent;
293
+ }
294
+
295
+ /** A drop *onto* an item rather than between two — `combine` on the list
296
+ * that took it. The dragged item is still the source list's until the app
297
+ * removes it: a merge is the app's operation, so nothing is reordered and
298
+ * `onRemove` fires on the source exactly as it does for a cross-list move. */
299
+ export interface ReorderCombine {
300
+ id: ReorderId;
301
+ ids: ReorderId[];
302
+ /** The item it was dropped onto, and where that item is. */
303
+ into: ReorderId;
304
+ index: number;
305
+ source: { list: string | undefined; index: number };
306
+ event: DropEvent;
307
+ }
308
+
309
+ /** A foreign payload — files, text, anything `accept` took — dropped at a
310
+ * slot. The event is core's, so `files`, `text` and `getData` are on it. */
311
+ export interface ReorderDrop {
312
+ index: number;
313
+ event: DropEvent;
314
+ }
315
+
316
+ /** What `canDrop` is asked, once per pointer position and again at the drop.
317
+ * A list item drag names `id`/`ids`/`source`; a foreign payload leaves them
318
+ * undefined and empty, and `event.types` is what it is offering. */
319
+ export interface ReorderDropQuery {
320
+ id?: ReorderId;
321
+ ids: ReorderId[];
322
+ source: { list: string | undefined; index: number } | null;
323
+ /** The slot it would land at. */
324
+ index: number;
325
+ /** The item it would merge into, when the pointer is in a combine band. */
326
+ combine: ReorderId | null;
327
+ event: DragEvent;
328
+ }
329
+
330
+ /** The drag started — a press past the threshold, or a keyboard lift. Fires
331
+ * on the list the item belongs to. */
332
+ export interface ReorderDragStart {
333
+ id: ReorderId;
334
+ ids: ReorderId[];
335
+ index: number;
336
+ input: ReorderInput;
337
+ }
338
+
339
+ /** Where the drag is now. Fires on the list the item belongs to, whichever
340
+ * list the pointer is over — `over` is null over anything that is not a
341
+ * list in the group. `over.index` is the index the item would **land at**,
342
+ * which is what `onReorder`'s `to` and `onInsert`'s `index` will say, not
343
+ * the raw gap it is hovering. */
344
+ export interface ReorderDragUpdate {
345
+ id: ReorderId;
346
+ ids: ReorderId[];
347
+ from: number;
348
+ over: { list: string | undefined; index: number } | null;
349
+ combine: ReorderId | null;
350
+ input: ReorderInput;
351
+ }
352
+
353
+ /** The gesture is over, however it ended. Fires on the list the item
354
+ * belongs to, after `onReorder`/`onInsert`/`onRemove`. */
355
+ export interface ReorderDragEnd {
356
+ id: ReorderId;
357
+ ids: ReorderId[];
358
+ from: number;
359
+ to: { list: string | undefined; index: number } | null;
360
+ combine: ReorderId | null;
361
+ reason: 'drop' | 'cancel';
362
+ input: ReorderInput;
363
+ }
364
+
365
+ // --- the looks --------------------------------------------------------------
366
+
367
+ /** What `styles.item` and `renderPreview` are handed. */
368
+ export interface ReorderItemState {
369
+ id: ReorderId;
370
+ /** The pointer is dragging this item. */
371
+ dragging: boolean;
372
+ /** The keyboard has lifted this item. */
373
+ lifted: boolean;
374
+ disabled: boolean;
375
+ /** Which of this item's edges the indicator is on, while a drag is over
376
+ * the list. */
377
+ edge: ReorderEdge | null;
378
+ /** A drop here would merge into this item rather than land beside it —
379
+ * the list's `combine`, and the pointer in this item's middle band. */
380
+ combining: boolean;
381
+ /** While the pointer drags this item: whether whatever is under the
382
+ * pointer would take it — a list in the group, a dropzone in the app,
383
+ * another application. False otherwise, the keyboard's lift included. */
384
+ accepted: boolean;
385
+ /** This render is the ghost's copy of the item, not the item in the list.
386
+ * What a child that renders in both asks before it draws its lighter
387
+ * version. */
388
+ preview: boolean;
389
+ /** Everything travelling with this item — `[id]`, unless the list's
390
+ * `selected` held it and a multi-drag picked the set up. */
391
+ ids: readonly ReorderId[];
392
+ }
393
+
394
+ export interface ReorderStyles {
395
+ /** The state seam: merged over the item's own `style`, so it can replace
396
+ * the wash a held item wears. */
397
+ item?: (state: ReorderItemState) => StyleInput | null | undefined | false;
398
+ handle?: StyleInput;
399
+ /** The insertion line, and the outline a combining item wears — the state
400
+ * tells them apart. */
401
+ indicator?: StyleInput;
402
+ /** The card the default preview draws the item's children in. */
403
+ preview?: StyleInput;
404
+ }
405
+
406
+ /** How big the ghost is. A function is asked once, at the drag's start. */
407
+ export type ReorderPreviewSize =
408
+ | { width: number; height: number }
409
+ | ((state: ReorderItemState) => { width: number; height: number });
410
+
411
+ // --- props ------------------------------------------------------------------
412
+
413
+ export interface ReorderListProps {
414
+ /** Names this list in the events other lists receive (`source.list`,
415
+ * `to.list`). */
416
+ id?: string;
417
+ /** Lists sharing a group accept each other's items. Without one a list
418
+ * takes only its own. */
419
+ group?: string;
420
+ /** Which way the items run. Decides the indicator's edge and the arrow
421
+ * keys; the root's `flexDirection` follows it. Default `'vertical'`. */
422
+ orientation?: ReorderOrientation;
423
+ /** Nothing drags, nothing lifts, nothing lands. */
424
+ disabled?: boolean;
425
+ /** The ids a multi-drag picks up together: dragging one of them carries
426
+ * all of them, in this list's own order. The app holds the selection. */
427
+ selected?: readonly ReorderId[];
428
+ /** An item moved within this list. */
429
+ onReorder?: (change: ReorderChange) => void;
430
+ /** An item arrived from another list in the group. */
431
+ onInsert?: (change: ReorderInsert) => void;
432
+ /** An item of this list was moved elsewhere. */
433
+ onRemove?: (change: ReorderRemove) => void;
434
+ /** A drop *onto* one of this list's items. Needs `combine`. */
435
+ onCombine?: (change: ReorderCombine) => void;
436
+ /** Whether the middle of an item is a merge target rather than an edge.
437
+ * Without `onCombine` it is inert. Default false. */
438
+ combine?: boolean;
439
+ /** The last word on whether a drop lands here, asked per pointer position
440
+ * and again at the drop. It can only *refuse* what `group`/`accept`
441
+ * already matched — see the header. */
442
+ canDrop?: (query: ReorderDropQuery) => boolean;
443
+ /** Foreign payloads the list takes, in core's `dropAccept` vocabulary —
444
+ * `['files']`, `'text/plain'`, a predicate. Without it every drag that is
445
+ * not a list item is refused. */
446
+ accept?: DropAccept;
447
+ /** A foreign payload landed, at `index`. */
448
+ onDrop?: (drop: ReorderDrop) => void;
449
+ /** The drag started: a press past the threshold, or a keyboard lift. */
450
+ onDragStart?: (ev: ReorderDragStart) => void;
451
+ /** Where the drag is now — one per pointer position that changed the
452
+ * answer, and one per keyboard step. */
453
+ onDragUpdate?: (ev: ReorderDragUpdate) => void;
454
+ /** The gesture is over, after the change events. */
455
+ onDragEnd?: (ev: ReorderDragEnd) => void;
456
+ /**
457
+ * The ghost that follows the pointer while an item is held.
458
+ *
459
+ * `'auto'` (the default, and what `true` means) is a `<popup>`: a window
460
+ * of its own, so it follows the pointer out of the list, out of the
461
+ * window, and over other applications, on both backends.
462
+ *
463
+ * `'inline'` draws it as a box inside the list instead — no second window
464
+ * per drag, which a remote display may prefer, at the cost of a ghost
465
+ * that stops at the window's edge. `false` leaves only the cursor and the
466
+ * indicator.
467
+ */
468
+ preview?: boolean | 'auto' | 'popup' | 'inline';
469
+ /** What the ghost shows. Default: the item's children again, on a card. */
470
+ renderPreview?: (state: ReorderItemState) => ReactNode;
471
+ /** How big the ghost is. Default: the item's own size. */
472
+ previewSize?: ReorderPreviewSize;
473
+ /** Whether the ghost flies to where the item landed instead of vanishing,
474
+ * and for how long (default 180ms). Off under the desktop's reduced
475
+ * motion, whatever this says. */
476
+ dropAnimation?: boolean | number;
477
+ styles?: ReorderStyles;
478
+ style?: StyleInput;
479
+ 'data-testname'?: string;
480
+ children?: ReactNode;
481
+ }
482
+
483
+ export interface ReorderItemProps {
484
+ id: ReorderId;
485
+ /** Not draggable and not liftable — but still a slot others land beside. */
486
+ disabled?: boolean;
487
+ /** Offered beside the reorder payload, for other targets in the app and
488
+ * for other applications — core's `dragData`, thunks and all. */
489
+ dragData?: DragSourceProps['dragData'];
490
+ /** What the drag offers. Default `['move']`; `['copy']` makes this list a
491
+ * palette — the item stays, and the list that takes it gets
492
+ * `onInsert({ action: 'copy' })`. */
493
+ dragActions?: Array<'copy' | 'move' | 'link'>;
494
+ /** Let a press on a control inside the item start a drag anyway. Off by
495
+ * default, so a text field, a slider or a button inside a card keeps the
496
+ * press — see the header. Moot when the item has a handle. */
497
+ dragFromInteractive?: boolean;
498
+ /** What a screen reader calls the item. Default: its own text. */
499
+ 'aria-label'?: string;
500
+ style?: StyleInput;
501
+ 'data-testname'?: string;
502
+ /** The content — or a function of the item's state, for content that
503
+ * reacts to the drag. `useReorderItem()` is the same thing for a
504
+ * component deeper inside. */
505
+ children?: ReactNode | ((state: ReorderItemState) => ReactNode);
506
+ }
507
+
508
+ export interface ReorderHandleProps {
509
+ /** Default "Drag to reorder". */
510
+ 'aria-label'?: string;
511
+ style?: StyleInput;
512
+ 'data-testname'?: string;
513
+ /** Default: a grip of six dots. */
514
+ children?: ReactNode;
515
+ }
516
+
517
+ // --- shared state -----------------------------------------------------------
518
+
519
+ /** The live payload behind an item's drag. A thunk hands it over at the
520
+ * drop, by reference, so the list that takes it can write `to` here and the
521
+ * source's `onDragEnd` — which core fires last, with no destination — can
522
+ * report it. */
523
+ interface Payload {
524
+ listUid: string;
525
+ list: string | undefined;
526
+ id: ReorderId;
527
+ ids: ReorderId[];
528
+ index: number;
529
+ to?: {
530
+ listUid: string;
531
+ list: string | undefined;
532
+ index: number;
533
+ combine: ReorderId | null;
534
+ };
535
+ }
536
+
537
+ /** Where the pointer is, as the list under it sees things. */
538
+ interface DragOver {
539
+ listUid: string;
540
+ list: string | undefined;
541
+ index: number;
542
+ combine: ReorderId | null;
543
+ }
544
+
545
+ /**
546
+ * The gesture's scratchpad — see the header. Written by whichever list the
547
+ * pointer is over, read by the source's own `onDrag`, and null between
548
+ * gestures. One drag at a time is a property of the pointer, not an
549
+ * assumption this makes.
550
+ *
551
+ * `source` and `inline` are here for the stacking: an in-window ghost has to
552
+ * be painted over everything between it and the drop, and `zIndex` only
553
+ * sorts siblings, so every list and item that *contains* the drag lifts
554
+ * itself. They ask this.
555
+ */
556
+ let activeDrag: {
557
+ payload: Payload;
558
+ over: DragOver | null;
559
+ source: DrawnNode | null;
560
+ inline: boolean;
561
+ } | null = null;
562
+
563
+ /**
564
+ * Who to tell when a drag starts or ends.
565
+ *
566
+ * A list two levels above the one being dragged in has nothing to re-render
567
+ * it — its children are the application's elements, unchanged — so it
568
+ * subscribes instead. Twice a gesture, for as many entries as there are
569
+ * mounted lists and items, each answering one `contains`.
570
+ */
571
+ const watchers = new Set<() => void>();
572
+
573
+ function announceDragChanged(): void {
574
+ for (const watcher of [...watchers]) watcher();
575
+ }
576
+
577
+ /**
578
+ * Whether the drag in flight is inside `ref`, and drawing a ghost this node
579
+ * has to be painted above. What lifts the whole path from the dragged item
580
+ * up to the outermost list — a board's card, its column, and the board.
581
+ */
582
+ function useCarryingDrag(ref: RefObject<DrawnNode | null>): boolean {
583
+ const [carrying, setCarrying] = useState(false);
584
+ useEffect(() => {
585
+ const update = (): void => {
586
+ const drag = activeDrag;
587
+ const node = ref.current;
588
+ const holds = Boolean(
589
+ drag?.inline && drag.source && node && node.contains(drag.source),
590
+ );
591
+ setCarrying(holds);
592
+ };
593
+ watchers.add(update);
594
+ update();
595
+ return () => {
596
+ watchers.delete(update);
597
+ };
598
+ }, [ref]);
599
+ return carrying;
600
+ }
601
+
602
+ /** One item, as the list sees it. */
603
+ interface Entry {
604
+ id: ReorderId;
605
+ node: RefObject<DrawnNode | null>;
606
+ setMark: (mark: Omit<Mark, 'id'> | null) => void;
607
+ focus: () => void;
608
+ label: () => string;
609
+ }
610
+
611
+ /** What the list marked, and on which item. */
612
+ interface Mark {
613
+ id: ReorderId;
614
+ edge: ReorderEdge;
615
+ combine: boolean;
616
+ /**
617
+ * How far off that edge the line sits, in the item's own logical pixels
618
+ * along the axis — negative toward the item before it.
619
+ *
620
+ * A gap is a space between two items, and the line belongs in the middle
621
+ * of it. Drawn on the following item's edge it is not: it sits hard
622
+ * against that item and a whole `gap` away from the one above, which
623
+ * reads as belonging to the item below rather than to the space. So the
624
+ * list measures the gap it is marking and hands over half of it. Zero at
625
+ * the two ends, where there is no gap to be in the middle of.
626
+ */
627
+ offset: number;
628
+ }
629
+
630
+ interface ListShared {
631
+ uid: string;
632
+ id: string | undefined;
633
+ type: string;
634
+ orientation: ReorderOrientation;
635
+ disabled: boolean;
636
+ preview: 'auto' | 'popup' | 'inline' | false;
637
+ renderPreview: ((state: ReorderItemState) => ReactNode) | undefined;
638
+ previewSize: ReorderPreviewSize | undefined;
639
+ dropMs: number;
640
+ styles: ReorderStyles | undefined;
641
+ liftedId: ReorderId | null;
642
+ register(entry: Entry): () => void;
643
+ order(): ReorderId[];
644
+ movingIds(id: ReorderId): ReorderId[];
645
+ lift(id: ReorderId): void;
646
+ drop(): void;
647
+ cancel(): void;
648
+ step(id: ReorderId, where: -1 | 1 | 'home' | 'end'): void;
649
+ focusNeighbour(id: ReorderId, delta: -1 | 1): void;
650
+ dragStarted(id: ReorderId): { index: number; ids: ReorderId[] };
651
+ dragMoved(id: ReorderId): void;
652
+ dragEnded(id: ReorderId, ev: DragEndEvent, payload: Payload | null): void;
653
+ }
654
+
655
+ const ListContext = React.createContext<ListShared | null>(null);
656
+
657
+ function useList(part: string): ListShared {
658
+ const list = useContext(ListContext);
659
+ if (!list) {
660
+ throw new Error(
661
+ `@react-x11/components: <${part}> has to be inside a <ReorderList>.`,
662
+ );
663
+ }
664
+ return list;
665
+ }
666
+
667
+ /** What an item lends its handle and its content: the drag, the keys, the
668
+ * look, and the state. */
669
+ interface ItemShared {
670
+ state: ReorderItemState;
671
+ disabled: boolean;
672
+ dragProps: DragSourceProps;
673
+ onKeyDown: (ev: KeyboardEvent) => void;
674
+ onBlur: () => void;
675
+ styles: ReorderStyles | undefined;
676
+ attachHandle(ref: RefObject<DrawnNode | null>): () => void;
677
+ }
678
+
679
+ const ItemContext = React.createContext<ItemShared | null>(null);
680
+
681
+ /**
682
+ * The state of the `<ReorderItem>` a component is rendered inside — what
683
+ * function `children` are handed, for a component deeper in the item. Reads
684
+ * `preview` to tell the ghost's copy from the item in the list.
685
+ */
686
+ export function useReorderItem(): ReorderItemState {
687
+ const item = useContext(ItemContext);
688
+ if (!item) {
689
+ throw new Error(
690
+ '@react-x11/components: useReorderItem() has to be called inside a <ReorderItem>.',
691
+ );
692
+ }
693
+ return item.state;
694
+ }
695
+
696
+ const NO_DRAG: DragSourceProps = {};
697
+
698
+ const noop = (): void => {};
699
+ const detachNoop = (): (() => void) => noop;
700
+
701
+ /** What the preview's copy of the children is handed: the state with
702
+ * `preview` set, and a handle rendered in the ghost draws its grip and does
703
+ * nothing else — it is a picture of one. */
704
+ function inertItem(
705
+ state: ReorderItemState,
706
+ styles: ReorderStyles | undefined,
707
+ ): ItemShared {
708
+ return {
709
+ state,
710
+ disabled: true,
711
+ dragProps: NO_DRAG,
712
+ onKeyDown: noop,
713
+ onBlur: noop,
714
+ styles,
715
+ attachHandle: detachNoop,
716
+ };
717
+ }
718
+
719
+ // --- helpers ----------------------------------------------------------------
720
+
721
+ /** What `DrawnNode` does not declare and a drag has to read: the display
722
+ * scale `abs` is in, the props a press landed on, and the owning window —
723
+ * The same
724
+ * widening `<Tabs>` makes to measure its strip: a ref's public contract is
725
+ * geometry and focus. */
726
+ interface OpaqueNode {
727
+ scale?: number;
728
+ props?: Record<string, unknown>;
729
+ }
730
+
731
+ /**
732
+ * Where a list's ghost is drawn, resolved.
733
+ *
734
+ * `'auto'` is the popup on both backends. It was the in-window box on cocoa
735
+ * for two releases, while a `<popup dragPreview>` there stopped the drop
736
+ * reaching the list beneath it: first because it registered as a dragging
737
+ * destination (react-x11#488, fixed in 2.8.1), and then because the window
738
+ * server finds the window under the pointer whether or not it has a
739
+ * destination, so a preview with none left the drag with none (#492, fixed
740
+ * in 2.8.2 by creating the preview with `ignoresMouseEvents`, so the hit
741
+ * passes through it). That needs `@windowkit/appkit` >= 0.6.0, which
742
+ * react-x11 2.8.2 asks for — an older bridge ignores the option silently.
743
+ */
744
+ function ghostMode(
745
+ preview: 'auto' | 'popup' | 'inline' | false,
746
+ ): 'popup' | 'inline' | false {
747
+ return preview === 'auto' ? 'popup' : preview;
748
+ }
749
+
750
+ function scaleOf(node: DrawnNode | null): number {
751
+ const scale = (node as (DrawnNode & OpaqueNode) | null)?.scale;
752
+ return scale && scale > 0 ? scale : 1;
753
+ }
754
+
755
+ /**
756
+ * The elements and roles a press belongs to rather than to the item around
757
+ * them. Roles rather than kinds wherever core's own widgets have one, so an
758
+ * app's `<Button>`, `<Slider>` or `<Switch>` is covered without this
759
+ * knowing what they are made of; kinds for the text elements, which are
760
+ * elements rather than compositions.
761
+ */
762
+ const INTERACTIVE_KINDS = new Set(['textinput', 'textarea', 'codeeditor']);
763
+ const INTERACTIVE_ROLES = new Set([
764
+ 'button',
765
+ 'checkbox',
766
+ 'combobox',
767
+ 'link',
768
+ 'menuitem',
769
+ 'menuitemcheckbox',
770
+ 'menuitemradio',
771
+ 'option',
772
+ 'radio',
773
+ 'scrollbar',
774
+ 'searchbox',
775
+ 'slider',
776
+ 'spinbutton',
777
+ 'switch',
778
+ 'tab',
779
+ 'textbox',
780
+ ]);
781
+
782
+ /** Was this press on a control, somewhere between the hit node and the item
783
+ * around it? */
784
+ function pressedControl(
785
+ target: DrawnNode | null,
786
+ item: DrawnNode | null,
787
+ ): boolean {
788
+ for (let n: DrawnNode | null = target; n; n = n.parent) {
789
+ if (INTERACTIVE_KINDS.has(n.kind)) return true;
790
+ const role = (n as DrawnNode & OpaqueNode).props?.role;
791
+ if (typeof role === 'string' && INTERACTIVE_ROLES.has(role)) return true;
792
+ if (n === item) break;
793
+ }
794
+ return false;
795
+ }
796
+
797
+ /** Every piece of text under a node, joined — what an item is called when
798
+ * it has no `aria-label`. `textContent()` answers for one node; a card is
799
+ * several. */
800
+ function labelOf(node: DrawnNode | null): string {
801
+ if (!node) return 'item';
802
+ const parts: string[] = [];
803
+ const walk = (n: DrawnNode): void => {
804
+ const text = n.textContent();
805
+ if (text) parts.push(text);
806
+ for (const child of n.children) walk(child);
807
+ };
808
+ walk(node);
809
+ const label = parts.join(' ').trim();
810
+ return label || 'item';
811
+ }
812
+
813
+ /**
814
+ * The list's `dropAccept`: its own scope, plus whatever the app's `accept`
815
+ * says. An array gains the scope and a predicate is or-ed with it, so
816
+ * core's own group matching (`'files'`, `'text'`, `'uris'`) keeps applying
817
+ * to the foreign half — nothing about accept vocabulary is re-implemented
818
+ * here.
819
+ */
820
+ function widenAccept(accept: DropAccept | undefined, type: string): DropAccept {
821
+ if (accept === undefined) return [type];
822
+ if (typeof accept === 'function') {
823
+ return (types: string[]) => types.includes(type) || Boolean(accept(types));
824
+ }
825
+ return [...(Array.isArray(accept) ? accept : [accept]), type];
826
+ }
827
+
828
+ /** Whether the app's `accept` — on its own, without the list's scope —
829
+ * takes what is on offer. `ev.has` is core's alias-aware test, which is
830
+ * what keeps the semantic groups core's to define. */
831
+ function foreignAccepted(
832
+ accept: DropAccept | undefined,
833
+ ev: DragEvent,
834
+ ): boolean {
835
+ if (accept === undefined) return false;
836
+ if (typeof accept === 'function') return Boolean(accept(ev.types));
837
+ return (Array.isArray(accept) ? accept : [accept]).some((want) =>
838
+ ev.has(want),
839
+ );
840
+ }
841
+
842
+ function refuse(): boolean {
843
+ return false;
844
+ }
845
+
846
+ function sameOver(a: DragOver | null, b: DragOver | null): boolean {
847
+ if (a === b) return true;
848
+ if (!a || !b) return false;
849
+ return (
850
+ a.listUid === b.listUid && a.index === b.index && a.combine === b.combine
851
+ );
852
+ }
853
+
854
+ /** Ease-out cubic — the curve core's own transitions use. */
855
+ function ease(t: number): number {
856
+ return 1 - (1 - t) ** 3;
857
+ }
858
+
859
+ // --- the list ---------------------------------------------------------------
860
+
861
+ /**
862
+ * `<ReorderList onReorder>` around `<ReorderItem id>`s.
863
+ *
864
+ * <ReorderList onReorder={(e) => setTodos((l) => arrayMove(l, e.from, e.to))}>
865
+ * {todos.map((t) => (
866
+ * <ReorderItem key={t.id} id={t.id}><text>{t.title}</text></ReorderItem>
867
+ * ))}
868
+ * </ReorderList>
869
+ *
870
+ * The order is read from the tree the list rendered, so it is written once —
871
+ * there is no `items` array to keep beside the children.
872
+ */
873
+ export function ReorderList(props: ReorderListProps): ReactElement {
874
+ const {
875
+ id,
876
+ group,
877
+ orientation = 'vertical',
878
+ disabled = false,
879
+ selected,
880
+ onReorder,
881
+ onInsert,
882
+ onRemove,
883
+ onCombine,
884
+ combine = false,
885
+ canDrop,
886
+ accept,
887
+ onDrop,
888
+ onDragStart,
889
+ onDragUpdate,
890
+ onDragEnd,
891
+ preview = 'auto',
892
+ renderPreview,
893
+ previewSize,
894
+ dropAnimation = true,
895
+ styles,
896
+ style,
897
+ 'data-testname': testname,
898
+ children,
899
+ } = props;
900
+
901
+ const uid = React.useId();
902
+ const type = scopedType(
903
+ group !== undefined ? `group:${group}` : `list:${uid}`,
904
+ );
905
+ const { reducedMotion } = useSystemAppearance();
906
+ const rootRef = useRef<DrawnNode | null>(null);
907
+ const entries = useRef(new Map<ReorderId, Entry>());
908
+ /** The item showing the indicator, and how. */
909
+ const marked = useRef<Mark | null>(null);
910
+ /** The item of *this* list the pointer is dragging, if any. */
911
+ const dragging = useRef<ReorderId | null>(null);
912
+ /** What the last `onDragUpdate` said, so one is not sent per motion. */
913
+ const reportedOver = useRef<DragOver | null>(null);
914
+ /** Whether the drag in flight is inside this list and drawing an
915
+ * in-window ghost — which is what its own stacking depends on. */
916
+ const carrying = useCarryingDrag(rootRef);
917
+ const [lifted, setLifted] = useState<{
918
+ id: ReorderId;
919
+ ids: ReorderId[];
920
+ origin: ReorderId[];
921
+ } | null>(null);
922
+ const liftedRef = useRef(lifted);
923
+ liftedRef.current = lifted;
924
+
925
+ // The handlers by ref, so the context value — and with it every item —
926
+ // does not re-render because an app wrote an inline arrow.
927
+ const handlers = useRef({
928
+ onReorder,
929
+ onInsert,
930
+ onRemove,
931
+ onCombine,
932
+ onDrop,
933
+ onDragStart,
934
+ onDragUpdate,
935
+ onDragEnd,
936
+ canDrop,
937
+ accept,
938
+ selected,
939
+ combine,
940
+ });
941
+ handlers.current = {
942
+ onReorder,
943
+ onInsert,
944
+ onRemove,
945
+ onCombine,
946
+ onDrop,
947
+ onDragStart,
948
+ onDragUpdate,
949
+ onDragEnd,
950
+ canDrop,
951
+ accept,
952
+ selected,
953
+ combine,
954
+ };
955
+
956
+ /**
957
+ * The ids in tree order — the order the app rendered the items in. A
958
+ * walk of the root's retained children, stopping at each item, so a
959
+ * wrapper around an item costs nothing and an item inside an item is not
960
+ * a thing.
961
+ */
962
+ const order = useCallback((): ReorderId[] => {
963
+ const root = rootRef.current;
964
+ if (!root) return [];
965
+ const byNode = new Map<DrawnNode, ReorderId>();
966
+ for (const entry of entries.current.values()) {
967
+ if (entry.node.current) byNode.set(entry.node.current, entry.id);
968
+ }
969
+ const out: ReorderId[] = [];
970
+ const walk = (n: DrawnNode): void => {
971
+ const found = byNode.get(n);
972
+ if (found !== undefined) {
973
+ out.push(found);
974
+ return;
975
+ }
976
+ for (const child of n.children) walk(child);
977
+ };
978
+ for (const child of root.children) walk(child);
979
+ return out;
980
+ }, []);
981
+
982
+ /** What travels when this item is picked up: the selection, in the list's
983
+ * own order, when the item is in it — and just the item otherwise. */
984
+ const movingIds = useCallback(
985
+ (itemId: ReorderId): ReorderId[] => {
986
+ const set = handlers.current.selected;
987
+ if (!set?.length || !set.includes(itemId)) return [itemId];
988
+ const chosen = new Set(set);
989
+ const ids = order().filter((x) => chosen.has(x));
990
+ return ids.length ? ids : [itemId];
991
+ },
992
+ [order],
993
+ );
994
+
995
+ /** The items' boxes in logical pixels — `abs` is device, and the pointer
996
+ * in a drag event is not. One division, here. */
997
+ const rectsOf = (ids: readonly ReorderId[]): ReorderRect[] => {
998
+ const scale = scaleOf(rootRef.current);
999
+ return ids.map((itemId) => {
1000
+ const node = entries.current.get(itemId)?.node.current;
1001
+ const { x, y, width, height } = node?.abs ?? {
1002
+ x: 0,
1003
+ y: 0,
1004
+ width: 0,
1005
+ height: 0,
1006
+ };
1007
+ return {
1008
+ x: x / scale,
1009
+ y: y / scale,
1010
+ width: width / scale,
1011
+ height: height / scale,
1012
+ };
1013
+ });
1014
+ };
1015
+
1016
+ /** Move the indicator: clear the item that had it, set the one that does. */
1017
+ const mark = (next: Mark | null): void => {
1018
+ const prev = marked.current;
1019
+ const same =
1020
+ prev !== null &&
1021
+ next !== null &&
1022
+ prev.id === next.id &&
1023
+ prev.edge === next.edge &&
1024
+ prev.combine === next.combine &&
1025
+ prev.offset === next.offset;
1026
+ if (same) return;
1027
+ if (prev) entries.current.get(prev.id)?.setMark(null);
1028
+ if (next) {
1029
+ entries.current.get(next.id)?.setMark({
1030
+ edge: next.edge,
1031
+ combine: next.combine,
1032
+ offset: next.offset,
1033
+ });
1034
+ }
1035
+ marked.current = next;
1036
+ };
1037
+
1038
+ /** Where a drag event's pointer would land. */
1039
+ const slotFor = (
1040
+ ev: DragEvent,
1041
+ moving: readonly ReorderId[],
1042
+ ): { ids: ReorderId[]; slot: number; at: Mark | null } => {
1043
+ const ids = order();
1044
+ const hit = closestSlot(
1045
+ rectsOf(ids),
1046
+ { x: ev.x, y: ev.y },
1047
+ orientation,
1048
+ rootRef.current?.direction ?? 'ltr',
1049
+ handlers.current.combine ? COMBINE_BAND : 0,
1050
+ );
1051
+ if (!hit) return { ids, slot: 0, at: null };
1052
+ const over = ids[hit.index]!;
1053
+ // an item cannot merge into itself, nor into anything travelling with it
1054
+ if (hit.combine && !moving.includes(over)) {
1055
+ return {
1056
+ ids,
1057
+ slot: hit.slot,
1058
+ at: { id: over, edge: hit.edge, combine: true, offset: 0 },
1059
+ };
1060
+ }
1061
+ // The mark comes from the **slot**, not from the item the slot was read
1062
+ // off: the lower half of one item and the upper half of the next are the
1063
+ // same gap, and a mark keyed to the item would draw that one insertion
1064
+ // point in two places and flip between them mid-gap. See `slotMark`.
1065
+ const line = slotMark(ids.length, hit.slot);
1066
+ if (!line) return { ids, slot: hit.slot, at: null };
1067
+ // half the gap it is marking, so the line sits in the space rather than
1068
+ // against the item below it. The ends have no gap: they stay on the edge.
1069
+ const rects = rectsOf(ids);
1070
+ const before = line.edge === 'before' ? line.index - 1 : -1;
1071
+ let offset = 0;
1072
+ if (before >= 0) {
1073
+ const a = rects[before]!;
1074
+ const b = rects[line.index]!;
1075
+ const rtl = (rootRef.current?.direction ?? 'ltr') === 'rtl';
1076
+ const space =
1077
+ orientation === 'vertical'
1078
+ ? b.y - (a.y + a.height)
1079
+ : rtl
1080
+ ? a.x - (b.x + b.width)
1081
+ : b.x - (a.x + a.width);
1082
+ offset = -Math.max(space, 0) / 2;
1083
+ }
1084
+ return {
1085
+ ids,
1086
+ slot: hit.slot,
1087
+ at: { id: ids[line.index]!, edge: line.edge, combine: false, offset },
1088
+ };
1089
+ };
1090
+
1091
+ /** What the payload of a drag is, when it is one of ours. */
1092
+ const payloadOf = (ev: DragEvent): Payload | null => {
1093
+ const items = (ev as Partial<DropEvent>).items;
1094
+ const found = items?.[type];
1095
+ return found && typeof found === 'object' ? (found as Payload) : null;
1096
+ };
1097
+
1098
+ /** The live payload while the pointer is merely *over* us: a `DragEvent`
1099
+ * carries no `items`, so the scratchpad is the only way to know whose
1100
+ * item this is — see the header. */
1101
+ const hoveringPayload = (ev: DragEvent): Payload | null => {
1102
+ if (!ev.types.includes(type)) return null;
1103
+ return payloadOf(ev) ?? activeDrag?.payload ?? null;
1104
+ };
1105
+
1106
+ const allowed = (
1107
+ ev: DragEvent,
1108
+ payload: Payload | null,
1109
+ slot: number,
1110
+ combining: ReorderId | null,
1111
+ ): boolean => {
1112
+ const ask = handlers.current.canDrop;
1113
+ if (!ask) return true;
1114
+ return Boolean(
1115
+ ask({
1116
+ id: payload?.id,
1117
+ ids: payload?.ids ?? [],
1118
+ source: payload ? { list: payload.list, index: payload.index } : null,
1119
+ index: slot,
1120
+ combine: combining,
1121
+ event: ev,
1122
+ }),
1123
+ );
1124
+ };
1125
+
1126
+ const onDragOver = (ev: DragEvent): void => {
1127
+ // `onDragOver` reaches every node on the path under the pointer, matched
1128
+ // or not — so a list has to ask the question `dropAccept` already
1129
+ // answered before it promises a slot to a drag it will never be given
1130
+ const payload = hoveringPayload(ev);
1131
+ if (!payload && !foreignAccepted(handlers.current.accept, ev)) {
1132
+ mark(null);
1133
+ return;
1134
+ }
1135
+ const moving = payload?.listUid === uid ? payload.ids : [];
1136
+ const { ids, slot, at } = slotFor(ev, moving);
1137
+ const combining = at?.combine ? at.id : null;
1138
+ if (!allowed(ev, payload, slot, combining)) {
1139
+ mark(null);
1140
+ if (activeDrag?.over?.listUid === uid) activeDrag.over = null;
1141
+ // core keeps the node its `dropAccept` matched; what a handler may do
1142
+ // is refuse this position, which is what turns the ghost's `accepted`
1143
+ // off and what `canDrop` means
1144
+ ev.reject();
1145
+ return;
1146
+ }
1147
+ // one of this list's own items, over a gap that is already its own:
1148
+ // nothing would move, so nothing is promised
1149
+ const move =
1150
+ !combining && moving.length > 0
1151
+ ? moveManyToSlot(ids, moving, slot, payload!.id)
1152
+ : null;
1153
+ if (!combining && moving.length > 0 && move === null) {
1154
+ mark(null);
1155
+ if (activeDrag?.over?.listUid === uid) activeDrag.over = null;
1156
+ return;
1157
+ }
1158
+ mark(at);
1159
+ if (activeDrag) {
1160
+ activeDrag.over = {
1161
+ listUid: uid,
1162
+ list: id,
1163
+ // where it would land, not the gap it is over — a same-list move
1164
+ // closes the hole it left behind, and a newcomer lands at the gap
1165
+ index: move ? move.to : slot,
1166
+ combine: combining,
1167
+ };
1168
+ }
1169
+ };
1170
+
1171
+ const onDragLeave = (): void => {
1172
+ mark(null);
1173
+ if (activeDrag?.over?.listUid === uid) activeDrag.over = null;
1174
+ };
1175
+
1176
+ const onDropHere = (ev: DropEvent): void => {
1177
+ mark(null);
1178
+ const payload = payloadOf(ev);
1179
+ const moving = payload?.listUid === uid ? payload.ids : [];
1180
+ const { ids, slot, at } = slotFor(ev, moving);
1181
+ const combining = at?.combine ? at.id : null;
1182
+ if (!allowed(ev, payload, slot, combining)) {
1183
+ ev.reject();
1184
+ return;
1185
+ }
1186
+ if (payload) {
1187
+ // a copy leaves the source's item where it is; anything else moves it
1188
+ const action = ev.action === 'copy' ? 'copy' : 'move';
1189
+ payload.to = { listUid: uid, list: id, index: slot, combine: combining };
1190
+ if (combining !== null) {
1191
+ ev.accept(action);
1192
+ handlers.current.onCombine?.({
1193
+ id: payload.id,
1194
+ ids: payload.ids,
1195
+ into: combining,
1196
+ index: ids.indexOf(combining),
1197
+ source: { list: payload.list, index: payload.index },
1198
+ event: ev,
1199
+ });
1200
+ return;
1201
+ }
1202
+ if (payload.listUid === uid) {
1203
+ // a list does not duplicate into itself: a same-list drop reorders
1204
+ const move = moveManyToSlot(ids, payload.ids, slot, payload.id);
1205
+ payload.to.index = move ? move.to : payload.index;
1206
+ ev.accept('move');
1207
+ if (move) {
1208
+ handlers.current.onReorder?.({
1209
+ items: move.items,
1210
+ id: payload.id,
1211
+ ids: move.ids,
1212
+ from: move.from,
1213
+ to: move.to,
1214
+ });
1215
+ }
1216
+ return;
1217
+ }
1218
+ ev.accept(action);
1219
+ handlers.current.onInsert?.({
1220
+ items: insertManyAtSlot(ids, payload.ids, slot),
1221
+ id: payload.id,
1222
+ ids: payload.ids,
1223
+ index: slot,
1224
+ action,
1225
+ source: { list: payload.list, index: payload.index },
1226
+ event: ev,
1227
+ });
1228
+ return;
1229
+ }
1230
+ // Not a live list item: files, text, another application's drag — or
1231
+ // the scope type from another process, which is JSON by then and not
1232
+ // ours to act on. The app's `accept` alone decides.
1233
+ if (!foreignAccepted(handlers.current.accept, ev)) {
1234
+ ev.reject();
1235
+ return;
1236
+ }
1237
+ handlers.current.onDrop?.({ index: slot, event: ev });
1238
+ };
1239
+
1240
+ // --- the keyboard's half, lent to the items ------------------------------
1241
+
1242
+ const labelFor = (itemId: ReorderId): string =>
1243
+ entries.current.get(itemId)?.label() ?? 'item';
1244
+
1245
+ const lift = useCallback(
1246
+ (itemId: ReorderId): void => {
1247
+ const ids = order();
1248
+ const moving = movingIds(itemId);
1249
+ setLifted({ id: itemId, ids: moving, origin: ids });
1250
+ const at = ids.indexOf(itemId) + 1;
1251
+ handlers.current.onDragStart?.({
1252
+ id: itemId,
1253
+ ids: moving,
1254
+ index: at - 1,
1255
+ input: 'keyboard',
1256
+ });
1257
+ announce(SAY.lifted(labelFor(itemId), at, ids.length, moving.length));
1258
+ },
1259
+ [order, movingIds],
1260
+ );
1261
+
1262
+ const drop = useCallback((): void => {
1263
+ const current = liftedRef.current;
1264
+ if (!current) return;
1265
+ setLifted(null);
1266
+ const ids = order();
1267
+ const at = ids.indexOf(current.id);
1268
+ handlers.current.onDragEnd?.({
1269
+ id: current.id,
1270
+ ids: current.ids,
1271
+ from: current.origin.indexOf(current.id),
1272
+ to: { list: id, index: at },
1273
+ combine: null,
1274
+ reason: 'drop',
1275
+ input: 'keyboard',
1276
+ });
1277
+ announce(SAY.dropped(labelFor(current.id), at + 1, ids.length));
1278
+ }, [id, order]);
1279
+
1280
+ const cancel = useCallback((): void => {
1281
+ const current = liftedRef.current;
1282
+ if (!current) return;
1283
+ setLifted(null);
1284
+ const ids = order();
1285
+ const from = ids.indexOf(current.id);
1286
+ const to = current.origin.indexOf(current.id);
1287
+ if (from >= 0 && to >= 0 && from !== to) {
1288
+ const back = moveManyToSlot(
1289
+ ids,
1290
+ current.ids,
1291
+ to + (to > from ? 1 : 0),
1292
+ current.id,
1293
+ );
1294
+ if (back) {
1295
+ handlers.current.onReorder?.({
1296
+ items: back.items,
1297
+ id: current.id,
1298
+ ids: back.ids,
1299
+ from: back.from,
1300
+ to: back.to,
1301
+ });
1302
+ }
1303
+ }
1304
+ handlers.current.onDragEnd?.({
1305
+ id: current.id,
1306
+ ids: current.ids,
1307
+ from,
1308
+ to: null,
1309
+ combine: null,
1310
+ reason: 'cancel',
1311
+ input: 'keyboard',
1312
+ });
1313
+ announce(SAY.cancelled(labelFor(current.id), (to >= 0 ? to : from) + 1));
1314
+ }, [order]);
1315
+
1316
+ const step = useCallback(
1317
+ (itemId: ReorderId, where: -1 | 1 | 'home' | 'end'): void => {
1318
+ const current = liftedRef.current;
1319
+ const ids = order();
1320
+ const from = ids.indexOf(itemId);
1321
+ if (from < 0) return;
1322
+ const moving = current?.ids ?? [itemId];
1323
+ const set = new Set(moving);
1324
+ const held = ids
1325
+ .map((x, i) => (set.has(x) ? i : -1))
1326
+ .filter((i) => i >= 0);
1327
+ // A step moves the whole run **past its next neighbour**, which for one
1328
+ // item is the ordinary "swap with the one below". `from + 1` would be
1329
+ // inside the run itself for a multi-drag, and land it where it already
1330
+ // is — the shape of that bug is a selection that will not move.
1331
+ let slot: number;
1332
+ if (where === 'home') slot = 0;
1333
+ else if (where === 'end') slot = ids.length;
1334
+ else if (where === 1) {
1335
+ const last = held.at(-1) ?? from;
1336
+ const next = ids.findIndex((x, i) => i > last && !set.has(x));
1337
+ if (next < 0) return;
1338
+ slot = next + 1;
1339
+ } else {
1340
+ const first = held[0] ?? from;
1341
+ let prev = -1;
1342
+ for (let i = first - 1; i >= 0; i--) {
1343
+ if (!set.has(ids[i]!)) {
1344
+ prev = i;
1345
+ break;
1346
+ }
1347
+ }
1348
+ if (prev < 0) return;
1349
+ slot = prev;
1350
+ }
1351
+ const move = moveManyToSlot(ids, moving, slot, itemId);
1352
+ if (!move) return;
1353
+ handlers.current.onReorder?.({
1354
+ items: move.items,
1355
+ id: itemId,
1356
+ ids: move.ids,
1357
+ from: move.from,
1358
+ to: move.to,
1359
+ });
1360
+ handlers.current.onDragUpdate?.({
1361
+ id: itemId,
1362
+ ids: move.ids,
1363
+ from,
1364
+ over: { list: id, index: move.to },
1365
+ combine: null,
1366
+ input: 'keyboard',
1367
+ });
1368
+ announce(SAY.moved(labelFor(itemId), move.to + 1, ids.length));
1369
+ },
1370
+ [id, order],
1371
+ );
1372
+
1373
+ const focusNeighbour = useCallback(
1374
+ (itemId: ReorderId, delta: -1 | 1) => {
1375
+ const ids = order();
1376
+ const next = ids[ids.indexOf(itemId) + delta];
1377
+ if (next !== undefined) entries.current.get(next)?.focus();
1378
+ },
1379
+ [order],
1380
+ );
1381
+
1382
+ const register = useCallback((entry: Entry): (() => void) => {
1383
+ entries.current.set(entry.id, entry);
1384
+ return () => {
1385
+ if (entries.current.get(entry.id) === entry) {
1386
+ entries.current.delete(entry.id);
1387
+ }
1388
+ if (marked.current?.id === entry.id) marked.current = null;
1389
+ };
1390
+ }, []);
1391
+
1392
+ const dragStarted = useCallback(
1393
+ (itemId: ReorderId): { index: number; ids: ReorderId[] } => {
1394
+ dragging.current = itemId;
1395
+ // a pointer drag and a keyboard lift are one gesture's worth of state
1396
+ if (liftedRef.current) setLifted(null);
1397
+ reportedOver.current = null;
1398
+ const ids = order();
1399
+ const moving = movingIds(itemId);
1400
+ const index = ids.indexOf(itemId);
1401
+ handlers.current.onDragStart?.({
1402
+ id: itemId,
1403
+ ids: moving,
1404
+ index,
1405
+ input: 'pointer',
1406
+ });
1407
+ announce(
1408
+ SAY.pickedUp(labelFor(itemId), index + 1, ids.length, moving.length),
1409
+ );
1410
+ return { index, ids: moving };
1411
+ },
1412
+ [order, movingIds],
1413
+ );
1414
+
1415
+ /** One pointer position, on the source's side: core tells the target
1416
+ * first, so the scratchpad is already up to date here. */
1417
+ const dragMoved = useCallback((itemId: ReorderId): void => {
1418
+ const over = activeDrag?.over ?? null;
1419
+ if (sameOver(over, reportedOver.current)) return;
1420
+ reportedOver.current = over;
1421
+ const payload = activeDrag?.payload;
1422
+ handlers.current.onDragUpdate?.({
1423
+ id: itemId,
1424
+ ids: payload?.ids ?? [itemId],
1425
+ from: payload?.index ?? -1,
1426
+ over: over ? { list: over.list, index: over.index } : null,
1427
+ combine: over?.combine ?? null,
1428
+ input: 'pointer',
1429
+ });
1430
+ }, []);
1431
+
1432
+ const dragEnded = useCallback(
1433
+ (itemId: ReorderId, ev: DragEndEvent, payload: Payload | null): void => {
1434
+ dragging.current = null;
1435
+ reportedOver.current = null;
1436
+ mark(null);
1437
+ const to = payload?.to;
1438
+ const moved = ev.dropped && ev.action === 'move';
1439
+ const ids = order();
1440
+ // a move within this list has already been reported as a reorder
1441
+ const elsewhere = moved && !(to && to.listUid === uid);
1442
+ if (elsewhere) {
1443
+ handlers.current.onRemove?.({
1444
+ items: ids.filter((x) => !(payload?.ids ?? [itemId]).includes(x)),
1445
+ id: itemId,
1446
+ ids: payload?.ids ?? [itemId],
1447
+ index: ids.indexOf(itemId),
1448
+ to: to ? { list: to.list, index: to.index } : null,
1449
+ event: ev,
1450
+ });
1451
+ }
1452
+ handlers.current.onDragEnd?.({
1453
+ id: itemId,
1454
+ ids: payload?.ids ?? [itemId],
1455
+ from: payload?.index ?? -1,
1456
+ to: ev.dropped && to ? { list: to.list, index: to.index } : null,
1457
+ combine: to?.combine ?? null,
1458
+ reason: ev.dropped ? 'drop' : 'cancel',
1459
+ input: 'pointer',
1460
+ });
1461
+ const label = labelFor(itemId);
1462
+ if (!ev.dropped) announce(SAY.returned(label));
1463
+ else if (to?.combine != null) {
1464
+ announce(SAY.combined(label, labelFor(to.combine)));
1465
+ } else if (to && to.listUid === uid) {
1466
+ announce(SAY.dropped(label, to.index + 1, ids.length));
1467
+ } else if (to) announce(SAY.droppedInto(label, to.list));
1468
+ },
1469
+ [uid, order],
1470
+ );
1471
+
1472
+ const shared = useMemo<ListShared>(
1473
+ () => ({
1474
+ uid,
1475
+ id,
1476
+ type,
1477
+ orientation,
1478
+ disabled,
1479
+ preview: preview === true ? 'auto' : preview,
1480
+ renderPreview,
1481
+ previewSize,
1482
+ dropMs:
1483
+ reducedMotion || dropAnimation === false
1484
+ ? 0
1485
+ : dropAnimation === true
1486
+ ? DROP_MS
1487
+ : dropAnimation,
1488
+ styles,
1489
+ liftedId: lifted?.id ?? null,
1490
+ register,
1491
+ order,
1492
+ movingIds,
1493
+ lift,
1494
+ drop,
1495
+ cancel,
1496
+ step,
1497
+ focusNeighbour,
1498
+ dragStarted,
1499
+ dragMoved,
1500
+ dragEnded,
1501
+ }),
1502
+ [
1503
+ uid,
1504
+ id,
1505
+ type,
1506
+ orientation,
1507
+ disabled,
1508
+ preview,
1509
+ renderPreview,
1510
+ previewSize,
1511
+ dropAnimation,
1512
+ reducedMotion,
1513
+ styles,
1514
+ lifted,
1515
+ register,
1516
+ order,
1517
+ movingIds,
1518
+ lift,
1519
+ drop,
1520
+ cancel,
1521
+ step,
1522
+ focusNeighbour,
1523
+ dragStarted,
1524
+ dragMoved,
1525
+ dragEnded,
1526
+ ],
1527
+ );
1528
+
1529
+ const dropAccept = useMemo<DropAccept>(
1530
+ () => (disabled ? refuse : widenAccept(accept, type)),
1531
+ [accept, type, disabled],
1532
+ );
1533
+
1534
+ return hx(
1535
+ 'box',
1536
+ {
1537
+ ref: rootRef,
1538
+ role: 'list',
1539
+ 'aria-orientation': orientation,
1540
+ 'data-testname': testname,
1541
+ dropAccept,
1542
+ onDragOver: disabled ? undefined : onDragOver,
1543
+ onDragLeave: disabled ? undefined : onDragLeave,
1544
+ onDrop: disabled ? undefined : onDropHere,
1545
+ style: [
1546
+ { flexDirection: orientation === 'horizontal' ? 'row' : 'column' },
1547
+ style,
1548
+ // An in-window ghost is a box inside this list, and `zIndex` only
1549
+ // sorts among siblings — so every list that *contains* the drag comes
1550
+ // forward among its own, or the ghost paints under whatever is next.
1551
+ // See the header.
1552
+ carrying ? { zIndex: 1 } : null,
1553
+ ],
1554
+ },
1555
+ h(ListContext.Provider, { value: shared }, children),
1556
+ );
1557
+ }
1558
+
1559
+ // --- the item ---------------------------------------------------------------
1560
+
1561
+ /** The item's own look, under its `style`. */
1562
+ function itemStyle(state: ReorderItemState, hasHandle: boolean): Style {
1563
+ return { cursor: state.disabled || hasHandle ? undefined : 'grab' };
1564
+ }
1565
+
1566
+ /** An item that contains the drag has to paint over its neighbours, or the
1567
+ * ghost following the pointer slides under the next row — or, for the item
1568
+ * that is a whole board column, under the next column. */
1569
+ function liftStyle(lifted: boolean): Style | null {
1570
+ return lifted ? { zIndex: 3 } : null;
1571
+ }
1572
+
1573
+ /**
1574
+ * What a lifted or dragging item wears — the wash the palette already has
1575
+ * for "this one", the way a hovered row does. It sits *over* the item's
1576
+ * `style`, the way a `:hover` block would: it is state, not look, and an
1577
+ * app that painted its rows `$surface` still gets to see which one it is
1578
+ * holding. `styles.item(state)` merges over this, so it stays the seam.
1579
+ */
1580
+ function washStyle(state: ReorderItemState, theme: Theme): Style | null {
1581
+ return state.lifted || state.dragging
1582
+ ? { backgroundColor: theme.surfaceHover }
1583
+ : null;
1584
+ }
1585
+
1586
+ /** The line at an edge: across the item and centred on the edge, inside the
1587
+ * item so a scrolling list carries it, logical edges so RTL costs nothing.
1588
+ * A combining item is outlined instead — the drop lands *on* it, and a line
1589
+ * beside it would say the opposite. */
1590
+ function indicatorStyle(
1591
+ mark: { edge: ReorderEdge; combine: boolean; offset: number },
1592
+ orientation: ReorderOrientation,
1593
+ theme: Theme,
1594
+ ): Style {
1595
+ const base: Style = {
1596
+ position: 'absolute',
1597
+ zIndex: 1,
1598
+ pointerEvents: 'none',
1599
+ };
1600
+ if (mark.combine) {
1601
+ return {
1602
+ ...base,
1603
+ top: 0,
1604
+ bottom: 0,
1605
+ left: 0,
1606
+ right: 0,
1607
+ borderWidth: INDICATOR,
1608
+ borderColor: theme.accent,
1609
+ borderRadius: theme.radius,
1610
+ };
1611
+ }
1612
+ const line: Style = { ...base, backgroundColor: theme.accent };
1613
+ // `offset` walks the line back into the middle of the gap; at the ends,
1614
+ // where there is no gap, it is zero and the line is centred on the edge
1615
+ const at = -INDICATOR / 2 + mark.offset;
1616
+ if (orientation === 'vertical') {
1617
+ return {
1618
+ ...line,
1619
+ left: 0,
1620
+ right: 0,
1621
+ height: INDICATOR,
1622
+ ...(mark.edge === 'before' ? { top: at } : { bottom: at }),
1623
+ };
1624
+ }
1625
+ return {
1626
+ ...line,
1627
+ top: 0,
1628
+ bottom: 0,
1629
+ width: INDICATOR,
1630
+ ...(mark.edge === 'before' ? { start: at } : { end: at }),
1631
+ };
1632
+ }
1633
+
1634
+ /**
1635
+ * What the default ghost is drawn on, *under* the item's own style.
1636
+ *
1637
+ * The ghost is the item, so it should look like the item: same padding, same
1638
+ * corners, same ground. An earlier cut drew a card of its own over the top —
1639
+ * a surface, a hairline and the theme's radius — and it was wrong in a way
1640
+ * that is easy to miss until an item has a look of its own. A chip lost its
1641
+ * rounded corners, and the border it gained ate two pixels of content box,
1642
+ * which is enough to wrap a word: "later" came out as "late" and "r".
1643
+ *
1644
+ * So the only thing here is a ground, and only when the item does not bring
1645
+ * one — a ghost with nothing behind it is a floating label, which is not
1646
+ * what anybody meant. `styles.preview` and `renderPreview` are where a
1647
+ * lifted-card look belongs, for an app that wants one.
1648
+ */
1649
+ function previewStyle(theme: Theme, hasGround: boolean): Style {
1650
+ return hasGround
1651
+ ? { flexGrow: 1 }
1652
+ : { flexGrow: 1, backgroundColor: theme.surface };
1653
+ }
1654
+
1655
+ /** The badge a multi-drag's ghost wears: how many are travelling. */
1656
+ function badgeStyle(theme: Theme): Style {
1657
+ return {
1658
+ position: 'absolute',
1659
+ top: -8,
1660
+ end: -8,
1661
+ minWidth: 18,
1662
+ paddingLeft: 5,
1663
+ paddingRight: 5,
1664
+ alignItems: 'center',
1665
+ justifyContent: 'center',
1666
+ borderRadius: 9,
1667
+ backgroundColor: theme.accent,
1668
+ };
1669
+ }
1670
+
1671
+ /**
1672
+ * `<ReorderItem id>` — one item. Draggable by any part of itself, unless a
1673
+ * `<ReorderHandle>` inside it takes that over; a tab stop, with the keyboard
1674
+ * model on it, for the same "unless".
1675
+ */
1676
+ export function ReorderItem(props: ReorderItemProps): ReactElement {
1677
+ const {
1678
+ id,
1679
+ disabled: ownDisabled = false,
1680
+ dragData,
1681
+ dragActions,
1682
+ dragFromInteractive = false,
1683
+ 'aria-label': ariaLabel,
1684
+ style,
1685
+ 'data-testname': testname,
1686
+ children,
1687
+ } = props;
1688
+ const list = useList('ReorderItem');
1689
+ const theme = useTheme();
1690
+ const nodeRef = useRef<DrawnNode | null>(null);
1691
+ const handleRef = useRef<RefObject<DrawnNode | null> | null>(null);
1692
+ const [mark, setMark] = useState<Omit<Mark, 'id'> | null>(null);
1693
+ /** Whether the drag in flight is inside this item — this one being
1694
+ * dragged, or a whole column of a board whose card is. */
1695
+ const carrying = useCarryingDrag(nodeRef);
1696
+ const [hasHandle, setHasHandle] = useState(false);
1697
+ const disabled = ownDisabled || list.disabled;
1698
+ const lifted = list.liftedId === id;
1699
+ const liftedRef = useRef(lifted);
1700
+ liftedRef.current = lifted;
1701
+
1702
+ /** Whether *this* item is the one being dragged, rather than one that
1703
+ * merely contains it — see the header. `useDragSource`'s own
1704
+ * `isDragging` cannot tell, because the events bubble. */
1705
+ const [holdingOwn, setHoldingOwn] = useState(false);
1706
+ /** The live payload, from the press that started a drag to its end. */
1707
+ const payload = useRef<Payload | null>(null);
1708
+ /** Where in the item the press landed, so the ghost appears under it
1709
+ * rather than jumping to the cursor; and how big the item was. */
1710
+ const grab = useRef({ x: 0, y: 0, width: 0, height: 0 });
1711
+ /** What the press landed on, for the interactive-element question, and
1712
+ * where the window is on screen, for the drop flight. */
1713
+ const pressed = useRef<DrawnNode | null>(null);
1714
+ const windowOrigin = useRef({ x: 0, y: 0 });
1715
+ /** Where the ghost was last seen, in screen coordinates, and where the
1716
+ * landing copy is now — an offset from the item's own origin, easing to
1717
+ * nothing. */
1718
+ const lastGhost = useRef<{ x: number; y: number } | null>(null);
1719
+ const [flight, setFlight] = useState<{ x: number; y: number } | null>(null);
1720
+ const ticks = useRef<{ layout: LayoutTick; timer: DelayTick }>({
1721
+ layout: null,
1722
+ timer: null,
1723
+ });
1724
+
1725
+ const label = useCallback(
1726
+ (): string => ariaLabel ?? labelOf(nodeRef.current),
1727
+ [ariaLabel],
1728
+ );
1729
+
1730
+ useEffect(
1731
+ () =>
1732
+ list.register({
1733
+ id,
1734
+ node: nodeRef,
1735
+ setMark,
1736
+ focus: () => {
1737
+ (handleRef.current?.current ?? nodeRef.current)?.focus();
1738
+ },
1739
+ label,
1740
+ }),
1741
+ [id, list.register, label],
1742
+ );
1743
+
1744
+ /** Nothing may outlive the item: a flight tick that fires after unmount
1745
+ * would set state on a component that is gone. */
1746
+ useEffect(
1747
+ () => () => {
1748
+ cancelAfterLayout(ticks.current.layout);
1749
+ cancelLater(ticks.current.timer);
1750
+ },
1751
+ [],
1752
+ );
1753
+
1754
+ /**
1755
+ * Should the drop be animated at all?
1756
+ *
1757
+ * The flight ends at where the item *is*, which is the right picture for
1758
+ * exactly two endings: a move inside this list, where it is somewhere new,
1759
+ * and a drag that landed nowhere, where flying back to an unchanged
1760
+ * position is precisely "it returned". Every other ending leaves the item
1761
+ * where it started while the drop *did* something — a merge into another
1762
+ * item, a copy taken by another list — and flying home there says the
1763
+ * opposite of what happened: it reads as a rejection. Those endings let
1764
+ * the ghost go where the pointer left it.
1765
+ */
1766
+ const flies = (ev: DragEndEvent, carried: Payload | null): boolean => {
1767
+ if (!ev.dropped || ev.action === null) return true; // it went nowhere
1768
+ const to = carried?.to;
1769
+ return Boolean(to && to.listUid === list.uid && to.combine === null);
1770
+ };
1771
+
1772
+ /**
1773
+ * Fly a copy of the item from where the pointer let go to where the item
1774
+ * landed, then let it go.
1775
+ *
1776
+ * Two things make this the shape it is. The landing place is only real
1777
+ * once the layout that moved the item has run — hence the tick — and the
1778
+ * copy travels in the *item's own* coordinates, as an offset easing to
1779
+ * zero, so it is a box inside the list rather than a window over it (see
1780
+ * the header). The screen point the pointer let go at is turned into that
1781
+ * offset here, which is the only thing the window's origin is needed for.
1782
+ */
1783
+ const flyHome = useCallback(
1784
+ (from: { x: number; y: number }, ms: number): void => {
1785
+ ticks.current.layout = afterLayout(() => {
1786
+ ticks.current.layout = null;
1787
+ const node = nodeRef.current;
1788
+ if (!node) {
1789
+ setFlight(null);
1790
+ return;
1791
+ }
1792
+ const scale = scaleOf(node);
1793
+ const dx = from.x - (windowOrigin.current.x + node.abs.x / scale);
1794
+ const dy = from.y - (windowOrigin.current.y + node.abs.y / scale);
1795
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
1796
+ setFlight(null);
1797
+ return;
1798
+ }
1799
+ const started = Date.now();
1800
+ const stepOn = (): void => {
1801
+ const t = Math.min((Date.now() - started) / ms, 1);
1802
+ if (t >= 1) {
1803
+ ticks.current.timer = null;
1804
+ setFlight(null);
1805
+ return;
1806
+ }
1807
+ const left = 1 - ease(t);
1808
+ setFlight({ x: dx * left, y: dy * left });
1809
+ ticks.current.timer = later(stepOn, FLIGHT_STEP_MS);
1810
+ };
1811
+ stepOn();
1812
+ });
1813
+ },
1814
+ [],
1815
+ );
1816
+
1817
+ /** Is this event about this item's own drag source — the item box, or the
1818
+ * handle the drag props were spread on — rather than a descendant's? */
1819
+ const ownSource = (ev: { target: DrawnNode }): boolean =>
1820
+ ev.target === nodeRef.current ||
1821
+ ev.target === (handleRef.current?.current ?? null);
1822
+
1823
+ const { dragProps, isDragging, position } = useDragSource({
1824
+ data: { [list.type]: () => payload.current, ...dragData },
1825
+ actions: dragActions ?? ['move'],
1826
+ onDragStart: (ev: DragSourceEvent) => {
1827
+ if (!ownSource(ev)) return;
1828
+ // A press on a control inside the item belongs to the control, not to
1829
+ // the item around it — core arms from the nearest draggable ancestor,
1830
+ // so this is the layer's own answer. Cancelling here leaves the
1831
+ // gesture as ordinary mouse events, which is what the control wanted.
1832
+ if (
1833
+ !dragFromInteractive &&
1834
+ !hasHandle &&
1835
+ pressedControl(pressed.current, nodeRef.current)
1836
+ ) {
1837
+ ev.preventDefault();
1838
+ return;
1839
+ }
1840
+ const node = nodeRef.current;
1841
+ const scale = scaleOf(node);
1842
+ if (node) {
1843
+ const { x, y, width, height } = node.abs;
1844
+ grab.current = {
1845
+ x: ev.x - x / scale,
1846
+ y: ev.y - y / scale,
1847
+ width: width / scale,
1848
+ height: height / scale,
1849
+ };
1850
+ }
1851
+ // the window's own origin on screen, which the ghost's flight home
1852
+ // needs and no ref reports: the same point in both spaces, once.
1853
+ windowOrigin.current = { x: ev.screenX - ev.x, y: ev.screenY - ev.y };
1854
+ const { index, ids } = list.dragStarted(id);
1855
+ payload.current = {
1856
+ listUid: list.uid,
1857
+ list: list.id,
1858
+ id,
1859
+ ids,
1860
+ index,
1861
+ };
1862
+ activeDrag = {
1863
+ payload: payload.current,
1864
+ over: null,
1865
+ source: node,
1866
+ inline: ghostMode(list.preview) === 'inline',
1867
+ };
1868
+ setHoldingOwn(true);
1869
+ announceDragChanged();
1870
+ },
1871
+ onDrag: (ev: DragSourceEvent) => {
1872
+ if (ownSource(ev)) list.dragMoved(id);
1873
+ },
1874
+ onDragEnd: (ev: DragEndEvent) => {
1875
+ if (!ownSource(ev)) return;
1876
+ setHoldingOwn(false);
1877
+ const carried = payload.current;
1878
+ payload.current = null;
1879
+ activeDrag = null;
1880
+ announceDragChanged();
1881
+ const from = lastGhost.current;
1882
+ lastGhost.current = null;
1883
+ list.dragEnded(id, ev, carried);
1884
+ if (
1885
+ from &&
1886
+ list.dropMs > 0 &&
1887
+ list.preview !== false &&
1888
+ flies(ev, carried)
1889
+ ) {
1890
+ flyHome(from, list.dropMs);
1891
+ }
1892
+ },
1893
+ });
1894
+
1895
+ // --- the keyboard --------------------------------------------------------
1896
+
1897
+ const onKeyDown = (ev: KeyboardEvent): void => {
1898
+ if (disabled) return;
1899
+ const key = ev.keysym;
1900
+ const rtl = nodeRef.current?.direction === 'rtl';
1901
+ const [prev, next] =
1902
+ list.orientation === 'vertical'
1903
+ ? [XK_UP, XK_DOWN]
1904
+ : rtl
1905
+ ? [XK_RIGHT, XK_LEFT]
1906
+ : [XK_LEFT, XK_RIGHT];
1907
+ const isLifted = liftedRef.current;
1908
+ if (key === XK_RETURN || ev.codepoint === 32) {
1909
+ if (isLifted) list.drop();
1910
+ else list.lift(id);
1911
+ ev.preventDefault();
1912
+ return;
1913
+ }
1914
+ if (key === XK_ESCAPE) {
1915
+ if (!isLifted) return;
1916
+ list.cancel();
1917
+ ev.preventDefault();
1918
+ return;
1919
+ }
1920
+ const delta = key === prev ? -1 : key === next ? 1 : 0;
1921
+ if (delta !== 0) {
1922
+ if (isLifted) list.step(id, delta);
1923
+ else list.focusNeighbour(id, delta);
1924
+ ev.preventDefault();
1925
+ return;
1926
+ }
1927
+ if (isLifted && (key === XK_HOME || key === XK_END)) {
1928
+ list.step(id, key === XK_HOME ? 'home' : 'end');
1929
+ ev.preventDefault();
1930
+ }
1931
+ };
1932
+
1933
+ /** Focus leaving a lifted item drops it where it is — a lifted item nobody
1934
+ * is holding is a list that will not sit still. */
1935
+ const onBlur = (): void => {
1936
+ if (liftedRef.current) list.drop();
1937
+ };
1938
+
1939
+ const attachHandle = useCallback(
1940
+ (ref: RefObject<DrawnNode | null>): (() => void) => {
1941
+ handleRef.current = ref;
1942
+ setHasHandle(true);
1943
+ return () => {
1944
+ if (handleRef.current === ref) handleRef.current = null;
1945
+ setHasHandle(false);
1946
+ };
1947
+ },
1948
+ [],
1949
+ );
1950
+
1951
+ const drag = disabled ? NO_DRAG : dragProps;
1952
+ /** The hook reports a drag anywhere beneath this item; only ours counts. */
1953
+ const dragging = isDragging && holdingOwn;
1954
+ const moving =
1955
+ dragging || lifted ? list.movingIds(id) : ([id] as ReorderId[]);
1956
+ const state: ReorderItemState = {
1957
+ id,
1958
+ dragging,
1959
+ lifted,
1960
+ disabled,
1961
+ edge: mark?.edge ?? null,
1962
+ combining: Boolean(mark?.combine),
1963
+ accepted: (holdingOwn && position?.accepted) ?? false,
1964
+ preview: false,
1965
+ ids: moving,
1966
+ };
1967
+ // A new object per render, deliberately: `dragProps` is new per render
1968
+ // too, and what it keeps current is one handle box and whoever called
1969
+ // `useReorderItem()` — both of which want the state of *this* render.
1970
+ const itemShared: ItemShared = {
1971
+ state,
1972
+ disabled,
1973
+ dragProps: drag,
1974
+ onKeyDown,
1975
+ onBlur,
1976
+ styles: list.styles,
1977
+ attachHandle,
1978
+ };
1979
+ const previewState: ReorderItemState = { ...state, preview: true };
1980
+ const content = typeof children === 'function' ? children(state) : children;
1981
+
1982
+ const size =
1983
+ typeof list.previewSize === 'function'
1984
+ ? list.previewSize(previewState)
1985
+ : (list.previewSize ?? grab.current);
1986
+ const ghostAt = holdingOwn &&
1987
+ position && {
1988
+ x: position.x - grab.current.x,
1989
+ y: position.y - grab.current.y,
1990
+ };
1991
+ if (dragging && ghostAt) lastGhost.current = ghostAt;
1992
+ // Where the ghost is drawn. A popup can leave the window and draws over
1993
+ // other applications; an in-window copy cannot, and is the opt-in.
1994
+ const mode = ghostMode(list.preview);
1995
+ /** The item's own origin on screen, which turns a pointer position into an
1996
+ * offset inside the item — what an inline ghost is placed by, and what the
1997
+ * drop flight eases to zero. */
1998
+ const originOnScreen = (): { x: number; y: number } | null => {
1999
+ const node = nodeRef.current;
2000
+ if (!node) return null;
2001
+ const scale = scaleOf(node);
2002
+ return {
2003
+ x: windowOrigin.current.x + node.abs.x / scale,
2004
+ y: windowOrigin.current.y + node.abs.y / scale,
2005
+ };
2006
+ };
2007
+ const origin = mode === 'inline' && ghostAt ? originOnScreen() : null;
2008
+ /** The offset the in-window copy is drawn at: the pointer's while the item
2009
+ * is held, and the flight's easing to zero once it has landed. One box
2010
+ * either way. */
2011
+ const inlineAt =
2012
+ flight ??
2013
+ (origin && ghostAt
2014
+ ? { x: ghostAt.x - origin.x, y: ghostAt.y - origin.y }
2015
+ : null);
2016
+ const showPopup = Boolean(ghostAt) && mode === 'popup';
2017
+
2018
+ /** The look: the defaults, then the app's `style`. */
2019
+ const ownStyle: StyleInput[] = [itemStyle(state, hasHandle), style].filter(
2020
+ (s): s is StyleInput => Boolean(s),
2021
+ );
2022
+ /** Whether the item paints its own ground, which decides whether the ghost
2023
+ * needs one of its own. */
2024
+ // `flattenStyle` takes one style or a flat list; ours may hold a nested
2025
+ // one, since `style` itself accepts an array.
2026
+ const hasGround =
2027
+ flattenStyle(ownStyle.flat() as Style[]).backgroundColor !== undefined;
2028
+ /** The state, over the look: the lift, the wash, then the seam. */
2029
+ const stateStyle: StyleInput[] = [
2030
+ liftStyle(carrying),
2031
+ washStyle(state, theme),
2032
+ list.styles?.item?.(state) || null,
2033
+ ].filter((s): s is StyleInput => Boolean(s));
2034
+ /** The ghost's content, drawn twice: once in the popup that follows the
2035
+ * pointer, once in the copy that flies home after the drop. */
2036
+ const ghostBody = (): ReactNode =>
2037
+ h(
2038
+ ItemContext.Provider,
2039
+ { value: inertItem(previewState, list.styles) },
2040
+ list.renderPreview
2041
+ ? list.renderPreview(previewState)
2042
+ : hx(
2043
+ 'box',
2044
+ {
2045
+ style: [
2046
+ // the ground goes *under* the item's own style, so a chip
2047
+ // keeps its corners and its width — see `previewStyle`
2048
+ previewStyle(theme, hasGround),
2049
+ ...ownStyle,
2050
+ // the seam sees the ghost too, with `preview` set
2051
+ list.styles?.item?.(previewState) || null,
2052
+ list.styles?.preview,
2053
+ ],
2054
+ },
2055
+ typeof children === 'function' ? children(previewState) : children,
2056
+ ),
2057
+ );
2058
+
2059
+ return hx(
2060
+ 'box',
2061
+ {
2062
+ ref: nodeRef,
2063
+ role: 'listitem',
2064
+ 'aria-label': ariaLabel,
2065
+ 'aria-description': hasHandle || disabled ? undefined : HINT,
2066
+ 'data-testname': testname,
2067
+ // Every press is remembered, whether or not it becomes a drag: at the
2068
+ // threshold there is no event saying where the gesture began. Capture
2069
+ // phase, so a child that stops propagation cannot hide it.
2070
+ onMouseDownCapture: (ev: MouseEvent) => {
2071
+ pressed.current = ev.target;
2072
+ },
2073
+ // without a handle the item is the drag source and the tab stop
2074
+ ...(hasHandle ? {} : drag),
2075
+ ...(hasHandle
2076
+ ? {}
2077
+ : {
2078
+ focusable: !disabled,
2079
+ onKeyDown: disabled ? undefined : onKeyDown,
2080
+ onBlur,
2081
+ }),
2082
+ style: [...ownStyle, ...stateStyle],
2083
+ },
2084
+ h(ItemContext.Provider, { key: 'content', value: itemShared }, content),
2085
+ mark !== null &&
2086
+ hx('box', {
2087
+ key: 'indicator',
2088
+ 'data-testname': testname ? `${testname}-indicator` : undefined,
2089
+ style: [
2090
+ indicatorStyle(mark, list.orientation, theme),
2091
+ list.styles?.indicator,
2092
+ ],
2093
+ }),
2094
+ inlineAt !== null &&
2095
+ hx(
2096
+ 'box',
2097
+ {
2098
+ key: 'ghost',
2099
+ // the same box before and after the release: the preview while the
2100
+ // pointer holds it, the flight once it has landed
2101
+ 'data-testname': testname
2102
+ ? `${testname}-${flight ? 'flight' : 'preview'}`
2103
+ : undefined,
2104
+ style: {
2105
+ position: 'absolute',
2106
+ left: inlineAt.x,
2107
+ top: inlineAt.y,
2108
+ width: Math.max(1, Math.ceil(size.width)),
2109
+ height: Math.max(1, Math.ceil(size.height)),
2110
+ zIndex: 2,
2111
+ // the copy is a picture of the drop, never a target for the
2112
+ // press that follows it
2113
+ pointerEvents: 'none',
2114
+ },
2115
+ },
2116
+ ghostBody(),
2117
+ ),
2118
+ showPopup &&
2119
+ ghostAt &&
2120
+ hx(
2121
+ 'popup',
2122
+ {
2123
+ key: 'preview',
2124
+ dragPreview: true,
2125
+ // a real window, and the card in it is rounded: see the header
2126
+ transparent: true,
2127
+ theme,
2128
+ x: Math.round(ghostAt.x),
2129
+ y: Math.round(ghostAt.y),
2130
+ width: Math.max(1, Math.ceil(size.width)),
2131
+ height: Math.max(1, Math.ceil(size.height)),
2132
+ 'data-testname': testname ? `${testname}-preview` : undefined,
2133
+ },
2134
+ ghostBody(),
2135
+ moving.length > 1 &&
2136
+ hx(
2137
+ 'box',
2138
+ {
2139
+ key: 'badge',
2140
+ 'data-testname': testname ? `${testname}-count` : undefined,
2141
+ style: badgeStyle(theme),
2142
+ },
2143
+ hx(
2144
+ 'text',
2145
+ { style: { fontSize: 11, color: theme.accentText } },
2146
+ String(moving.length),
2147
+ ),
2148
+ ),
2149
+ ),
2150
+ );
2151
+ }
2152
+
2153
+ // --- the handle -------------------------------------------------------------
2154
+
2155
+ /**
2156
+ * `<ReorderHandle>` — the one part of an item that drags it. Rendering one
2157
+ * inside a `<ReorderItem>` is the whole opt-in: the item stops being the
2158
+ * press target and the tab stop, and this is both.
2159
+ */
2160
+ export function ReorderHandle(props: ReorderHandleProps): ReactElement {
2161
+ const {
2162
+ 'aria-label': ariaLabel = 'Drag to reorder',
2163
+ style,
2164
+ 'data-testname': testname,
2165
+ children,
2166
+ } = props;
2167
+ const item = useContext(ItemContext);
2168
+ if (!item) {
2169
+ throw new Error(
2170
+ '@react-x11/components: <ReorderHandle> has to be inside a <ReorderItem>.',
2171
+ );
2172
+ }
2173
+ const ref = useRef<DrawnNode | null>(null);
2174
+ const { attachHandle } = item;
2175
+ useLayoutEffect(() => attachHandle(ref), [attachHandle]);
2176
+
2177
+ return hx(
2178
+ 'box',
2179
+ {
2180
+ ref,
2181
+ role: 'button',
2182
+ 'aria-label': ariaLabel,
2183
+ 'aria-description': item.disabled ? undefined : HINT,
2184
+ 'data-testname': testname,
2185
+ focusable: !item.disabled,
2186
+ ...item.dragProps,
2187
+ onKeyDown: item.disabled ? undefined : item.onKeyDown,
2188
+ onBlur: item.onBlur,
2189
+ style: [
2190
+ {
2191
+ flexDirection: 'row',
2192
+ alignItems: 'center',
2193
+ alignSelf: 'center',
2194
+ padding: 2,
2195
+ borderRadius: 3,
2196
+ cursor: item.disabled ? undefined : 'grab',
2197
+ },
2198
+ !item.disabled && { ':hover': { backgroundColor: '$surfaceHover' } },
2199
+ item.styles?.handle,
2200
+ style,
2201
+ ],
2202
+ },
2203
+ children ??
2204
+ // two columns of core's three dots: the grip every list draws, made of
2205
+ // the affordance set rather than drawn here
2206
+ hx(
2207
+ 'box',
2208
+ { key: 'grip', style: { flexDirection: 'row' } },
2209
+ h(Icon, {
2210
+ key: 'a',
2211
+ name: 'moreVertical',
2212
+ size: GRIP_DOT,
2213
+ color: '$textMuted',
2214
+ }),
2215
+ h(Icon, {
2216
+ key: 'b',
2217
+ name: 'moreVertical',
2218
+ size: GRIP_DOT,
2219
+ color: '$textMuted',
2220
+ }),
2221
+ ),
2222
+ );
2223
+ }