@flamingo-stack/openframe-frontend-core 0.0.552 → 0.0.553-1815.4815.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 (109) hide show
  1. package/dist/{chunk-RMWLUKTC.cjs → chunk-2EOL4GLM.cjs} +11 -11
  2. package/dist/{chunk-RMWLUKTC.cjs.map → chunk-2EOL4GLM.cjs.map} +1 -1
  3. package/dist/{chunk-4XG7TG7B.cjs → chunk-2JWKBJIP.cjs} +7 -7
  4. package/dist/{chunk-4XG7TG7B.cjs.map → chunk-2JWKBJIP.cjs.map} +1 -1
  5. package/dist/{chunk-PVPXI5V6.cjs → chunk-2T5ZC2RO.cjs} +31 -31
  6. package/dist/{chunk-PVPXI5V6.cjs.map → chunk-2T5ZC2RO.cjs.map} +1 -1
  7. package/dist/{chunk-RMTTW7LG.cjs → chunk-5FMWTAX5.cjs} +27 -27
  8. package/dist/{chunk-RMTTW7LG.cjs.map → chunk-5FMWTAX5.cjs.map} +1 -1
  9. package/dist/{chunk-MVE2YQVG.cjs → chunk-6746LTZC.cjs} +20 -20
  10. package/dist/{chunk-MVE2YQVG.cjs.map → chunk-6746LTZC.cjs.map} +1 -1
  11. package/dist/{chunk-6MHMEF5W.cjs → chunk-6POBPMSN.cjs} +39 -39
  12. package/dist/{chunk-6MHMEF5W.cjs.map → chunk-6POBPMSN.cjs.map} +1 -1
  13. package/dist/{chunk-6J6Q5QEM.cjs → chunk-7BUY7VKW.cjs} +4 -4
  14. package/dist/{chunk-6J6Q5QEM.cjs.map → chunk-7BUY7VKW.cjs.map} +1 -1
  15. package/dist/{chunk-Z5UK7SKC.js → chunk-7FWRJBNW.js} +4 -4
  16. package/dist/{chunk-KSGQG6WU.js → chunk-AADAEOJA.js} +2 -2
  17. package/dist/{chunk-SIXNMDYY.js → chunk-AXUS7Y7N.js} +2 -2
  18. package/dist/{chunk-7TRIUG7Q.js → chunk-CM75WCSO.js} +5 -5
  19. package/dist/{chunk-SXXIV7IL.cjs → chunk-DG5D5XCS.cjs} +86 -86
  20. package/dist/{chunk-SXXIV7IL.cjs.map → chunk-DG5D5XCS.cjs.map} +1 -1
  21. package/dist/{chunk-RJSDYOFD.js → chunk-HECGJFP4.js} +2 -2
  22. package/dist/{chunk-X4RCWYYF.cjs → chunk-HUZD2MJC.cjs} +807 -695
  23. package/dist/chunk-HUZD2MJC.cjs.map +1 -0
  24. package/dist/{chunk-HK5YRHEN.cjs → chunk-I7Y2S3DR.cjs} +9 -9
  25. package/dist/{chunk-HK5YRHEN.cjs.map → chunk-I7Y2S3DR.cjs.map} +1 -1
  26. package/dist/{chunk-TI75367M.js → chunk-K5SQ625X.js} +660 -548
  27. package/dist/chunk-K5SQ625X.js.map +1 -0
  28. package/dist/{chunk-DOPZ5LCH.cjs → chunk-LXLAVFK3.cjs} +14 -14
  29. package/dist/{chunk-DOPZ5LCH.cjs.map → chunk-LXLAVFK3.cjs.map} +1 -1
  30. package/dist/{chunk-6XOJTTWY.js → chunk-M2XQSLNS.js} +2 -2
  31. package/dist/{chunk-XEOZB5DX.cjs → chunk-PNNO5CMG.cjs} +3 -3
  32. package/dist/{chunk-XEOZB5DX.cjs.map → chunk-PNNO5CMG.cjs.map} +1 -1
  33. package/dist/{chunk-OVQ3O66O.cjs → chunk-QD7WBLQ7.cjs} +5 -5
  34. package/dist/{chunk-OVQ3O66O.cjs.map → chunk-QD7WBLQ7.cjs.map} +1 -1
  35. package/dist/{chunk-CMDPARDV.js → chunk-QJ4KTTJB.js} +2 -2
  36. package/dist/{chunk-7MPOW46J.js → chunk-RXMDOLDH.js} +2 -2
  37. package/dist/{chunk-Y64725GI.cjs → chunk-SQADRYGF.cjs} +61 -61
  38. package/dist/{chunk-Y64725GI.cjs.map → chunk-SQADRYGF.cjs.map} +1 -1
  39. package/dist/{chunk-Y6PD76R6.js → chunk-SUGA42SR.js} +2 -2
  40. package/dist/{chunk-WXDDPK7T.js → chunk-VW5KSRGZ.js} +4 -4
  41. package/dist/{chunk-BFOXIIM4.js → chunk-Y7SI6UU5.js} +2 -2
  42. package/dist/{chunk-RJEP74Z4.js → chunk-YHMRC24T.js} +2 -2
  43. package/dist/{chunk-4DD3223G.js → chunk-ZK2UHBEH.js} +6 -6
  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/board-column.d.ts +6 -3
  57. package/dist/components/features/board/board-column.d.ts.map +1 -1
  58. package/dist/components/features/board/board.d.ts +3 -3
  59. package/dist/components/features/board/board.d.ts.map +1 -1
  60. package/dist/components/features/board/drop-target.d.ts +79 -0
  61. package/dist/components/features/board/drop-target.d.ts.map +1 -0
  62. package/dist/components/features/board/ticket-card.d.ts +11 -6
  63. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  64. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +24 -0
  65. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  66. package/dist/components/features/index.cjs +2 -2
  67. package/dist/components/features/index.js +1 -1
  68. package/dist/components/help-center-pages/index.cjs +22 -22
  69. package/dist/components/help-center-pages/index.js +13 -13
  70. package/dist/components/index.cjs +131 -131
  71. package/dist/components/index.js +11 -11
  72. package/dist/components/meeting-scheduler/index.cjs +28 -28
  73. package/dist/components/meeting-scheduler/index.js +3 -3
  74. package/dist/components/navigation/index.cjs +2 -2
  75. package/dist/components/navigation/index.js +1 -1
  76. package/dist/components/onboarding-guides/index.cjs +5 -5
  77. package/dist/components/onboarding-guides/index.js +4 -4
  78. package/dist/components/related-content/index.cjs +3 -3
  79. package/dist/components/related-content/index.js +2 -2
  80. package/dist/components/tickets/index.cjs +6 -6
  81. package/dist/components/tickets/index.js +5 -5
  82. package/dist/components/ui/index.cjs +2 -2
  83. package/dist/components/ui/index.js +1 -1
  84. package/dist/index.cjs +2 -2
  85. package/dist/index.js +1 -1
  86. package/package.json +1 -1
  87. package/src/__tests__/board-drag-guard.test.tsx +146 -0
  88. package/src/__tests__/board-drop-target.test.ts +262 -0
  89. package/src/components/features/board/board-column.tsx +50 -40
  90. package/src/components/features/board/board.tsx +48 -40
  91. package/src/components/features/board/drop-target.ts +188 -0
  92. package/src/components/features/board/ticket-card.tsx +36 -16
  93. package/src/components/features/board/use-lane-scroll-anchor.ts +75 -0
  94. package/src/stories/Board.stories.tsx +16 -7
  95. package/dist/chunk-TI75367M.js.map +0 -1
  96. package/dist/chunk-X4RCWYYF.cjs.map +0 -1
  97. /package/dist/{chunk-Z5UK7SKC.js.map → chunk-7FWRJBNW.js.map} +0 -0
  98. /package/dist/{chunk-KSGQG6WU.js.map → chunk-AADAEOJA.js.map} +0 -0
  99. /package/dist/{chunk-SIXNMDYY.js.map → chunk-AXUS7Y7N.js.map} +0 -0
  100. /package/dist/{chunk-7TRIUG7Q.js.map → chunk-CM75WCSO.js.map} +0 -0
  101. /package/dist/{chunk-RJSDYOFD.js.map → chunk-HECGJFP4.js.map} +0 -0
  102. /package/dist/{chunk-6XOJTTWY.js.map → chunk-M2XQSLNS.js.map} +0 -0
  103. /package/dist/{chunk-CMDPARDV.js.map → chunk-QJ4KTTJB.js.map} +0 -0
  104. /package/dist/{chunk-7MPOW46J.js.map → chunk-RXMDOLDH.js.map} +0 -0
  105. /package/dist/{chunk-Y6PD76R6.js.map → chunk-SUGA42SR.js.map} +0 -0
  106. /package/dist/{chunk-WXDDPK7T.js.map → chunk-VW5KSRGZ.js.map} +0 -0
  107. /package/dist/{chunk-BFOXIIM4.js.map → chunk-Y7SI6UU5.js.map} +0 -0
  108. /package/dist/{chunk-RJEP74Z4.js.map → chunk-YHMRC24T.js.map} +0 -0
  109. /package/dist/{chunk-4DD3223G.js.map → chunk-ZK2UHBEH.js.map} +0 -0
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ClientRect, Collision, DroppableContainer, UniqueIdentifier } from '@dnd-kit/core'
3
+ import {
4
+ createDropTargetResolver,
5
+ detectCollisions,
6
+ type BoardDroppableData,
7
+ type CollisionArgs,
8
+ type FrameScheduler,
9
+ } from '../components/features/board/drop-target'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Fixtures — a two-lane board laid out by hand. dnd-kit's collision helpers are
13
+ // pure functions over rects, so no DOM layout is needed to exercise them.
14
+ //
15
+ // x: 0 400 416 816 lane width 400, 16px lane gap
16
+ // COL-A cards at x 0..400, COL-B cards at x 416..816
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const rect = (left: number, top: number, width: number, height: number): ClientRect => ({
20
+ left,
21
+ top,
22
+ width,
23
+ height,
24
+ right: left + width,
25
+ bottom: top + height,
26
+ })
27
+
28
+ interface Droppable {
29
+ id: UniqueIdentifier
30
+ data: BoardDroppableData
31
+ rect: ClientRect
32
+ }
33
+
34
+ const LANE_A: Droppable = { id: 'column:a', data: { type: 'column', columnId: 'a' }, rect: rect(0, 0, 400, 800) }
35
+ const LANE_B: Droppable = { id: 'column:b', data: { type: 'column', columnId: 'b' }, rect: rect(416, 0, 400, 800) }
36
+ const A1: Droppable = { id: 'a1', data: { type: 'ticket', columnId: 'a' }, rect: rect(8, 48, 384, 100) }
37
+ const A2: Droppable = { id: 'a2', data: { type: 'ticket', columnId: 'a' }, rect: rect(8, 156, 384, 100) }
38
+ const B1: Droppable = { id: 'b1', data: { type: 'ticket', columnId: 'b' }, rect: rect(424, 48, 384, 100) }
39
+ const B2: Droppable = { id: 'b2', data: { type: 'ticket', columnId: 'b' }, rect: rect(424, 156, 384, 100) }
40
+
41
+ function makeArgs(options: {
42
+ droppables: Droppable[]
43
+ activeId?: UniqueIdentifier
44
+ pointer?: { x: number; y: number } | null
45
+ /** Rect of the card being dragged; defaults to sitting over the lane gap. */
46
+ collisionRect?: ClientRect
47
+ }): CollisionArgs {
48
+ const { droppables, activeId = 'a1', pointer = null, collisionRect = rect(200, 300, 384, 100) } = options
49
+
50
+ const containers = droppables.map(
51
+ d =>
52
+ ({
53
+ id: d.id,
54
+ key: String(d.id),
55
+ disabled: false,
56
+ node: { current: null },
57
+ rect: { current: d.rect },
58
+ data: { current: d.data },
59
+ }) as unknown as DroppableContainer,
60
+ )
61
+
62
+ return {
63
+ active: { id: activeId, data: { current: null }, rect: { current: { initial: null, translated: null } } },
64
+ collisionRect,
65
+ droppableRects: new Map(droppables.map(d => [d.id, d.rect])),
66
+ droppableContainers: containers,
67
+ pointerCoordinates: pointer,
68
+ } as unknown as CollisionArgs
69
+ }
70
+
71
+ const idsOf = (collisions: Collision[]) => collisions.map(c => c.id)
72
+ const pointerHitsOf = (args: CollisionArgs, hits: UniqueIdentifier[]): Collision[] =>
73
+ hits.map(id => {
74
+ const container = args.droppableContainers.find(c => c.id === id)
75
+ if (!container) throw new Error(`fixture has no droppable ${String(id)}`)
76
+ return { id, data: { droppableContainer: container, value: 0 } }
77
+ })
78
+
79
+ /** A scheduler whose frames only advance when the test says so. */
80
+ function manualFrames() {
81
+ let pending: (() => void)[] = []
82
+ const schedule: FrameScheduler = callback => {
83
+ pending.push(callback)
84
+ return () => {
85
+ pending = pending.filter(p => p !== callback)
86
+ }
87
+ }
88
+ return {
89
+ schedule,
90
+ tick: () => {
91
+ const due = pending
92
+ pending = []
93
+ for (const cb of due) cb()
94
+ },
95
+ get pendingCount() {
96
+ return pending.length
97
+ },
98
+ }
99
+ }
100
+
101
+ describe('detectCollisions', () => {
102
+ it('prefers a ticket under the pointer over its lane', () => {
103
+ const args = makeArgs({ droppables: [LANE_B, B1, B2], pointer: { x: 600, y: 98 } })
104
+ const collisions = detectCollisions(args, pointerHitsOf(args, [LANE_B.id, B1.id]))
105
+ expect(idsOf(collisions)).toEqual(['b1'])
106
+ })
107
+
108
+ it('resolves a lane-only hit to the nearest ticket inside that lane', () => {
109
+ // Pointer in lane B's empty space below both cards: the answer must still be
110
+ // a ticket, so the move lands at a position instead of "somewhere in B".
111
+ // `closestCorners` ranks every candidate; dnd-kit reads the first one.
112
+ const args = makeArgs({ droppables: [LANE_B, B1, B2], pointer: { x: 600, y: 700 } })
113
+ const collisions = detectCollisions(args, pointerHitsOf(args, [LANE_B.id]))
114
+ expect(idsOf(collisions)).toEqual(['b2', 'b1'])
115
+ })
116
+
117
+ it('never answers with the dragged card itself', () => {
118
+ // Dragging b2 over its own lane's empty space: b2 is the geometrically
119
+ // closest ticket, but returning it reads as "no change" and swallows the move.
120
+ const args = makeArgs({ droppables: [LANE_B, B1, B2], activeId: 'b2', pointer: { x: 600, y: 700 } })
121
+ const collisions = detectCollisions(args, pointerHitsOf(args, [LANE_B.id]))
122
+ expect(idsOf(collisions)).toEqual(['b1'])
123
+ })
124
+
125
+ it('falls back to the lane itself when it holds no other ticket', () => {
126
+ const args = makeArgs({ droppables: [LANE_B, B1], activeId: 'b1', pointer: { x: 600, y: 700 } })
127
+ const collisions = detectCollisions(args, pointerHitsOf(args, [LANE_B.id]))
128
+ expect(idsOf(collisions)).toEqual(['column:b'])
129
+ })
130
+
131
+ it('ranks by rect overlap when there is no pointer at all (keyboard drag)', () => {
132
+ const args = makeArgs({
133
+ droppables: [LANE_A, LANE_B, A1, B1],
134
+ pointer: null,
135
+ collisionRect: rect(424, 48, 384, 100), // exactly over B1
136
+ })
137
+ expect(idsOf(detectCollisions(args, []))[0]).toBe('b1')
138
+ })
139
+ })
140
+
141
+ describe('createDropTargetResolver', () => {
142
+ it('holds the last target while the pointer is over nothing', () => {
143
+ const resolver = createDropTargetResolver(manualFrames().schedule)
144
+ const droppables = [LANE_A, LANE_B, A1, A2, B1, B2]
145
+
146
+ const overB1 = makeArgs({ droppables, pointer: { x: 600, y: 98 } })
147
+ expect(idsOf(resolver.detect(overB1))).toEqual(['b1'])
148
+
149
+ // Pointer parked in the 16px lane gap: nothing is under it. Without the hold
150
+ // this falls to rect ranking, which flips between the lanes as the lists
151
+ // re-lay out — the ping-pong that crashed React with #185.
152
+ const inGap = makeArgs({ droppables, pointer: { x: 408, y: 98 } })
153
+ expect(idsOf(resolver.detect(inGap))).toEqual(['b1'])
154
+ expect(idsOf(resolver.detect(inGap))).toEqual(['b1'])
155
+ })
156
+
157
+ it('freezes the target for exactly one frame after a move', () => {
158
+ const frames = manualFrames()
159
+ const resolver = createDropTargetResolver(frames.schedule)
160
+ const droppables = [LANE_A, LANE_B, A1, A2, B1, B2]
161
+
162
+ resolver.detect(makeArgs({ droppables, pointer: { x: 600, y: 98 } })) // target = b1
163
+ resolver.freeze()
164
+
165
+ // Same frame: whatever the re-measured geometry now says, the answer holds.
166
+ const overA1 = makeArgs({ droppables, pointer: { x: 200, y: 98 } })
167
+ expect(idsOf(resolver.detect(overA1))).toEqual(['b1'])
168
+
169
+ frames.tick()
170
+ expect(idsOf(resolver.detect(overA1))).toEqual(['a1'])
171
+ })
172
+
173
+ it('settle carries the freeze past the commit it was guarding', () => {
174
+ // `freeze` runs in the handler, the re-measure runs in the commit that
175
+ // follows it. Releasing a frame after the HANDLER assumes the commit already
176
+ // happened; `settle` — called from an effect on the moved list — re-arms so
177
+ // the guard outlives the commit however late React schedules it.
178
+ const frames = manualFrames()
179
+ const resolver = createDropTargetResolver(frames.schedule)
180
+ const droppables = [LANE_A, LANE_B, A1, A2, B1, B2]
181
+ const overA1 = makeArgs({ droppables, pointer: { x: 200, y: 98 } })
182
+
183
+ resolver.detect(makeArgs({ droppables, pointer: { x: 600, y: 98 } })) // target = b1
184
+ resolver.freeze()
185
+ resolver.settle() // the commit landed
186
+ expect(frames.pendingCount).toBe(1)
187
+
188
+ // Still frozen on the frame the commit landed in.
189
+ expect(idsOf(resolver.detect(overA1))).toEqual(['b1'])
190
+ frames.tick()
191
+ expect(idsOf(resolver.detect(overA1))).toEqual(['a1'])
192
+ })
193
+
194
+ it('settle is inert when nothing is frozen', () => {
195
+ // It fires on every list change, including ones no drag caused.
196
+ const frames = manualFrames()
197
+ const resolver = createDropTargetResolver(frames.schedule)
198
+ resolver.settle()
199
+ expect(frames.pendingCount).toBe(0)
200
+ })
201
+
202
+ it('opens on its own when the guarded move commits nothing', () => {
203
+ // A move that resolves to no change never reaches an effect, so `settle`
204
+ // never runs. The frame `freeze` scheduled is the ceiling that keeps the
205
+ // board from going deaf for the rest of the drag.
206
+ const frames = manualFrames()
207
+ const resolver = createDropTargetResolver(frames.schedule)
208
+ const droppables = [LANE_A, LANE_B, A1, A2, B1, B2]
209
+ const overA1 = makeArgs({ droppables, pointer: { x: 200, y: 98 } })
210
+
211
+ resolver.detect(makeArgs({ droppables, pointer: { x: 600, y: 98 } }))
212
+ resolver.freeze()
213
+ frames.tick() // no settle() — nothing committed
214
+ expect(idsOf(resolver.detect(overA1))).toEqual(['a1'])
215
+ })
216
+
217
+ it('re-arms the freeze window on every move instead of stacking frames', () => {
218
+ const frames = manualFrames()
219
+ const resolver = createDropTargetResolver(frames.schedule)
220
+ resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 600, y: 98 } }))
221
+
222
+ resolver.freeze()
223
+ resolver.freeze()
224
+ resolver.freeze()
225
+ expect(frames.pendingCount).toBe(1)
226
+ })
227
+
228
+ it('release drops both the freeze and the remembered target', () => {
229
+ const frames = manualFrames()
230
+ const resolver = createDropTargetResolver(frames.schedule)
231
+ const droppables = [LANE_A, LANE_B, A1, B1]
232
+
233
+ resolver.detect(makeArgs({ droppables, pointer: { x: 600, y: 98 } }))
234
+ resolver.freeze()
235
+ resolver.release()
236
+
237
+ // Nothing held any more: over-nothing now resolves by geometry again, which
238
+ // is what a fresh drag must do.
239
+ const inGap = makeArgs({ droppables, pointer: { x: 408, y: 98 }, collisionRect: rect(424, 48, 384, 100) })
240
+ expect(idsOf(resolver.detect(inGap))).toEqual(['b1'])
241
+ expect(frames.pendingCount).toBe(0)
242
+ })
243
+
244
+ it('keeps the remembered target when a resolution comes back empty', () => {
245
+ const frames = manualFrames()
246
+ const resolver = createDropTargetResolver(frames.schedule)
247
+
248
+ resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 600, y: 98 } }))
249
+ // Board unmounted its lanes mid-drag: no droppables, so no collision. The
250
+ // resolver must not forget — the next hold has to have something to hold.
251
+ expect(resolver.detect(makeArgs({ droppables: [], pointer: { x: 600, y: 98 } }))).toEqual([])
252
+ expect(idsOf(resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 408, y: 98 } })))).toEqual(['b1'])
253
+ })
254
+
255
+ it('dispose cancels a pending freeze frame', () => {
256
+ const frames = manualFrames()
257
+ const resolver = createDropTargetResolver(frames.schedule)
258
+ resolver.freeze()
259
+ resolver.dispose()
260
+ expect(frames.pendingCount).toBe(0)
261
+ })
262
+ })
@@ -2,13 +2,14 @@
2
2
 
3
3
  import { useDroppable } from '@dnd-kit/core'
4
4
  import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
5
- import * as React from 'react'
5
+ import { memo, useEffect, useMemo, useRef, type ReactNode } from 'react'
6
6
  import { TagIcon } from '../../icons-v2-generated/shopping/tag-icon'
7
7
  import { cn } from '../../../utils/cn'
8
8
  import { BoardColumnHeader } from './board-column-header'
9
9
  import { tintOnDark } from './color-utils'
10
10
  import { TicketCard } from './ticket-card'
11
11
  import { TicketCardSkeleton } from './ticket-card-skeleton'
12
+ import { useLaneScrollAnchor } from './use-lane-scroll-anchor'
12
13
  import type { BoardColumnDef, BoardTicket } from './types'
13
14
 
14
15
  export interface BoardColumnProps {
@@ -18,7 +19,7 @@ export interface BoardColumnProps {
18
19
  onAddTicket?: (columnId: string) => void
19
20
  onArchive?: (columnId: string) => void
20
21
  getTicketHref?: (ticketId: string) => string
21
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
22
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
22
23
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
23
24
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
24
25
  onLoadMore?: (columnId: string) => void
@@ -27,7 +28,10 @@ export interface BoardColumnProps {
27
28
  joinRight?: boolean
28
29
  }
29
30
 
30
- export function BoardColumn({
31
+ /** Memoized: `Board` rebuilds only the two columns a drag touches, so the
32
+ * untouched ones keep their object identity and can skip the render entirely.
33
+ * Without this, every pointer move that shifts a card re-renders all lanes. */
34
+ export const BoardColumn = memo(function BoardColumn({
31
35
  column,
32
36
  collapsed = false,
33
37
  onToggleCollapse,
@@ -42,8 +46,21 @@ export function BoardColumn({
42
46
  joinLeft = false,
43
47
  joinRight = false,
44
48
  }: BoardColumnProps) {
49
+ // The drop zone is the WHOLE lane, header included — not just the list. A
50
+ // droppable that stops at the list leaves dead strips (the header, the
51
+ // padding) where the pointer is over a column yet hits nothing, and there the
52
+ // drop target has to be guessed from rect overlap, which is both wrong-looking
53
+ // and unstable. Collapsed lanes stay out: they render no list to drop into.
54
+ const droppableData = useMemo(() => ({ columnId: column.id, type: 'column' as const }), [column.id])
55
+ const { setNodeRef: setLaneRef, isOver } = useDroppable({
56
+ id: `column:${column.id}`,
57
+ data: droppableData,
58
+ disabled: column.dropDisabled || collapsed,
59
+ })
60
+
45
61
  return (
46
62
  <div
63
+ ref={setLaneRef}
47
64
  className={cn(
48
65
  '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
66
  'transition-[width] duration-300 ease-out',
@@ -51,6 +68,7 @@ export function BoardColumn({
51
68
  column.system && 'bg-ods-card',
52
69
  joinLeft && 'rounded-l-none border-l-0',
53
70
  joinRight && 'rounded-r-none',
71
+ isOver && 'outline outline-2 -outline-offset-2 outline-ods-focus',
54
72
  )}
55
73
  style={column.system ? undefined : { backgroundColor: tintOnDark(column.color) }}
56
74
  >
@@ -77,40 +95,40 @@ export function BoardColumn({
77
95
  )}
78
96
  </div>
79
97
  )
80
- }
98
+ })
81
99
 
82
100
  interface ColumnBodyProps {
83
101
  column: BoardColumnDef
84
102
  getTicketHref?: (ticketId: string) => string
85
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
103
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
86
104
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
87
105
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
88
106
  onLoadMore?: (columnId: string) => void
89
107
  loadMoreRootMargin: string
90
108
  }
91
109
 
92
- function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReject, onLoadMore, loadMoreRootMargin }: ColumnBodyProps) {
93
- const ticketIds = React.useMemo(() => column.tickets.map(t => t.id), [column.tickets])
110
+ function ColumnBody({
111
+ column,
112
+ getTicketHref,
113
+ renderAssignSlot,
114
+ onApprove,
115
+ onReject,
116
+ onLoadMore,
117
+ loadMoreRootMargin,
118
+ }: ColumnBodyProps) {
119
+ const ticketIds = useMemo(() => column.tickets.map(t => t.id), [column.tickets])
94
120
 
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
- })
121
+ const scrollRef = useRef<HTMLDivElement | null>(null)
122
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
104
123
 
105
- const scrollRef = React.useRef<HTMLDivElement | null>(null)
106
- const sentinelRef = React.useRef<HTMLDivElement | null>(null)
124
+ useLaneScrollAnchor(scrollRef, column.tickets)
107
125
 
108
- const loadMoreRef = React.useRef(onLoadMore)
126
+ const loadMoreRef = useRef(onLoadMore)
109
127
  loadMoreRef.current = onLoadMore
110
- const columnIdRef = React.useRef(column.id)
128
+ const columnIdRef = useRef(column.id)
111
129
  columnIdRef.current = column.id
112
130
 
113
- React.useEffect(() => {
131
+ useEffect(() => {
114
132
  if (!column.hasMore || column.isLoadingMore) return
115
133
  const sentinel = sentinelRef.current
116
134
  const root = scrollRef.current
@@ -128,21 +146,14 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
128
146
  return () => observer.disconnect()
129
147
  }, [column.hasMore, column.isLoadingMore, loadMoreRootMargin])
130
148
 
131
- const setBodyRef = React.useCallback(
132
- (el: HTMLDivElement | null) => {
133
- scrollRef.current = el
134
- setDroppableRef(el)
135
- },
136
- [setDroppableRef],
137
- )
138
-
139
149
  return (
150
+ // `overflow-anchor:none` hands scroll anchoring to `useLaneScrollAnchor`
151
+ // wholesale. The browser's own anchoring is unusable here — it silently
152
+ // skips transformed elements, so it works between drags and does nothing
153
+ // during one — and leaving both on double-corrects every insertion.
140
154
  <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
- )}
155
+ ref={scrollRef}
156
+ className="flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-xs)] overflow-y-auto [overflow-anchor:none]"
146
157
  >
147
158
  <SortableContext items={ticketIds} strategy={verticalListSortingStrategy}>
148
159
  {column.isLoading ? (
@@ -172,14 +183,13 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
172
183
  }
173
184
 
174
185
  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
- )
186
+ // Position IS the identity here: the rows are interchangeable placeholders in
187
+ // a fixed-length list, so the index is the correct key. Random keys remounted
188
+ // every row whenever this re-rendered with a different `count`.
179
189
  return (
180
190
  <>
181
- {keys.map(k => (
182
- <TicketCardSkeleton key={k} />
191
+ {Array.from({ length: count }, (_, i) => (
192
+ <TicketCardSkeleton key={`skeleton-${i}`} />
183
193
  ))}
184
194
  </>
185
195
  )
@@ -1,17 +1,13 @@
1
1
  'use client'
2
2
 
3
- import * as React from 'react'
3
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
4
  import {
5
5
  DndContext,
6
6
  DragOverlay,
7
7
  KeyboardSensor,
8
8
  PointerSensor,
9
- closestCorners,
10
- pointerWithin,
11
- rectIntersection,
12
9
  useSensor,
13
10
  useSensors,
14
- type CollisionDetection,
15
11
  type DragEndEvent,
16
12
  type DragOverEvent,
17
13
  type DragStartEvent,
@@ -20,6 +16,7 @@ import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
20
16
  import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollbar'
21
17
  import { cn } from '../../../utils/cn'
22
18
  import { BoardColumn } from './board-column'
19
+ import { createDropTargetResolver } from './drop-target'
23
20
  import { TicketCard } from './ticket-card'
24
21
  import type { BoardChange, BoardColumnDef, BoardTicket } from './types'
25
22
  import { useBoardCollapse } from './use-board-collapse'
@@ -31,7 +28,7 @@ export interface BoardProps {
31
28
  onAddTicket?: (columnId: string) => void
32
29
  onArchiveColumn?: (columnId: string) => void
33
30
  getTicketHref?: (ticketId: string) => string
34
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
31
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
35
32
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
36
33
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
37
34
  collapseStorageKey?: string
@@ -68,53 +65,54 @@ export function Board({
68
65
  onThumbPointerUp,
69
66
  } = useHorizontalScrollbar()
70
67
 
71
- const [items, setItems] = React.useState<BoardColumnDef[]>(columns)
72
- const isDraggingRef = React.useRef(false)
68
+ const [items, setItems] = useState<BoardColumnDef[]>(columns)
69
+ const isDraggingRef = useRef(false)
73
70
 
74
- React.useEffect(() => {
71
+ useEffect(() => {
75
72
  if (!isDraggingRef.current) setItems(columns)
76
73
  }, [columns])
77
74
 
78
- const dragOriginRef = React.useRef<{ ticketId: string; fromColumnId: string } | null>(null)
79
- const [activeTicket, setActiveTicket] = React.useState<{ ticket: BoardTicket; columnId: string } | null>(null)
75
+ const dragOriginRef = useRef<{ ticketId: string; fromColumnId: string } | null>(null)
76
+ const [activeTicket, setActiveTicket] = useState<{ ticket: BoardTicket; columnId: string } | null>(null)
80
77
 
81
78
  const sensors = useSensors(
82
79
  useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
83
80
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
84
81
  )
85
82
 
86
- const collisionDetection = React.useCallback<CollisionDetection>((args) => {
87
- const pointer = pointerWithin(args)
88
- const intersections = pointer.length > 0 ? pointer : rectIntersection(args)
89
-
90
- const ticketHit = intersections.find(
91
- c => c.data?.droppableContainer?.data?.current?.type === 'ticket',
92
- )
93
- if (ticketHit) return [ticketHit]
94
-
95
- const columnHit = intersections.find(
96
- c => c.data?.droppableContainer?.data?.current?.type === 'column',
97
- )
98
- if (columnHit) {
99
- const columnId = columnHit.data?.droppableContainer?.data?.current?.columnId
100
- const ticketsInColumn = args.droppableContainers.filter(c => {
101
- const d = c.data.current as { type?: string; columnId?: string } | undefined
102
- return d?.type === 'ticket' && d.columnId === columnId
103
- })
104
- if (ticketsInColumn.length > 0) {
105
- const closest = closestCorners({ ...args, droppableContainers: ticketsInColumn })
106
- if (closest.length > 0) return closest
107
- }
108
- return [columnHit]
83
+ // Same collision policy as before, plus the two rules that stop a cross-column
84
+ // move from triggering the next one — see `drop-target.ts`. Kept out of React
85
+ // so both rules are unit-testable frame by frame.
86
+ const dropTarget = useMemo(() => createDropTargetResolver(), [])
87
+ useEffect(() => dropTarget.dispose, [dropTarget])
88
+
89
+ // Keep a freeze alive across the commit that caused it — see `settle`. Keyed
90
+ // on `items` because that is the state whose change re-measures the lanes.
91
+ useEffect(() => {
92
+ dropTarget.settle()
93
+ }, [items, dropTarget])
94
+
95
+ // One stable handler per column id. `onToggleCollapse` keeps its zero-arg
96
+ // shape (public prop), but an inline arrow would hand every lane a new
97
+ // function on every drag frame and defeat `BoardColumn`'s memo — which is the
98
+ // whole point of only rebuilding the lanes a move touches.
99
+ const toggleRef = useRef(toggle)
100
+ toggleRef.current = toggle
101
+ const collapseHandlers = useRef(new Map<string, () => void>())
102
+ const collapseHandlerFor = useCallback((columnId: string) => {
103
+ let handler = collapseHandlers.current.get(columnId)
104
+ if (!handler) {
105
+ handler = () => toggleRef.current(columnId)
106
+ collapseHandlers.current.set(columnId, handler)
109
107
  }
110
-
111
- return closestCorners(args)
108
+ return handler
112
109
  }, [])
113
110
 
114
111
  const handleDragStart = (e: DragStartEvent) => {
115
112
  const id = String(e.active.id)
116
113
  const located = locate(items, id)
117
114
  if (!located) return
115
+ dropTarget.release()
118
116
  isDraggingRef.current = true
119
117
  dragOriginRef.current = { ticketId: id, fromColumnId: located.columnId }
120
118
  setActiveTicket({ ticket: located.ticket, columnId: located.columnId })
@@ -143,6 +141,14 @@ export function Board({
143
141
  !targetCol.allowedFromColumns.includes(origin.fromColumnId)
144
142
  if ((targetCol?.dropDisabled && !isReturnToOrigin) || blockedBySource) return
145
143
 
144
+ // The move below re-mounts the card under another parent, so dnd-kit
145
+ // unregisters + re-registers its droppable and re-measures every rect, then
146
+ // dispatches `onDragOver` again from the effect keyed on the over id. Hold
147
+ // the target for one frame so that cascade cannot schedule the next move —
148
+ // otherwise the two columns pass the card back and forth inside a single
149
+ // commit until React aborts the tree with "Maximum update depth exceeded".
150
+ dropTarget.freeze()
151
+
146
152
  setItems(prev => {
147
153
  const fromIndex = findIndexInColumn(prev, fromColumnId, activeId)
148
154
  const toCol = prev.find(c => c.id === toColumnId)
@@ -180,6 +186,7 @@ export function Board({
180
186
  dragOriginRef.current = null
181
187
  setActiveTicket(null)
182
188
  isDraggingRef.current = false
189
+ dropTarget.release()
183
190
 
184
191
  const { over } = e
185
192
  if (!over || !origin) {
@@ -240,13 +247,14 @@ export function Board({
240
247
  dragOriginRef.current = null
241
248
  setActiveTicket(null)
242
249
  isDraggingRef.current = false
250
+ dropTarget.release()
243
251
  setItems(columns)
244
252
  }
245
253
 
246
254
  return (
247
255
  <DndContext
248
256
  sensors={sensors}
249
- collisionDetection={collisionDetection}
257
+ collisionDetection={dropTarget.detect}
250
258
  onDragStart={handleDragStart}
251
259
  onDragOver={handleDragOver}
252
260
  onDragEnd={handleDragEnd}
@@ -265,12 +273,12 @@ export function Board({
265
273
  const joinRight = !!(column.system && next?.system)
266
274
  const showGap = i > 0 && !joinLeft
267
275
  return (
268
- <React.Fragment key={column.id}>
276
+ <Fragment key={column.id}>
269
277
  {showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
270
278
  <BoardColumn
271
279
  column={column}
272
280
  collapsed={!!collapsed[column.id]}
273
- onToggleCollapse={() => toggle(column.id)}
281
+ onToggleCollapse={collapseHandlerFor(column.id)}
274
282
  onAddTicket={onAddTicket}
275
283
  onArchive={onArchiveColumn}
276
284
  getTicketHref={getTicketHref}
@@ -282,7 +290,7 @@ export function Board({
282
290
  joinLeft={joinLeft}
283
291
  joinRight={joinRight}
284
292
  />
285
- </React.Fragment>
293
+ </Fragment>
286
294
  )
287
295
  })}
288
296
  </div>