@flamingo-stack/openframe-frontend-core 0.0.552 → 0.0.553-1815.4814.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 (108) hide show
  1. package/dist/{chunk-KSGQG6WU.js → chunk-27WZGFQZ.js} +2 -2
  2. package/dist/{chunk-WXDDPK7T.js → chunk-2JYC2GZV.js} +4 -4
  3. package/dist/{chunk-7TRIUG7Q.js → chunk-3RBHFJ3L.js} +5 -5
  4. package/dist/{chunk-TI75367M.js → chunk-3RPRI46G.js} +651 -548
  5. package/dist/chunk-3RPRI46G.js.map +1 -0
  6. package/dist/{chunk-4DD3223G.js → chunk-4NICA4QT.js} +6 -6
  7. package/dist/{chunk-Y64725GI.cjs → chunk-5AB6GPNZ.cjs} +61 -61
  8. package/dist/{chunk-Y64725GI.cjs.map → chunk-5AB6GPNZ.cjs.map} +1 -1
  9. package/dist/{chunk-SIXNMDYY.js → chunk-77BW6SN6.js} +2 -2
  10. package/dist/{chunk-Y6PD76R6.js → chunk-7VIJUKWE.js} +2 -2
  11. package/dist/{chunk-HK5YRHEN.cjs → chunk-CWQKH7BW.cjs} +9 -9
  12. package/dist/{chunk-HK5YRHEN.cjs.map → chunk-CWQKH7BW.cjs.map} +1 -1
  13. package/dist/{chunk-PVPXI5V6.cjs → chunk-HDUBS5Y5.cjs} +31 -31
  14. package/dist/{chunk-PVPXI5V6.cjs.map → chunk-HDUBS5Y5.cjs.map} +1 -1
  15. package/dist/{chunk-X4RCWYYF.cjs → chunk-I2WN5X6Q.cjs} +798 -695
  16. package/dist/chunk-I2WN5X6Q.cjs.map +1 -0
  17. package/dist/{chunk-RMTTW7LG.cjs → chunk-IBDY7C65.cjs} +27 -27
  18. package/dist/{chunk-RMTTW7LG.cjs.map → chunk-IBDY7C65.cjs.map} +1 -1
  19. package/dist/{chunk-MVE2YQVG.cjs → chunk-IBEZSTFR.cjs} +20 -20
  20. package/dist/{chunk-MVE2YQVG.cjs.map → chunk-IBEZSTFR.cjs.map} +1 -1
  21. package/dist/{chunk-Z5UK7SKC.js → chunk-KJSLAD6K.js} +4 -4
  22. package/dist/{chunk-6MHMEF5W.cjs → chunk-MOL2OYPN.cjs} +39 -39
  23. package/dist/{chunk-6MHMEF5W.cjs.map → chunk-MOL2OYPN.cjs.map} +1 -1
  24. package/dist/{chunk-7MPOW46J.js → chunk-ND53XFLK.js} +2 -2
  25. package/dist/{chunk-4XG7TG7B.cjs → chunk-NLGMSRVW.cjs} +7 -7
  26. package/dist/{chunk-4XG7TG7B.cjs.map → chunk-NLGMSRVW.cjs.map} +1 -1
  27. package/dist/{chunk-SXXIV7IL.cjs → chunk-NTOO6MFG.cjs} +86 -86
  28. package/dist/{chunk-SXXIV7IL.cjs.map → chunk-NTOO6MFG.cjs.map} +1 -1
  29. package/dist/{chunk-6J6Q5QEM.cjs → chunk-RWFUYIOD.cjs} +4 -4
  30. package/dist/{chunk-6J6Q5QEM.cjs.map → chunk-RWFUYIOD.cjs.map} +1 -1
  31. package/dist/{chunk-XEOZB5DX.cjs → chunk-SS4ZY4RI.cjs} +3 -3
  32. package/dist/{chunk-XEOZB5DX.cjs.map → chunk-SS4ZY4RI.cjs.map} +1 -1
  33. package/dist/{chunk-RJEP74Z4.js → chunk-T3FQIBZB.js} +2 -2
  34. package/dist/{chunk-BFOXIIM4.js → chunk-TOLUP7IV.js} +2 -2
  35. package/dist/{chunk-OVQ3O66O.cjs → chunk-TPTBRKY6.cjs} +5 -5
  36. package/dist/{chunk-OVQ3O66O.cjs.map → chunk-TPTBRKY6.cjs.map} +1 -1
  37. package/dist/{chunk-RJSDYOFD.js → chunk-TUOUCWOB.js} +2 -2
  38. package/dist/{chunk-CMDPARDV.js → chunk-VL3LF754.js} +2 -2
  39. package/dist/{chunk-DOPZ5LCH.cjs → chunk-X5ANHCAH.cjs} +14 -14
  40. package/dist/{chunk-DOPZ5LCH.cjs.map → chunk-X5ANHCAH.cjs.map} +1 -1
  41. package/dist/{chunk-6XOJTTWY.js → chunk-XEQMFPML.js} +2 -2
  42. package/dist/{chunk-RMWLUKTC.cjs → chunk-Y4WUGTXF.cjs} +11 -11
  43. package/dist/{chunk-RMWLUKTC.cjs.map → chunk-Y4WUGTXF.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/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 +64 -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-drop-target.test.ts +218 -0
  88. package/src/components/features/board/board-column.tsx +50 -40
  89. package/src/components/features/board/board.tsx +42 -40
  90. package/src/components/features/board/drop-target.ts +164 -0
  91. package/src/components/features/board/ticket-card.tsx +36 -16
  92. package/src/components/features/board/use-lane-scroll-anchor.ts +75 -0
  93. package/src/stories/Board.stories.tsx +16 -7
  94. package/dist/chunk-TI75367M.js.map +0 -1
  95. package/dist/chunk-X4RCWYYF.cjs.map +0 -1
  96. /package/dist/{chunk-KSGQG6WU.js.map → chunk-27WZGFQZ.js.map} +0 -0
  97. /package/dist/{chunk-WXDDPK7T.js.map → chunk-2JYC2GZV.js.map} +0 -0
  98. /package/dist/{chunk-7TRIUG7Q.js.map → chunk-3RBHFJ3L.js.map} +0 -0
  99. /package/dist/{chunk-4DD3223G.js.map → chunk-4NICA4QT.js.map} +0 -0
  100. /package/dist/{chunk-SIXNMDYY.js.map → chunk-77BW6SN6.js.map} +0 -0
  101. /package/dist/{chunk-Y6PD76R6.js.map → chunk-7VIJUKWE.js.map} +0 -0
  102. /package/dist/{chunk-Z5UK7SKC.js.map → chunk-KJSLAD6K.js.map} +0 -0
  103. /package/dist/{chunk-7MPOW46J.js.map → chunk-ND53XFLK.js.map} +0 -0
  104. /package/dist/{chunk-RJEP74Z4.js.map → chunk-T3FQIBZB.js.map} +0 -0
  105. /package/dist/{chunk-BFOXIIM4.js.map → chunk-TOLUP7IV.js.map} +0 -0
  106. /package/dist/{chunk-RJSDYOFD.js.map → chunk-TUOUCWOB.js.map} +0 -0
  107. /package/dist/{chunk-CMDPARDV.js.map → chunk-VL3LF754.js.map} +0 -0
  108. /package/dist/{chunk-6XOJTTWY.js.map → chunk-XEQMFPML.js.map} +0 -0
@@ -0,0 +1,218 @@
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('re-arms the freeze window on every move instead of stacking frames', () => {
174
+ const frames = manualFrames()
175
+ const resolver = createDropTargetResolver(frames.schedule)
176
+ resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 600, y: 98 } }))
177
+
178
+ resolver.freeze()
179
+ resolver.freeze()
180
+ resolver.freeze()
181
+ expect(frames.pendingCount).toBe(1)
182
+ })
183
+
184
+ it('release drops both the freeze and the remembered target', () => {
185
+ const frames = manualFrames()
186
+ const resolver = createDropTargetResolver(frames.schedule)
187
+ const droppables = [LANE_A, LANE_B, A1, B1]
188
+
189
+ resolver.detect(makeArgs({ droppables, pointer: { x: 600, y: 98 } }))
190
+ resolver.freeze()
191
+ resolver.release()
192
+
193
+ // Nothing held any more: over-nothing now resolves by geometry again, which
194
+ // is what a fresh drag must do.
195
+ const inGap = makeArgs({ droppables, pointer: { x: 408, y: 98 }, collisionRect: rect(424, 48, 384, 100) })
196
+ expect(idsOf(resolver.detect(inGap))).toEqual(['b1'])
197
+ expect(frames.pendingCount).toBe(0)
198
+ })
199
+
200
+ it('keeps the remembered target when a resolution comes back empty', () => {
201
+ const frames = manualFrames()
202
+ const resolver = createDropTargetResolver(frames.schedule)
203
+
204
+ resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 600, y: 98 } }))
205
+ // Board unmounted its lanes mid-drag: no droppables, so no collision. The
206
+ // resolver must not forget — the next hold has to have something to hold.
207
+ expect(resolver.detect(makeArgs({ droppables: [], pointer: { x: 600, y: 98 } }))).toEqual([])
208
+ expect(idsOf(resolver.detect(makeArgs({ droppables: [LANE_B, B1], pointer: { x: 408, y: 98 } })))).toEqual(['b1'])
209
+ })
210
+
211
+ it('dispose cancels a pending freeze frame', () => {
212
+ const frames = manualFrames()
213
+ const resolver = createDropTargetResolver(frames.schedule)
214
+ resolver.freeze()
215
+ resolver.dispose()
216
+ expect(frames.pendingCount).toBe(0)
217
+ })
218
+ })
@@ -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,48 @@ 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
+ // One stable handler per column id. `onToggleCollapse` keeps its zero-arg
90
+ // shape (public prop), but an inline arrow would hand every lane a new
91
+ // function on every drag frame and defeat `BoardColumn`'s memo — which is the
92
+ // whole point of only rebuilding the lanes a move touches.
93
+ const toggleRef = useRef(toggle)
94
+ toggleRef.current = toggle
95
+ const collapseHandlers = useRef(new Map<string, () => void>())
96
+ const collapseHandlerFor = useCallback((columnId: string) => {
97
+ let handler = collapseHandlers.current.get(columnId)
98
+ if (!handler) {
99
+ handler = () => toggleRef.current(columnId)
100
+ collapseHandlers.current.set(columnId, handler)
109
101
  }
110
-
111
- return closestCorners(args)
102
+ return handler
112
103
  }, [])
113
104
 
114
105
  const handleDragStart = (e: DragStartEvent) => {
115
106
  const id = String(e.active.id)
116
107
  const located = locate(items, id)
117
108
  if (!located) return
109
+ dropTarget.release()
118
110
  isDraggingRef.current = true
119
111
  dragOriginRef.current = { ticketId: id, fromColumnId: located.columnId }
120
112
  setActiveTicket({ ticket: located.ticket, columnId: located.columnId })
@@ -143,6 +135,14 @@ export function Board({
143
135
  !targetCol.allowedFromColumns.includes(origin.fromColumnId)
144
136
  if ((targetCol?.dropDisabled && !isReturnToOrigin) || blockedBySource) return
145
137
 
138
+ // The move below re-mounts the card under another parent, so dnd-kit
139
+ // unregisters + re-registers its droppable and re-measures every rect, then
140
+ // dispatches `onDragOver` again from the effect keyed on the over id. Hold
141
+ // the target for one frame so that cascade cannot schedule the next move —
142
+ // otherwise the two columns pass the card back and forth inside a single
143
+ // commit until React aborts the tree with "Maximum update depth exceeded".
144
+ dropTarget.freeze()
145
+
146
146
  setItems(prev => {
147
147
  const fromIndex = findIndexInColumn(prev, fromColumnId, activeId)
148
148
  const toCol = prev.find(c => c.id === toColumnId)
@@ -180,6 +180,7 @@ export function Board({
180
180
  dragOriginRef.current = null
181
181
  setActiveTicket(null)
182
182
  isDraggingRef.current = false
183
+ dropTarget.release()
183
184
 
184
185
  const { over } = e
185
186
  if (!over || !origin) {
@@ -240,13 +241,14 @@ export function Board({
240
241
  dragOriginRef.current = null
241
242
  setActiveTicket(null)
242
243
  isDraggingRef.current = false
244
+ dropTarget.release()
243
245
  setItems(columns)
244
246
  }
245
247
 
246
248
  return (
247
249
  <DndContext
248
250
  sensors={sensors}
249
- collisionDetection={collisionDetection}
251
+ collisionDetection={dropTarget.detect}
250
252
  onDragStart={handleDragStart}
251
253
  onDragOver={handleDragOver}
252
254
  onDragEnd={handleDragEnd}
@@ -265,12 +267,12 @@ export function Board({
265
267
  const joinRight = !!(column.system && next?.system)
266
268
  const showGap = i > 0 && !joinLeft
267
269
  return (
268
- <React.Fragment key={column.id}>
270
+ <Fragment key={column.id}>
269
271
  {showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
270
272
  <BoardColumn
271
273
  column={column}
272
274
  collapsed={!!collapsed[column.id]}
273
- onToggleCollapse={() => toggle(column.id)}
275
+ onToggleCollapse={collapseHandlerFor(column.id)}
274
276
  onAddTicket={onAddTicket}
275
277
  onArchive={onArchiveColumn}
276
278
  getTicketHref={getTicketHref}
@@ -282,7 +284,7 @@ export function Board({
282
284
  joinLeft={joinLeft}
283
285
  joinRight={joinRight}
284
286
  />
285
- </React.Fragment>
287
+ </Fragment>
286
288
  )
287
289
  })}
288
290
  </div>