@flamingo-stack/openframe-frontend-core 0.0.556 → 0.0.557-1828.4873.1

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 (163) hide show
  1. package/dist/{chunk-5756Q6UN.js → chunk-25YLMIWU.js} +2 -2
  2. package/dist/{chunk-FLSEJSTN.js → chunk-26HAJBSQ.js} +2 -2
  3. package/dist/{chunk-JDUPAHJR.cjs → chunk-33FOBAWS.cjs} +11 -11
  4. package/dist/{chunk-JDUPAHJR.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
  5. package/dist/{chunk-CRGIXNUE.cjs → chunk-4EGNYOZA.cjs} +20 -20
  6. package/dist/{chunk-CRGIXNUE.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
  7. package/dist/{chunk-SXMJLNOF.cjs → chunk-4WREPWRL.cjs} +3 -3
  8. package/dist/{chunk-SXMJLNOF.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
  9. package/dist/{chunk-LZD3VSPS.cjs → chunk-5HNZTPAB.cjs} +9 -9
  10. package/dist/{chunk-LZD3VSPS.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
  11. package/dist/{chunk-HYABNMBN.cjs → chunk-5JF6H7OJ.cjs} +62 -62
  12. package/dist/{chunk-HYABNMBN.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
  13. package/dist/{chunk-BNMKHG2L.cjs → chunk-64HHDGRN.cjs} +4 -4
  14. package/dist/{chunk-BNMKHG2L.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
  15. package/dist/{chunk-Z3R3LOC5.cjs → chunk-6YXEZCVG.cjs} +5 -5
  16. package/dist/{chunk-Z3R3LOC5.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
  17. package/dist/{chunk-BZKUID4R.cjs → chunk-6ZOVSD4M.cjs} +14 -14
  18. package/dist/{chunk-BZKUID4R.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
  19. package/dist/{chunk-Q5SZ2QTZ.cjs → chunk-A6TAVIBX.cjs} +39 -39
  20. package/dist/{chunk-Q5SZ2QTZ.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
  21. package/dist/{chunk-ICG7JRYT.cjs → chunk-BE6TRUKY.cjs} +31 -31
  22. package/dist/{chunk-ICG7JRYT.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
  23. package/dist/{chunk-JRWOHH7K.js → chunk-DRNSIK3V.js} +2 -2
  24. package/dist/{chunk-VF4DCOPD.js → chunk-FXVMRZIQ.js} +4 -4
  25. package/dist/{chunk-XGBVHQP7.js → chunk-G3NBTL4I.js} +3244 -2555
  26. package/dist/chunk-G3NBTL4I.js.map +1 -0
  27. package/dist/{chunk-JXJ4FTKN.js → chunk-G7ROZAY5.js} +2 -2
  28. package/dist/{chunk-S4LL6QI6.cjs → chunk-IPO24G7U.cjs} +1630 -941
  29. package/dist/chunk-IPO24G7U.cjs.map +1 -0
  30. package/dist/{chunk-5M4PS3XX.js → chunk-LJWTXODF.js} +2 -2
  31. package/dist/{chunk-CGUSNV6R.js → chunk-NNGQ6ONX.js} +6 -6
  32. package/dist/{chunk-HOEOGFQM.cjs → chunk-QCBBX6DO.cjs} +86 -86
  33. package/dist/{chunk-HOEOGFQM.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
  34. package/dist/{chunk-VXNMOGRP.js → chunk-RPCY2FAC.js} +4 -4
  35. package/dist/{chunk-WX5UCLL7.js → chunk-RRWWAH37.js} +2 -2
  36. package/dist/{chunk-5JWLZJK5.js → chunk-SBKX2VSG.js} +2 -2
  37. package/dist/{chunk-5EES5HD4.js → chunk-UTEQHK3H.js} +5 -5
  38. package/dist/{chunk-7Q7GFIFB.cjs → chunk-V5EYGEX7.cjs} +7 -7
  39. package/dist/{chunk-7Q7GFIFB.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
  40. package/dist/{chunk-YM2WHYYK.js → chunk-VJL46NHW.js} +2 -2
  41. package/dist/{chunk-FZT7BYNF.js → chunk-Y6TXTBIQ.js} +2 -2
  42. package/dist/{chunk-C74PZU6M.cjs → chunk-YHRE4JBF.cjs} +26 -26
  43. package/dist/{chunk-C74PZU6M.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/board/aim.d.ts +57 -0
  57. package/dist/components/features/board/aim.d.ts.map +1 -0
  58. package/dist/components/features/board/board-column-header.d.ts.map +1 -1
  59. package/dist/components/features/board/board-column.d.ts +8 -4
  60. package/dist/components/features/board/board-column.d.ts.map +1 -1
  61. package/dist/components/features/board/board.d.ts +15 -4
  62. package/dist/components/features/board/board.d.ts.map +1 -1
  63. package/dist/components/features/board/drop-aim.d.ts +34 -0
  64. package/dist/components/features/board/drop-aim.d.ts.map +1 -0
  65. package/dist/components/features/board/lane-geometry.d.ts +22 -0
  66. package/dist/components/features/board/lane-geometry.d.ts.map +1 -0
  67. package/dist/components/features/board/pending-move.d.ts +77 -0
  68. package/dist/components/features/board/pending-move.d.ts.map +1 -0
  69. package/dist/components/features/board/resolve-drop.d.ts +19 -0
  70. package/dist/components/features/board/resolve-drop.d.ts.map +1 -0
  71. package/dist/components/features/board/ticket-card.d.ts +25 -9
  72. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  73. package/dist/components/features/board/use-board-collapse.d.ts.map +1 -1
  74. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +27 -0
  75. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  76. package/dist/components/features/index.cjs +8 -2
  77. package/dist/components/features/index.cjs.map +1 -1
  78. package/dist/components/features/index.d.ts +1 -0
  79. package/dist/components/features/index.d.ts.map +1 -1
  80. package/dist/components/features/index.js +7 -1
  81. package/dist/components/features/sortable-list/index.d.ts +4 -0
  82. package/dist/components/features/sortable-list/index.d.ts.map +1 -0
  83. package/dist/components/features/sortable-list/slot-geometry.d.ts +57 -0
  84. package/dist/components/features/sortable-list/slot-geometry.d.ts.map +1 -0
  85. package/dist/components/features/sortable-list/sortable-list.d.ts +50 -0
  86. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -0
  87. package/dist/components/features/sortable-list/use-sortable-item.d.ts +29 -0
  88. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -0
  89. package/dist/components/help-center-pages/index.cjs +22 -22
  90. package/dist/components/help-center-pages/index.js +13 -13
  91. package/dist/components/index.cjs +137 -131
  92. package/dist/components/index.cjs.map +1 -1
  93. package/dist/components/index.js +17 -11
  94. package/dist/components/index.js.map +1 -1
  95. package/dist/components/meeting-scheduler/index.cjs +28 -28
  96. package/dist/components/meeting-scheduler/index.js +3 -3
  97. package/dist/components/navigation/index.cjs +2 -2
  98. package/dist/components/navigation/index.js +1 -1
  99. package/dist/components/onboarding-guides/index.cjs +5 -5
  100. package/dist/components/onboarding-guides/index.js +4 -4
  101. package/dist/components/related-content/index.cjs +3 -3
  102. package/dist/components/related-content/index.js +2 -2
  103. package/dist/components/tickets/index.cjs +6 -6
  104. package/dist/components/tickets/index.js +5 -5
  105. package/dist/components/ui/index.cjs +2 -2
  106. package/dist/components/ui/index.js +1 -1
  107. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.cjs.map +1 -1
  110. package/dist/index.js +7 -1
  111. package/dist/utils/auto-scroll-ancestors.d.ts +43 -0
  112. package/dist/utils/auto-scroll-ancestors.d.ts.map +1 -0
  113. package/dist/utils/drag-effect.d.ts +21 -0
  114. package/dist/utils/drag-effect.d.ts.map +1 -0
  115. package/dist/utils/scroll-parent.d.ts +11 -0
  116. package/dist/utils/scroll-parent.d.ts.map +1 -0
  117. package/package.json +6 -3
  118. package/src/__tests__/board-aim.test.ts +152 -0
  119. package/src/__tests__/board-pending-move.test.ts +113 -0
  120. package/src/__tests__/board-resolve-drop.test.ts +68 -0
  121. package/src/__tests__/sortable-slot-geometry.test.ts +105 -0
  122. package/src/components/features/board/.board-column.md +17 -4
  123. package/src/components/features/board/.board.md +11 -7
  124. package/src/components/features/board/.ticket-card.md +27 -5
  125. package/src/components/features/board/aim.ts +131 -0
  126. package/src/components/features/board/board-column-header.tsx +2 -9
  127. package/src/components/features/board/board-column.tsx +137 -66
  128. package/src/components/features/board/board.tsx +501 -258
  129. package/src/components/features/board/drop-aim.ts +50 -0
  130. package/src/components/features/board/lane-geometry.ts +44 -0
  131. package/src/components/features/board/pending-move.ts +116 -0
  132. package/src/components/features/board/resolve-drop.ts +28 -0
  133. package/src/components/features/board/ticket-card.tsx +256 -47
  134. package/src/components/features/board/use-board-collapse.ts +16 -14
  135. package/src/components/features/board/use-lane-scroll-anchor.ts +79 -0
  136. package/src/components/features/index.ts +1 -0
  137. package/src/components/features/sortable-list/.sortable-list.md +82 -0
  138. package/src/components/features/sortable-list/index.ts +3 -0
  139. package/src/components/features/sortable-list/slot-geometry.ts +100 -0
  140. package/src/components/features/sortable-list/sortable-list.tsx +270 -0
  141. package/src/components/features/sortable-list/use-sortable-item.ts +92 -0
  142. package/src/components/ui/square-avatar.tsx +5 -0
  143. package/src/stories/.TicketCard.stories.md +6 -12
  144. package/src/stories/Board.stories.tsx +16 -7
  145. package/src/stories/TicketCard.stories.tsx +38 -76
  146. package/src/utils/auto-scroll-ancestors.ts +90 -0
  147. package/src/utils/drag-effect.ts +26 -0
  148. package/src/utils/scroll-parent.ts +23 -0
  149. package/dist/chunk-S4LL6QI6.cjs.map +0 -1
  150. package/dist/chunk-XGBVHQP7.js.map +0 -1
  151. /package/dist/{chunk-5756Q6UN.js.map → chunk-25YLMIWU.js.map} +0 -0
  152. /package/dist/{chunk-FLSEJSTN.js.map → chunk-26HAJBSQ.js.map} +0 -0
  153. /package/dist/{chunk-JRWOHH7K.js.map → chunk-DRNSIK3V.js.map} +0 -0
  154. /package/dist/{chunk-VF4DCOPD.js.map → chunk-FXVMRZIQ.js.map} +0 -0
  155. /package/dist/{chunk-JXJ4FTKN.js.map → chunk-G7ROZAY5.js.map} +0 -0
  156. /package/dist/{chunk-5M4PS3XX.js.map → chunk-LJWTXODF.js.map} +0 -0
  157. /package/dist/{chunk-CGUSNV6R.js.map → chunk-NNGQ6ONX.js.map} +0 -0
  158. /package/dist/{chunk-VXNMOGRP.js.map → chunk-RPCY2FAC.js.map} +0 -0
  159. /package/dist/{chunk-WX5UCLL7.js.map → chunk-RRWWAH37.js.map} +0 -0
  160. /package/dist/{chunk-5JWLZJK5.js.map → chunk-SBKX2VSG.js.map} +0 -0
  161. /package/dist/{chunk-5EES5HD4.js.map → chunk-UTEQHK3H.js.map} +0 -0
  162. /package/dist/{chunk-YM2WHYYK.js.map → chunk-VJL46NHW.js.map} +0 -0
  163. /package/dist/{chunk-FZT7BYNF.js.map → chunk-Y6TXTBIQ.js.map} +0 -0
@@ -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" />