@flamingo-stack/openframe-frontend-core 0.0.555 → 0.0.556-snapshot.20260818165639

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 (187) hide show
  1. package/dist/{chunk-6ER3AYJM.js → chunk-25YLMIWU.js} +3 -3
  2. package/dist/{chunk-HO3Q7NXJ.js → chunk-26HAJBSQ.js} +2 -2
  3. package/dist/{chunk-P22HM2MO.cjs → chunk-33FOBAWS.cjs} +18 -18
  4. package/dist/{chunk-P22HM2MO.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
  5. package/dist/{chunk-N5A4AC2J.cjs → chunk-4EGNYOZA.cjs} +23 -23
  6. package/dist/{chunk-N5A4AC2J.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
  7. package/dist/{chunk-A7ES3NZK.cjs → chunk-4WREPWRL.cjs} +3 -3
  8. package/dist/{chunk-A7ES3NZK.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
  9. package/dist/{chunk-CU6Q2KQT.cjs → chunk-5HNZTPAB.cjs} +12 -12
  10. package/dist/{chunk-CU6Q2KQT.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
  11. package/dist/{chunk-MLVF3OXU.cjs → chunk-5JF6H7OJ.cjs} +76 -76
  12. package/dist/{chunk-MLVF3OXU.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
  13. package/dist/{chunk-UXW63WCV.cjs → chunk-64HHDGRN.cjs} +4 -4
  14. package/dist/{chunk-UXW63WCV.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
  15. package/dist/{chunk-KXLQITOX.cjs → chunk-6YXEZCVG.cjs} +5 -5
  16. package/dist/{chunk-KXLQITOX.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
  17. package/dist/{chunk-SST2YI7S.cjs → chunk-6ZOVSD4M.cjs} +14 -14
  18. package/dist/{chunk-SST2YI7S.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
  19. package/dist/{chunk-M5TWHFIN.cjs → chunk-A6TAVIBX.cjs} +42 -42
  20. package/dist/{chunk-M5TWHFIN.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
  21. package/dist/{chunk-J76LCSR4.cjs → chunk-BE6TRUKY.cjs} +40 -40
  22. package/dist/{chunk-J76LCSR4.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
  23. package/dist/{chunk-Q6RC3YGW.cjs → chunk-DFJ7TWH6.cjs} +3 -3
  24. package/dist/{chunk-Q6RC3YGW.cjs.map → chunk-DFJ7TWH6.cjs.map} +1 -1
  25. package/dist/{chunk-B2GEKAAA.js → chunk-DRNSIK3V.js} +2 -2
  26. package/dist/{chunk-CTTY5EKH.js → chunk-FXVMRZIQ.js} +5 -5
  27. package/dist/{chunk-QZ64JOE2.js → chunk-G3NBTL4I.js} +3246 -2557
  28. package/dist/chunk-G3NBTL4I.js.map +1 -0
  29. package/dist/{chunk-TXV444YV.js → chunk-G7ROZAY5.js} +3 -3
  30. package/dist/{chunk-E56VNTBV.cjs → chunk-IPO24G7U.cjs} +1775 -1086
  31. package/dist/chunk-IPO24G7U.cjs.map +1 -0
  32. package/dist/{chunk-CVCQW7WG.js → chunk-LJWTXODF.js} +3 -3
  33. package/dist/{chunk-P7MSXPGL.js → chunk-NNGQ6ONX.js} +8 -8
  34. package/dist/{chunk-XOKRFZBD.cjs → chunk-QCBBX6DO.cjs} +97 -97
  35. package/dist/{chunk-XOKRFZBD.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
  36. package/dist/{chunk-GSONSIWG.js → chunk-R7A7WHXP.js} +60 -35
  37. package/dist/chunk-R7A7WHXP.js.map +1 -0
  38. package/dist/{chunk-L36R3G7Z.js → chunk-RPCY2FAC.js} +6 -6
  39. package/dist/{chunk-QMQQ37RW.js → chunk-RRWWAH37.js} +2 -2
  40. package/dist/{chunk-ODRMPI3Y.js → chunk-SBKX2VSG.js} +2 -2
  41. package/dist/{chunk-KQKU4VXB.cjs → chunk-STHVHAV4.cjs} +82 -57
  42. package/dist/chunk-STHVHAV4.cjs.map +1 -0
  43. package/dist/{chunk-E2IPBWGT.js → chunk-UTEQHK3H.js} +6 -6
  44. package/dist/{chunk-6QRKCVYR.cjs → chunk-V5EYGEX7.cjs} +7 -7
  45. package/dist/{chunk-6QRKCVYR.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
  46. package/dist/{chunk-K432ES7N.js → chunk-VJL46NHW.js} +4 -4
  47. package/dist/{chunk-QTO3WYB2.cjs → chunk-VJTD2DGF.cjs} +3 -3
  48. package/dist/{chunk-QTO3WYB2.cjs.map → chunk-VJTD2DGF.cjs.map} +1 -1
  49. package/dist/{chunk-VGLDA77S.js → chunk-VRX6Y6A7.js} +2 -2
  50. package/dist/{chunk-OR67WC32.js → chunk-WBQ3NOH7.js} +2 -2
  51. package/dist/{chunk-UL3WQWTF.js → chunk-Y6TXTBIQ.js} +2 -2
  52. package/dist/{chunk-YAOBDOI5.cjs → chunk-YHRE4JBF.cjs} +32 -32
  53. package/dist/{chunk-YAOBDOI5.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
  54. package/dist/components/case-studies/index.cjs +10 -10
  55. package/dist/components/case-studies/index.js +4 -4
  56. package/dist/components/chat/index.cjs +4 -4
  57. package/dist/components/chat/index.js +3 -3
  58. package/dist/components/contact/index.cjs +5 -5
  59. package/dist/components/contact/index.js +4 -4
  60. package/dist/components/docs/index.cjs +7 -7
  61. package/dist/components/docs/index.js +6 -6
  62. package/dist/components/embeds/index.cjs +5 -5
  63. package/dist/components/embeds/index.js +4 -4
  64. package/dist/components/faq/index.cjs +6 -6
  65. package/dist/components/faq/index.js +5 -5
  66. package/dist/components/features/board/aim.d.ts +57 -0
  67. package/dist/components/features/board/aim.d.ts.map +1 -0
  68. package/dist/components/features/board/board-column-header.d.ts.map +1 -1
  69. package/dist/components/features/board/board-column.d.ts +8 -4
  70. package/dist/components/features/board/board-column.d.ts.map +1 -1
  71. package/dist/components/features/board/board.d.ts +15 -4
  72. package/dist/components/features/board/board.d.ts.map +1 -1
  73. package/dist/components/features/board/drop-aim.d.ts +34 -0
  74. package/dist/components/features/board/drop-aim.d.ts.map +1 -0
  75. package/dist/components/features/board/lane-geometry.d.ts +22 -0
  76. package/dist/components/features/board/lane-geometry.d.ts.map +1 -0
  77. package/dist/components/features/board/pending-move.d.ts +77 -0
  78. package/dist/components/features/board/pending-move.d.ts.map +1 -0
  79. package/dist/components/features/board/resolve-drop.d.ts +19 -0
  80. package/dist/components/features/board/resolve-drop.d.ts.map +1 -0
  81. package/dist/components/features/board/ticket-card.d.ts +25 -9
  82. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  83. package/dist/components/features/board/use-board-collapse.d.ts.map +1 -1
  84. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +27 -0
  85. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  86. package/dist/components/features/index.cjs +10 -4
  87. package/dist/components/features/index.cjs.map +1 -1
  88. package/dist/components/features/index.d.ts +1 -0
  89. package/dist/components/features/index.d.ts.map +1 -1
  90. package/dist/components/features/index.js +9 -3
  91. package/dist/components/features/sortable-list/index.d.ts +4 -0
  92. package/dist/components/features/sortable-list/index.d.ts.map +1 -0
  93. package/dist/components/features/sortable-list/slot-geometry.d.ts +57 -0
  94. package/dist/components/features/sortable-list/slot-geometry.d.ts.map +1 -0
  95. package/dist/components/features/sortable-list/sortable-list.d.ts +50 -0
  96. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -0
  97. package/dist/components/features/sortable-list/use-sortable-item.d.ts +29 -0
  98. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -0
  99. package/dist/components/help-center-pages/index.cjs +25 -25
  100. package/dist/components/help-center-pages/index.js +16 -16
  101. package/dist/components/index.cjs +144 -138
  102. package/dist/components/index.cjs.map +1 -1
  103. package/dist/components/index.js +20 -14
  104. package/dist/components/index.js.map +1 -1
  105. package/dist/components/meeting-scheduler/index.cjs +35 -35
  106. package/dist/components/meeting-scheduler/index.js +5 -5
  107. package/dist/components/navigation/index.cjs +4 -4
  108. package/dist/components/navigation/index.js +3 -3
  109. package/dist/components/onboarding-guides/index.cjs +8 -8
  110. package/dist/components/onboarding-guides/index.js +7 -7
  111. package/dist/components/related-content/index.cjs +6 -6
  112. package/dist/components/related-content/index.js +5 -5
  113. package/dist/components/tickets/index.cjs +8 -8
  114. package/dist/components/tickets/index.js +7 -7
  115. package/dist/components/ui/index.cjs +4 -4
  116. package/dist/components/ui/index.js +3 -3
  117. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  118. package/dist/hooks/index.cjs +3 -3
  119. package/dist/hooks/index.js +2 -2
  120. package/dist/index.cjs +10 -4
  121. package/dist/index.cjs.map +1 -1
  122. package/dist/index.js +9 -3
  123. package/dist/nats/index.cjs +2 -2
  124. package/dist/nats/index.js +1 -1
  125. package/dist/nats/nats.d.ts.map +1 -1
  126. package/dist/nats/shared-connection.d.ts +13 -6
  127. package/dist/nats/shared-connection.d.ts.map +1 -1
  128. package/dist/utils/auto-scroll-ancestors.d.ts +43 -0
  129. package/dist/utils/auto-scroll-ancestors.d.ts.map +1 -0
  130. package/dist/utils/drag-effect.d.ts +21 -0
  131. package/dist/utils/drag-effect.d.ts.map +1 -0
  132. package/dist/utils/scroll-parent.d.ts +11 -0
  133. package/dist/utils/scroll-parent.d.ts.map +1 -0
  134. package/package.json +6 -3
  135. package/src/__tests__/board-aim.test.ts +152 -0
  136. package/src/__tests__/board-pending-move.test.ts +113 -0
  137. package/src/__tests__/board-resolve-drop.test.ts +68 -0
  138. package/src/__tests__/sortable-slot-geometry.test.ts +105 -0
  139. package/src/components/features/board/.board-column.md +17 -4
  140. package/src/components/features/board/.board.md +11 -7
  141. package/src/components/features/board/.ticket-card.md +27 -5
  142. package/src/components/features/board/aim.ts +131 -0
  143. package/src/components/features/board/board-column-header.tsx +2 -9
  144. package/src/components/features/board/board-column.tsx +137 -66
  145. package/src/components/features/board/board.tsx +501 -258
  146. package/src/components/features/board/drop-aim.ts +50 -0
  147. package/src/components/features/board/lane-geometry.ts +44 -0
  148. package/src/components/features/board/pending-move.ts +116 -0
  149. package/src/components/features/board/resolve-drop.ts +28 -0
  150. package/src/components/features/board/ticket-card.tsx +256 -47
  151. package/src/components/features/board/use-board-collapse.ts +16 -14
  152. package/src/components/features/board/use-lane-scroll-anchor.ts +79 -0
  153. package/src/components/features/index.ts +1 -0
  154. package/src/components/features/sortable-list/.sortable-list.md +82 -0
  155. package/src/components/features/sortable-list/index.ts +3 -0
  156. package/src/components/features/sortable-list/slot-geometry.ts +100 -0
  157. package/src/components/features/sortable-list/sortable-list.tsx +270 -0
  158. package/src/components/features/sortable-list/use-sortable-item.ts +92 -0
  159. package/src/components/ui/square-avatar.tsx +5 -0
  160. package/src/nats/__tests__/reconnect-lifecycle.test.tsx +814 -0
  161. package/src/nats/nats.ts +56 -13
  162. package/src/nats/shared-connection.ts +132 -36
  163. package/src/stories/.TicketCard.stories.md +6 -12
  164. package/src/stories/Board.stories.tsx +16 -7
  165. package/src/stories/TicketCard.stories.tsx +38 -76
  166. package/src/utils/auto-scroll-ancestors.ts +90 -0
  167. package/src/utils/drag-effect.ts +26 -0
  168. package/src/utils/scroll-parent.ts +23 -0
  169. package/dist/chunk-E56VNTBV.cjs.map +0 -1
  170. package/dist/chunk-GSONSIWG.js.map +0 -1
  171. package/dist/chunk-KQKU4VXB.cjs.map +0 -1
  172. package/dist/chunk-QZ64JOE2.js.map +0 -1
  173. /package/dist/{chunk-6ER3AYJM.js.map → chunk-25YLMIWU.js.map} +0 -0
  174. /package/dist/{chunk-HO3Q7NXJ.js.map → chunk-26HAJBSQ.js.map} +0 -0
  175. /package/dist/{chunk-B2GEKAAA.js.map → chunk-DRNSIK3V.js.map} +0 -0
  176. /package/dist/{chunk-CTTY5EKH.js.map → chunk-FXVMRZIQ.js.map} +0 -0
  177. /package/dist/{chunk-TXV444YV.js.map → chunk-G7ROZAY5.js.map} +0 -0
  178. /package/dist/{chunk-CVCQW7WG.js.map → chunk-LJWTXODF.js.map} +0 -0
  179. /package/dist/{chunk-P7MSXPGL.js.map → chunk-NNGQ6ONX.js.map} +0 -0
  180. /package/dist/{chunk-L36R3G7Z.js.map → chunk-RPCY2FAC.js.map} +0 -0
  181. /package/dist/{chunk-QMQQ37RW.js.map → chunk-RRWWAH37.js.map} +0 -0
  182. /package/dist/{chunk-ODRMPI3Y.js.map → chunk-SBKX2VSG.js.map} +0 -0
  183. /package/dist/{chunk-E2IPBWGT.js.map → chunk-UTEQHK3H.js.map} +0 -0
  184. /package/dist/{chunk-K432ES7N.js.map → chunk-VJL46NHW.js.map} +0 -0
  185. /package/dist/{chunk-VGLDA77S.js.map → chunk-VRX6Y6A7.js.map} +0 -0
  186. /package/dist/{chunk-OR67WC32.js.map → chunk-WBQ3NOH7.js.map} +0 -0
  187. /package/dist/{chunk-UL3WQWTF.js.map → chunk-Y6TXTBIQ.js.map} +0 -0
@@ -7,13 +7,26 @@ A kanban-style board column component with drag-and-drop support, infinite scrol
7
7
  The primary column container. Renders a collapsible column with a header, divider, and body. Supports visual joining with adjacent columns via `joinLeft`/`joinRight` props and applies tinted background colors for non-system columns.
8
8
 
9
9
  ### `ColumnBody` (internal)
10
- Handles the droppable zone (`@dnd-kit/core`), sortable ticket list (`@dnd-kit/sortable`), and infinite scroll via `IntersectionObserver`. Manages refs for both the scroll container and the droppable drop target.
10
+ Handles the lane-wide drop target, drag auto-scroll, and infinite scroll via `IntersectionObserver`.
11
+
12
+ Each card is a drop target in its own right and is reported ahead of the lane, so the lane only ever
13
+ answers for the gaps between them — and it answers with the **nearest card and the edge of it the
14
+ pointer is on**, exactly as that card would have. Reporting a bare lane there sent the ticket to the
15
+ BOTTOM of the column, which made small moves look like they had not registered and large ones like
16
+ the only thing that worked.
17
+
18
+ Auto-scroll on the lane is `autoScrollColumn`: vertical-only overflow reach, so a pointer carried
19
+ below the board scrolls exactly the one lane it is under — a reach that also spread sideways put
20
+ that pointer inside every lane's region at once and scrolled them all. The page's own scrollers are
21
+ registered once by the board, not per lane. The lane body reserves a `py-[5px]` sliver so the
22
+ insertion line is not clipped at the first and last card.
11
23
 
12
24
  ### `SkeletonStack` (internal)
13
25
  Renders a configurable number of `TicketCardSkeleton` placeholders during initial load.
14
26
 
15
27
  ### `EmptyState` (internal)
16
- Displays a centered empty state with a tag icon when a column has no tickets.
28
+ Displays a centered empty state with a tag icon when a column has no tickets — and carries the
29
+ insertion line when a drop is aimed at the empty lane, since there is no card to hang it off.
17
30
 
18
31
  ## Key Props (`BoardColumnProps`)
19
32
 
@@ -21,7 +34,7 @@ Displays a centered empty state with a tag icon when a column has no tickets.
21
34
  |---|---|---|
22
35
  | `column` | `BoardColumnDef` | Column definition including tickets, color, and state flags |
23
36
  | `collapsed` | `boolean` | Collapses column to `w-14` |
24
- | `onToggleCollapse` | `() => void` | Toggle handler |
37
+ | `onToggleCollapse` | `(columnId: string) => void` | Toggle handler — takes the id so one stable handler can serve every lane |
25
38
  | `onLoadMore` | `(columnId: string) => void` | Triggered by intersection sentinel |
26
39
  | `joinLeft` / `joinRight` | `boolean` | Removes borders/radius for adjacent column groups |
27
40
  | `renderAssignSlot` | `(ticket: BoardTicket) => ReactNode` | Custom assignee slot renderer |
@@ -32,7 +45,7 @@ Displays a centered empty state with a tag icon when a column has no tickets.
32
45
  <BoardColumn
33
46
  column={column}
34
47
  collapsed={false}
35
- onToggleCollapse={() => setCollapsed(id, !collapsed)}
48
+ onToggleCollapse={toggle}
36
49
  onAddTicket={(colId) => openNewTicketModal(colId)}
37
50
  getTicketHref={(ticketId) => `/tickets/${ticketId}`}
38
51
  renderAssignSlot={(ticket) => <AvatarPicker ticket={ticket} />}
@@ -1,22 +1,26 @@
1
1
  <!-- source-hash: eec2d729297b9f5f1be4e627c13aa43f -->
2
- Kanban-style drag-and-drop board component built on `@dnd-kit/core`, managing multi-column ticket reordering with cross-column movement, collapse state, and a custom horizontal scrollbar.
2
+ Kanban-style drag-and-drop board built on Pragmatic drag and drop, managing multi-column ticket reordering with cross-column movement, collapse state, and a custom horizontal scrollbar. The board holds no copy of `columns` and changes no state during a drag: a single `monitorForElements` turns the drop into one `onChange`.
3
3
 
4
4
  ## Key Components
5
5
 
6
6
  | Export | Description |
7
7
  |---|---|
8
- | `Board` | Main board component rendering columns with full DnD context |
8
+ | `Board` | Main board component rendering the columns it is given |
9
9
  | `BoardProps` | Interface defining all board configuration and callback props |
10
- | `locate()` | Internal helper to find a ticket across all columns by ID |
11
- | `findIndexInColumn()` | Internal helper to find a ticket's index within a specific column |
12
10
 
13
11
  ## Key Behaviors
14
12
 
15
- - **Collision detection** — Custom strategy combining `pointerWithin`, `rectIntersection`, and `closestCorners` to prioritize ticket-level hits over column-level hits
16
- - **Cross-column guards** — Respects `dropDisabled` and `allowedFromColumns` constraints; reverts state on invalid drops
17
- - **Optimistic UI** — `isDraggingRef` prevents external `columns` prop updates from overwriting in-flight drag state
13
+ - **One monitor, one change** — A single `monitorForElements` watches the whole board; `resolveBoardDrop` (own module, unit-tested) turns the drop into a `BoardChange` or `null`
14
+ - **Which target** — Two nested drop targets answer in the same terms, innermost first: a card, then its lane (for the gaps between cards, resolved to the nearest card and the edge of it the pointer is on). **Outside the lanes there is deliberately no target at all and releasing cancels** — Jira's model; landing a card "somewhere" because the pointer happened to be nearest to it makes a mis-drop unrecoverable. The aim arithmetic itself lives in `aim.ts` (slot indices, so one landing place can never be described two ways), shared verbatim by the pointer, the keyboard and the drop
15
+ - **Nothing moves during a drag** — The dragged card stays put and fades; the landing place is a line. No margins open, no slots close, so the lane's height cannot wander and an edge flip costs one span instead of a relayout plus a card render
16
+ - **One aim, one preview** — Where the drop points is resolved once, in the board's monitor, and handed down (`drop-aim.ts`). Each card used to work it out from its own hover events, which only ever answers for a card the pointer is physically over — so the preview vanished the moment the pointer left the lanes, even though the drop would still have landed. The board's answer is compared before it is stored, so a pointer moving inside one card does not re-render anything
17
+ - **Cross-column guards** — `dropDisabled` and `allowedFromColumns` are enforced by each target's `canDrop`, so an invalid drop is never accepted and nothing has to be reverted
18
+ - **Optimistic view** — The board renders `applyPendingMove(columns, pendingMove)` from the drop until the host's answer arrives (`pending-move.ts`), so the move is on screen instantly and the host's data replaces an identical picture. "Landed" is judged per kind of move: across lanes, arriving in the new lane once the old one has let go (a server may re-sort the lane, so the exact index would never come); within one lane, the exact slot. Weaker tests (a fresh `columns` array, the card leaving its old index) fire on a lane's own poll and snap the card back mid-flight. A 2s timeout is the backstop for a host that never applies the change
19
+ - **Dragged card** — The card under the pointer is the browser's own native drag image (a styled DOM clone via `setCustomNativeDragPreview`), so the browser moves it — over its own chrome included — and carrying a card re-renders nothing and cannot freeze at the viewport edge. On drop, a portal copy takes over for one 160ms FLIP glide from the release point into the slot the move just opened, then hands over to the real card (hidden under it via `LandingCardContext` for exactly that long)
20
+ - **Keyboard move** — Space on a focused card lifts it; the arrows walk it (up/down through slots, left/right through lanes, skipping any that will not take it); space or enter drops it; escape puts it back. Lift-and-place rather than one-press-one-move, because on a board every press would otherwise be its own write to the server. The lifted card feeds the same aim a pointer drag does, and the drop line is scrolled into view on every step — arrow keys bring no auto-scroll of their own. Keys are handled on the board, not the card: once a card is lifted they belong to the move rather than to whatever holds focus. Rules live in `aim.ts`, pure and unit-tested
18
21
  - **Collapse persistence** — Column collapsed state stored via `useBoardCollapse` (keyed by `collapseStorageKey`)
19
22
  - **Custom scrollbar** — Horizontal scrollbar rendered via `useHorizontalScrollbar` with pointer-drag thumb support
23
+ - **Auto-scroll** — The board registers its own scroller and every scrollable ancestor once (`autoScrollAncestors`); each lane registers itself with a vertical-only overflow reach (`autoScrollColumn`), so a pointer below the board scrolls exactly the one lane it is under. `preventUnhandled` + `holdMoveDragEffect` keep the cursor a plain move cursor for the whole drag — without them the first frame and everything outside the lanes draw the browser's green copy cursor
20
24
  - **`onChange` payload** — Emits `BoardChange` with `ticketId`, `fromColumnId`, `toColumnId`, `afterTicketId`, and `beforeTicketId` for precise ordering
21
25
 
22
26
  ## Usage Example
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: db6e303b7af152f7914246835dde7146 -->
2
- Draggable and static board ticket card components for a kanban-style board view, providing both a `dnd-kit`-powered sortable card and a standalone presentational variant.
2
+ Draggable and static board ticket card components for a kanban-style board view, providing both a card that registers its own drag and drop behaviour and a standalone presentational variant.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -7,16 +7,38 @@ Draggable and static board ticket card components for a kanban-style board view,
7
7
  |---|---|---|
8
8
  | `TicketCardBody` | Component | Pure presentational inner content — title, device/org row, priority flag, assignees, tags, timestamp, new message badge, and approval row |
9
9
  | `TicketCardView` | Component | Static, non-draggable card using the same shell and body as the board card; suitable for embeds, previews, and marketing |
10
- | `TicketCard` | Component | Full draggable board card powered by `useSortable` from `@dnd-kit/sortable`; supports overlay mode, optional `href` link, and drag-disabled state |
10
+ | `TicketCard` | Component | Full board card: registers itself as both draggable and drop target, fades in place while carried, draws the insertion line for the shared aim, and supports an optional `href` link plus drag/drop-disabled state |
11
11
  | `TicketCardBodyProps` | Interface | Props for the shared body: `ticket`, `columnColor`, `renderAssignSlot`, `onApprove`, `onReject` |
12
- | `TicketCardProps` | Interface | Extends body props with `columnId`, `href`, `isOverlay`, `dragDisabled`, and board-aware approve/reject signatures |
12
+ | `TicketCardProps` | Interface | Extends body props with `columnId`, `href`, `dragDisabled`, `dropDisabled`, `allowedFromColumns`, and board-aware approve/reject signatures |
13
+
14
+ **Nothing about the lane moves during a drag.** The card being carried stays exactly where it is,
15
+ faded (0.4), and where it would land is drawn as a **line** on the nearest edge of the card it would
16
+ displace — matched on the lane as well as the card id, because a ticket mid-status-change is briefly
17
+ listed by two lanes at once. No room opens, no slot closes, so there is no layout to keep balanced
18
+ and no lane height to wander. A no-op slot (the card's own place) draws nothing.
19
+
20
+ That is a performance decision as much as a visual one. Opening a card's worth of room and drawing a
21
+ copy of the card in it costs a lane relayout plus a whole card render — avatars, tags, tooltips, the
22
+ host's assignee slot — on every edge flip, and the flips come as fast as the pointer moves. The line
23
+ is one absolutely positioned span and says the same thing.
24
+
25
+ The card under the pointer is the browser's **native drag image** — a DOM clone of the card, taken
26
+ in `onGenerateDragPreview` (`setCustomNativeDragPreview` + `preserveOffsetOnSource`), at 0.9 opacity
27
+ with a held shadow. A clone, not a second React tree: it is a photograph of exactly what is on
28
+ screen, assignee slot included, with no providers to re-supply. Native, not a positioned element:
29
+ an element in the page cannot leave the page, so a hand-drawn preview froze whenever the pointer
30
+ crossed into the browser's own chrome.
31
+
32
+ After the drop the board shows the move immediately (see `pending-move.ts`); the landed card is
33
+ hidden under the portal copy gliding into its slot (`useIsLanding`) for the 160ms of that flight,
34
+ and no longer than that.
13
35
 
14
36
  **Constants:** `MAX_VISIBLE_TAGS = 2`, `MAX_VISIBLE_ASSIGNEES = 3`, `PRIORITY_COLOR_CLASS` maps `low | medium | high | urgent` to ODS color tokens.
15
37
 
16
38
  ## Usage Example
17
39
 
18
40
  ```typescript
19
- // Draggable board card (inside a <Board> with dnd-kit context)
41
+ // Draggable board card (inside a <Board>)
20
42
  <TicketCard
21
43
  ticket={ticket}
22
44
  columnId="in-progress"
@@ -26,7 +48,7 @@ Draggable and static board ticket card components for a kanban-style board view,
26
48
  onReject={(ticketId, requestId) => handleReject(ticketId, requestId)}
27
49
  />
28
50
 
29
- // Static preview card (no dnd-kit context required)
51
+ // Static preview card (renders anywhere, registers nothing)
30
52
  <TicketCardView
31
53
  ticket={ticket}
32
54
  columnColor="#6366f1"
@@ -0,0 +1,131 @@
1
+ import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
2
+ import type { BoardChange, BoardColumnDef } from './types'
3
+
4
+ /**
5
+ * Where a card being moved currently points: a lane, and a slot among the cards
6
+ * ALREADY in it — the card being moved never counts as one of them.
7
+ *
8
+ * This is the board's ONE description of a landing place, and everything is
9
+ * expressed in it: the pointer, the keyboard, the line that gets drawn and the
10
+ * change that gets reported. An index cannot say the same thing two ways, which
11
+ * matters more than it sounds — "below card X" and "above card Y" are the same
12
+ * slot, and drawing a line for whichever one a hit test happened to return makes
13
+ * it jump between two positions as the pointer crosses the boundary.
14
+ */
15
+ export interface BoardAim {
16
+ columnId: string
17
+ /** Insertion index among the other cards, so `0..others.length`. */
18
+ index: number
19
+ }
20
+
21
+ export type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
22
+
23
+ /** Whether a lane will take this card — the board's own rules, passed in so this
24
+ * module stays arithmetic. */
25
+ type LaneFilter = (column: BoardColumnDef) => boolean
26
+
27
+ const othersIn = (columns: readonly BoardColumnDef[], columnId: string, ticketId: string) =>
28
+ (columns.find(c => c.id === columnId)?.tickets ?? []).filter(t => t.id !== ticketId)
29
+
30
+ /** The lane a card is in right now, or `null` if it is in none of them. */
31
+ export function laneOf(columns: readonly BoardColumnDef[], ticketId: string): BoardColumnDef | null {
32
+ return columns.find(c => c.tickets.some(t => t.id === ticketId)) ?? null
33
+ }
34
+
35
+ /** Where a card starts: exactly where it already sits. */
36
+ export function initialAim(columns: readonly BoardColumnDef[], ticketId: string): BoardAim | null {
37
+ const lane = laneOf(columns, ticketId)
38
+ if (!lane) return null
39
+ // Its index among the others is its own index: dropping it back at that slot
40
+ // puts it exactly where it came from.
41
+ return { columnId: lane.id, index: lane.tickets.findIndex(t => t.id === ticketId) }
42
+ }
43
+
44
+ /**
45
+ * The aim after one arrow key, or the aim unchanged when the move would leave
46
+ * the board or land in a lane that will not take the card.
47
+ *
48
+ * Up and down step through slots; left and right step through lanes, skipping
49
+ * any the card is not allowed into rather than stopping dead at it — a lane the
50
+ * user cannot use should cost a keypress, not end the journey.
51
+ */
52
+ /**
53
+ * The slot a resolved drop target points at.
54
+ *
55
+ * Card targets carry the edge the pointer is nearest; a lane target means "past
56
+ * the last card". Either way the answer comes out as an index, so the caller
57
+ * never has to care which kind of target replied.
58
+ */
59
+ export function aimFromTarget(
60
+ columns: readonly BoardColumnDef[],
61
+ ticketId: string,
62
+ target: Record<string | symbol, unknown>,
63
+ ): BoardAim | null {
64
+ const columnId = String(target.columnId)
65
+ if (!columns.some(c => c.id === columnId)) return null
66
+ const others = othersIn(columns, columnId, ticketId)
67
+ if (target.type !== 'ticket') return { columnId, index: others.length }
68
+ const over = others.findIndex(t => t.id === String(target.ticketId))
69
+ if (over < 0) return { columnId, index: others.length }
70
+ return { columnId, index: over + (extractClosestEdge(target) === 'bottom' ? 1 : 0) }
71
+ }
72
+
73
+ export function moveAim(
74
+ columns: readonly BoardColumnDef[],
75
+ ticketId: string,
76
+ aim: BoardAim,
77
+ key: ArrowKey,
78
+ accepts: LaneFilter,
79
+ ): BoardAim {
80
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
81
+ const limit = othersIn(columns, aim.columnId, ticketId).length
82
+ const index = Math.min(Math.max(aim.index + (key === 'ArrowUp' ? -1 : 1), 0), limit)
83
+ return index === aim.index ? aim : { ...aim, index }
84
+ }
85
+
86
+ const step = key === 'ArrowLeft' ? -1 : 1
87
+ const from = columns.findIndex(c => c.id === aim.columnId)
88
+ for (let i = from + step; i >= 0 && i < columns.length; i += step) {
89
+ const column = columns[i]
90
+ if (!accepts(column)) continue
91
+ // Keep the slot the card had, as far as the new lane allows.
92
+ const limit = othersIn(columns, column.id, ticketId).length
93
+ return { columnId: column.id, index: Math.min(aim.index, limit) }
94
+ }
95
+ return aim
96
+ }
97
+
98
+ /** The card the preview hangs off, and which side of it — `null` for a lane with
99
+ * nothing else in it. */
100
+ export function aimAnchor(
101
+ columns: readonly BoardColumnDef[],
102
+ ticketId: string,
103
+ aim: BoardAim,
104
+ ): { ticketId: string | null; edge: 'top' | 'bottom' | null } {
105
+ const others = othersIn(columns, aim.columnId, ticketId)
106
+ if (others.length === 0) return { ticketId: null, edge: null }
107
+ if (aim.index < others.length) return { ticketId: others[aim.index].id, edge: 'top' }
108
+ return { ticketId: others[others.length - 1].id, edge: 'bottom' }
109
+ }
110
+
111
+ /**
112
+ * The move to report, or `null` when the card would land back where it started.
113
+ *
114
+ * Same rule as a pointer drop, and deliberately so: a keyboard move that ends
115
+ * where it began must be as silent as a card picked up and put down again.
116
+ */
117
+ export function aimToChange(columns: readonly BoardColumnDef[], ticketId: string, aim: BoardAim): BoardChange | null {
118
+ const lane = laneOf(columns, ticketId)
119
+ if (!lane) return null
120
+
121
+ const others = othersIn(columns, aim.columnId, ticketId)
122
+ const afterTicketId = others[aim.index - 1]?.id ?? null
123
+ const beforeTicketId = others[aim.index]?.id ?? null
124
+
125
+ if (lane.id === aim.columnId) {
126
+ const currentIndex = lane.tickets.findIndex(t => t.id === ticketId)
127
+ if ((lane.tickets[currentIndex - 1]?.id ?? null) === afterTicketId) return null
128
+ }
129
+
130
+ return { ticketId, fromColumnId: lane.id, toColumnId: aim.columnId, afterTicketId, beforeTicketId }
131
+ }
@@ -54,15 +54,8 @@ export function BoardColumnHeader({
54
54
  return (
55
55
  <div className="flex items-center gap-[var(--spacing-system-xsf)]">
56
56
  <div className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xsf)]">
57
- <TicketStatusTag
58
- status={tagStatus}
59
- label={column.label}
60
- color={useStatusVariant ? undefined : column.color}
61
- />
62
- <span
63
- className="text-h5"
64
- style={useStatusVariant ? undefined : { color: column.color }}
65
- >
57
+ <TicketStatusTag status={tagStatus} label={column.label} color={useStatusVariant ? undefined : column.color} />
58
+ <span className="text-h5" style={useStatusVariant ? undefined : { color: column.color }}>
66
59
  {count}
67
60
  </span>
68
61
  </div>
@@ -1,24 +1,29 @@
1
1
  'use client'
2
2
 
3
- import { useDroppable } from '@dnd-kit/core'
4
- import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
5
- import * as React from 'react'
3
+ import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
4
+ import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
6
5
  import { TagIcon } from '../../icons-v2-generated/shopping/tag-icon'
7
6
  import { cn } from '../../../utils/cn'
7
+ import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
8
+ import { autoScrollColumn } from '../../../utils/auto-scroll-ancestors'
8
9
  import { BoardColumnHeader } from './board-column-header'
10
+ import { useDropAim } from './drop-aim'
11
+ import { DROP_LINE_ATTRIBUTE, nearestCardIn } from './lane-geometry'
9
12
  import { tintOnDark } from './color-utils'
10
13
  import { TicketCard } from './ticket-card'
11
14
  import { TicketCardSkeleton } from './ticket-card-skeleton'
15
+ import { TICKET_ID_ATTRIBUTE, useLaneScrollAnchor } from './use-lane-scroll-anchor'
12
16
  import type { BoardColumnDef, BoardTicket } from './types'
13
17
 
14
18
  export interface BoardColumnProps {
15
19
  column: BoardColumnDef
16
20
  collapsed?: boolean
17
- onToggleCollapse: () => void
21
+ /** Takes the id so the board can pass one stable handler to every lane. */
22
+ onToggleCollapse: (columnId: string) => void
18
23
  onAddTicket?: (columnId: string) => void
19
24
  onArchive?: (columnId: string) => void
20
25
  getTicketHref?: (ticketId: string) => string
21
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
26
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
22
27
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
23
28
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
24
29
  onLoadMore?: (columnId: string) => void
@@ -27,7 +32,10 @@ export interface BoardColumnProps {
27
32
  joinRight?: boolean
28
33
  }
29
34
 
30
- export function BoardColumn({
35
+ /** Memoized: `Board` rebuilds only the two columns a drag touches, so the
36
+ * untouched ones keep their object identity and can skip the render entirely.
37
+ * Without this, every pointer move that shifts a card re-renders all lanes. */
38
+ export const BoardColumn = memo(function BoardColumn({
31
39
  column,
32
40
  collapsed = false,
33
41
  onToggleCollapse,
@@ -42,8 +50,59 @@ export function BoardColumn({
42
50
  joinLeft = false,
43
51
  joinRight = false,
44
52
  }: BoardColumnProps) {
53
+ // The drop zone is the WHOLE lane, header included — not just the list. A
54
+ // target that stops at the list leaves dead strips (the header, the padding)
55
+ // where the pointer is over a column yet hits nothing. Collapsed lanes stay
56
+ // out: they render no list to drop into.
57
+ //
58
+ // A card sitting inside this lane is a drop target too, and Pragmatic reports
59
+ // the innermost one first — so the lane only ever answers for the gaps, and
60
+ // "which card" never has to be guessed from rect overlap.
61
+ const laneRef = useRef<HTMLDivElement>(null)
62
+ const [isOver, setIsOver] = useState(false)
63
+
64
+ useEffect(() => {
65
+ const element = laneRef.current
66
+ if (!element || collapsed || column.dropDisabled) return
67
+
68
+ return dropTargetForElements({
69
+ element,
70
+ canDrop: ({ source }) => {
71
+ if (source.data.type !== 'ticket') return false
72
+ const from = source.data.columnId
73
+ if (from === column.id) return true
74
+ return !column.allowedFromColumns || column.allowedFromColumns.includes(String(from))
75
+ },
76
+ // A drop that lands between cards — most of the lane, once a hovered card
77
+ // has opened the room the preview is drawn in — answers with the nearest
78
+ // card and the edge of it the pointer is on, exactly as that card would
79
+ // have. Reporting a bare lane instead sent the ticket to the BOTTOM of the
80
+ // column, which is what made small moves look like they had not worked and
81
+ // large ones like the only thing that did.
82
+ getData: ({ input, element: lane, source }) => {
83
+ const nearest = nearestCardIn(lane, input.clientY, String(source.data.ticketId))
84
+ const ticketId = nearest?.dataset.ticketId
85
+ // An empty lane, or one whose only card is the one being dragged: the
86
+ // drop appends, which is the only place it could go.
87
+ if (!nearest || !ticketId) return { type: 'column', columnId: column.id }
88
+ return attachClosestEdge(
89
+ { type: 'ticket', ticketId, columnId: column.id },
90
+ { input, element: nearest, allowedEdges: ['top', 'bottom'] },
91
+ )
92
+ },
93
+ onDragEnter: () => setIsOver(true),
94
+ onDragLeave: () => setIsOver(false),
95
+ onDrop: () => setIsOver(false),
96
+ })
97
+ }, [column.id, column.dropDisabled, column.allowedFromColumns, collapsed])
98
+
99
+ // The header's own prop stays zero-arg; the id is bound here, where it is
100
+ // already known, rather than by the board building one closure per lane.
101
+ const handleToggleCollapse = useCallback(() => onToggleCollapse(column.id), [onToggleCollapse, column.id])
102
+
45
103
  return (
46
104
  <div
105
+ ref={laneRef}
47
106
  className={cn(
48
107
  'flex h-full shrink-0 flex-col gap-[var(--spacing-system-sf)] overflow-hidden rounded-md border border-ods-border p-[var(--spacing-system-sf)]',
49
108
  'transition-[width] duration-300 ease-out',
@@ -51,13 +110,14 @@ export function BoardColumn({
51
110
  column.system && 'bg-ods-card',
52
111
  joinLeft && 'rounded-l-none border-l-0',
53
112
  joinRight && 'rounded-r-none',
113
+ isOver && 'outline outline-2 -outline-offset-2 outline-ods-focus',
54
114
  )}
55
115
  style={column.system ? undefined : { backgroundColor: tintOnDark(column.color) }}
56
116
  >
57
117
  <BoardColumnHeader
58
118
  column={column}
59
119
  collapsed={collapsed}
60
- onToggleCollapse={onToggleCollapse}
120
+ onToggleCollapse={handleToggleCollapse}
61
121
  onAddTicket={!collapsed && onAddTicket ? () => onAddTicket(column.id) : undefined}
62
122
  onArchive={!collapsed && column.archivable && onArchive ? () => onArchive(column.id) : undefined}
63
123
  />
@@ -77,40 +137,49 @@ export function BoardColumn({
77
137
  )}
78
138
  </div>
79
139
  )
80
- }
140
+ })
81
141
 
82
142
  interface ColumnBodyProps {
83
143
  column: BoardColumnDef
84
144
  getTicketHref?: (ticketId: string) => string
85
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
145
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
86
146
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
87
147
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
88
148
  onLoadMore?: (columnId: string) => void
89
149
  loadMoreRootMargin: string
90
150
  }
91
151
 
92
- function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReject, onLoadMore, loadMoreRootMargin }: ColumnBodyProps) {
93
- const ticketIds = React.useMemo(() => column.tickets.map(t => t.id), [column.tickets])
152
+ function ColumnBody({
153
+ column,
154
+ getTicketHref,
155
+ renderAssignSlot,
156
+ onApprove,
157
+ onReject,
158
+ onLoadMore,
159
+ loadMoreRootMargin,
160
+ }: ColumnBodyProps) {
161
+ const scrollRef = useRef<HTMLDivElement | null>(null)
162
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
94
163
 
95
- const droppableData = React.useMemo(
96
- () => ({ columnId: column.id, type: 'column' as const }),
97
- [column.id],
98
- )
99
- const { setNodeRef: setDroppableRef, isOver } = useDroppable({
100
- id: `column:${column.id}`,
101
- data: droppableData,
102
- disabled: column.dropDisabled,
103
- })
164
+ useLaneScrollAnchor(scrollRef, column.tickets)
104
165
 
105
- const scrollRef = React.useRef<HTMLDivElement | null>(null)
106
- const sentinelRef = React.useRef<HTMLDivElement | null>(null)
166
+ // Dragging near the top or bottom of a long lane scrolls THIS lane, and only
167
+ // this one — the reach past the lane's edges is vertical, so a pointer below
168
+ // the board is below exactly one column. The page's own scrollers are
169
+ // registered once by the board, not once per lane: registering them here gave
170
+ // `<main>` one auto-scroller per column, all pulling at the same time.
171
+ useEffect(() => {
172
+ const element = scrollRef.current
173
+ if (!element) return
174
+ return autoScrollColumn(element)
175
+ }, [])
107
176
 
108
- const loadMoreRef = React.useRef(onLoadMore)
177
+ const loadMoreRef = useRef(onLoadMore)
109
178
  loadMoreRef.current = onLoadMore
110
- const columnIdRef = React.useRef(column.id)
179
+ const columnIdRef = useRef(column.id)
111
180
  columnIdRef.current = column.id
112
181
 
113
- React.useEffect(() => {
182
+ useEffect(() => {
114
183
  if (!column.hasMore || column.isLoadingMore) return
115
184
  const sentinel = sentinelRef.current
116
185
  const root = scrollRef.current
@@ -128,43 +197,40 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
128
197
  return () => observer.disconnect()
129
198
  }, [column.hasMore, column.isLoadingMore, loadMoreRootMargin])
130
199
 
131
- const setBodyRef = React.useCallback(
132
- (el: HTMLDivElement | null) => {
133
- scrollRef.current = el
134
- setDroppableRef(el)
135
- },
136
- [setDroppableRef],
137
- )
138
-
139
200
  return (
201
+ // `overflow-anchor:none` hands scroll anchoring to `useLaneScrollAnchor`
202
+ // wholesale. The browser's own anchoring is unusable here — it silently
203
+ // skips transformed elements, so it works between drags and does nothing
204
+ // during one — and leaving both on double-corrects every insertion.
140
205
  <div
141
- ref={setBodyRef}
142
- className={cn(
143
- 'flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-xs)] overflow-y-auto',
144
- isOver && 'rounded-md outline outline-2 outline-offset-2 outline-ods-focus',
145
- )}
206
+ ref={scrollRef}
207
+ // `py-[5px]`: the insertion line sits in the middle of the gap between two
208
+ // cards, and the first and last card have no gap on their outer side. This
209
+ // is the sliver it lives in there — without it the line is clipped away by
210
+ // this element's own overflow exactly when a card is aimed at either end.
211
+ className="flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-xs)] overflow-y-auto py-[5px] [overflow-anchor:none]"
146
212
  >
147
- <SortableContext items={ticketIds} strategy={verticalListSortingStrategy}>
148
- {column.isLoading ? (
149
- <SkeletonStack />
150
- ) : column.tickets.length === 0 ? (
151
- <EmptyState />
152
- ) : (
153
- column.tickets.map(t => (
154
- <TicketCard
155
- key={t.id}
156
- ticket={t}
157
- columnId={column.id}
158
- columnColor={column.color}
159
- href={getTicketHref?.(t.id)}
160
- renderAssignSlot={renderAssignSlot}
161
- onApprove={onApprove}
162
- onReject={onReject}
163
- dragDisabled={column.dragDisabled}
164
- />
165
- ))
166
- )}
167
- </SortableContext>
213
+ {column.isLoading ? (
214
+ <SkeletonStack />
215
+ ) : column.tickets.length === 0 ? (
216
+ <EmptyState columnId={column.id} />
217
+ ) : (
218
+ column.tickets.map(t => (
219
+ <TicketCard
220
+ key={t.id}
221
+ ticket={t}
222
+ columnId={column.id}
223
+ columnColor={column.color}
224
+ href={getTicketHref?.(t.id)}
225
+ renderAssignSlot={renderAssignSlot}
226
+ onApprove={onApprove}
227
+ onReject={onReject}
228
+ dragDisabled={column.dragDisabled}
229
+ dropDisabled={column.dropDisabled}
230
+ allowedFromColumns={column.allowedFromColumns}
231
+ />
232
+ ))
233
+ )}
168
234
  {column.isLoadingMore && !column.isLoading && <TicketCardSkeleton />}
169
235
  {column.hasMore && <div ref={sentinelRef} aria-hidden className="h-1 shrink-0" />}
170
236
  </div>
@@ -172,20 +238,25 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
172
238
  }
173
239
 
174
240
  function SkeletonStack({ count = 4 }: { count?: number }) {
175
- const keys = React.useMemo(
176
- () => Array.from({ length: count }, () => Math.random().toString(36).slice(2)),
177
- [count],
178
- )
241
+ // Position IS the identity here: the rows are interchangeable placeholders in
242
+ // a fixed-length list, so the index is the correct key. Random keys remounted
243
+ // every row whenever this re-rendered with a different `count`.
179
244
  return (
180
245
  <>
181
- {keys.map(k => (
182
- <TicketCardSkeleton key={k} />
246
+ {Array.from({ length: count }, (_, i) => (
247
+ <TicketCardSkeleton key={`skeleton-${i}`} />
183
248
  ))}
184
249
  </>
185
250
  )
186
251
  }
187
252
 
188
- function EmptyState() {
253
+ /** Also carries the insertion line when a drop is aimed here, since the lane has
254
+ * no card to hang it off. */
255
+ function EmptyState({ columnId }: { columnId: string }) {
256
+ const aim = useDropAim()
257
+ if (aim && aim.columnId === columnId) {
258
+ return <div aria-hidden {...{ [DROP_LINE_ATTRIBUTE]: '' }} className="h-0.5 shrink-0 rounded-full bg-ods-accent" />
259
+ }
189
260
  return (
190
261
  <div className="flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-lf)] p-[var(--spacing-system-lf)] text-center text-ods-text-secondary">
191
262
  <TagIcon className="h-6 w-6 shrink-0" />