@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.
- package/dist/{chunk-KSGQG6WU.js → chunk-27WZGFQZ.js} +2 -2
- package/dist/{chunk-WXDDPK7T.js → chunk-2JYC2GZV.js} +4 -4
- package/dist/{chunk-7TRIUG7Q.js → chunk-3RBHFJ3L.js} +5 -5
- package/dist/{chunk-TI75367M.js → chunk-3RPRI46G.js} +651 -548
- package/dist/chunk-3RPRI46G.js.map +1 -0
- package/dist/{chunk-4DD3223G.js → chunk-4NICA4QT.js} +6 -6
- package/dist/{chunk-Y64725GI.cjs → chunk-5AB6GPNZ.cjs} +61 -61
- package/dist/{chunk-Y64725GI.cjs.map → chunk-5AB6GPNZ.cjs.map} +1 -1
- package/dist/{chunk-SIXNMDYY.js → chunk-77BW6SN6.js} +2 -2
- package/dist/{chunk-Y6PD76R6.js → chunk-7VIJUKWE.js} +2 -2
- package/dist/{chunk-HK5YRHEN.cjs → chunk-CWQKH7BW.cjs} +9 -9
- package/dist/{chunk-HK5YRHEN.cjs.map → chunk-CWQKH7BW.cjs.map} +1 -1
- package/dist/{chunk-PVPXI5V6.cjs → chunk-HDUBS5Y5.cjs} +31 -31
- package/dist/{chunk-PVPXI5V6.cjs.map → chunk-HDUBS5Y5.cjs.map} +1 -1
- package/dist/{chunk-X4RCWYYF.cjs → chunk-I2WN5X6Q.cjs} +798 -695
- package/dist/chunk-I2WN5X6Q.cjs.map +1 -0
- package/dist/{chunk-RMTTW7LG.cjs → chunk-IBDY7C65.cjs} +27 -27
- package/dist/{chunk-RMTTW7LG.cjs.map → chunk-IBDY7C65.cjs.map} +1 -1
- package/dist/{chunk-MVE2YQVG.cjs → chunk-IBEZSTFR.cjs} +20 -20
- package/dist/{chunk-MVE2YQVG.cjs.map → chunk-IBEZSTFR.cjs.map} +1 -1
- package/dist/{chunk-Z5UK7SKC.js → chunk-KJSLAD6K.js} +4 -4
- package/dist/{chunk-6MHMEF5W.cjs → chunk-MOL2OYPN.cjs} +39 -39
- package/dist/{chunk-6MHMEF5W.cjs.map → chunk-MOL2OYPN.cjs.map} +1 -1
- package/dist/{chunk-7MPOW46J.js → chunk-ND53XFLK.js} +2 -2
- package/dist/{chunk-4XG7TG7B.cjs → chunk-NLGMSRVW.cjs} +7 -7
- package/dist/{chunk-4XG7TG7B.cjs.map → chunk-NLGMSRVW.cjs.map} +1 -1
- package/dist/{chunk-SXXIV7IL.cjs → chunk-NTOO6MFG.cjs} +86 -86
- package/dist/{chunk-SXXIV7IL.cjs.map → chunk-NTOO6MFG.cjs.map} +1 -1
- package/dist/{chunk-6J6Q5QEM.cjs → chunk-RWFUYIOD.cjs} +4 -4
- package/dist/{chunk-6J6Q5QEM.cjs.map → chunk-RWFUYIOD.cjs.map} +1 -1
- package/dist/{chunk-XEOZB5DX.cjs → chunk-SS4ZY4RI.cjs} +3 -3
- package/dist/{chunk-XEOZB5DX.cjs.map → chunk-SS4ZY4RI.cjs.map} +1 -1
- package/dist/{chunk-RJEP74Z4.js → chunk-T3FQIBZB.js} +2 -2
- package/dist/{chunk-BFOXIIM4.js → chunk-TOLUP7IV.js} +2 -2
- package/dist/{chunk-OVQ3O66O.cjs → chunk-TPTBRKY6.cjs} +5 -5
- package/dist/{chunk-OVQ3O66O.cjs.map → chunk-TPTBRKY6.cjs.map} +1 -1
- package/dist/{chunk-RJSDYOFD.js → chunk-TUOUCWOB.js} +2 -2
- package/dist/{chunk-CMDPARDV.js → chunk-VL3LF754.js} +2 -2
- package/dist/{chunk-DOPZ5LCH.cjs → chunk-X5ANHCAH.cjs} +14 -14
- package/dist/{chunk-DOPZ5LCH.cjs.map → chunk-X5ANHCAH.cjs.map} +1 -1
- package/dist/{chunk-6XOJTTWY.js → chunk-XEQMFPML.js} +2 -2
- package/dist/{chunk-RMWLUKTC.cjs → chunk-Y4WUGTXF.cjs} +11 -11
- package/dist/{chunk-RMWLUKTC.cjs.map → chunk-Y4WUGTXF.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/board/board-column.d.ts +6 -3
- package/dist/components/features/board/board-column.d.ts.map +1 -1
- package/dist/components/features/board/board.d.ts +3 -3
- package/dist/components/features/board/board.d.ts.map +1 -1
- package/dist/components/features/board/drop-target.d.ts +64 -0
- package/dist/components/features/board/drop-target.d.ts.map +1 -0
- package/dist/components/features/board/ticket-card.d.ts +11 -6
- package/dist/components/features/board/ticket-card.d.ts.map +1 -1
- package/dist/components/features/board/use-lane-scroll-anchor.d.ts +24 -0
- package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +131 -131
- package/dist/components/index.js +11 -11
- package/dist/components/meeting-scheduler/index.cjs +28 -28
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/board-drop-target.test.ts +218 -0
- package/src/components/features/board/board-column.tsx +50 -40
- package/src/components/features/board/board.tsx +42 -40
- package/src/components/features/board/drop-target.ts +164 -0
- package/src/components/features/board/ticket-card.tsx +36 -16
- package/src/components/features/board/use-lane-scroll-anchor.ts +75 -0
- package/src/stories/Board.stories.tsx +16 -7
- package/dist/chunk-TI75367M.js.map +0 -1
- package/dist/chunk-X4RCWYYF.cjs.map +0 -1
- /package/dist/{chunk-KSGQG6WU.js.map → chunk-27WZGFQZ.js.map} +0 -0
- /package/dist/{chunk-WXDDPK7T.js.map → chunk-2JYC2GZV.js.map} +0 -0
- /package/dist/{chunk-7TRIUG7Q.js.map → chunk-3RBHFJ3L.js.map} +0 -0
- /package/dist/{chunk-4DD3223G.js.map → chunk-4NICA4QT.js.map} +0 -0
- /package/dist/{chunk-SIXNMDYY.js.map → chunk-77BW6SN6.js.map} +0 -0
- /package/dist/{chunk-Y6PD76R6.js.map → chunk-7VIJUKWE.js.map} +0 -0
- /package/dist/{chunk-Z5UK7SKC.js.map → chunk-KJSLAD6K.js.map} +0 -0
- /package/dist/{chunk-7MPOW46J.js.map → chunk-ND53XFLK.js.map} +0 -0
- /package/dist/{chunk-RJEP74Z4.js.map → chunk-T3FQIBZB.js.map} +0 -0
- /package/dist/{chunk-BFOXIIM4.js.map → chunk-TOLUP7IV.js.map} +0 -0
- /package/dist/{chunk-RJSDYOFD.js.map → chunk-TUOUCWOB.js.map} +0 -0
- /package/dist/{chunk-CMDPARDV.js.map → chunk-VL3LF754.js.map} +0 -0
- /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
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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({
|
|
93
|
-
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
106
|
-
const sentinelRef = React.useRef<HTMLDivElement | null>(null)
|
|
124
|
+
useLaneScrollAnchor(scrollRef, column.tickets)
|
|
107
125
|
|
|
108
|
-
const loadMoreRef =
|
|
126
|
+
const loadMoreRef = useRef(onLoadMore)
|
|
109
127
|
loadMoreRef.current = onLoadMore
|
|
110
|
-
const columnIdRef =
|
|
128
|
+
const columnIdRef = useRef(column.id)
|
|
111
129
|
columnIdRef.current = column.id
|
|
112
130
|
|
|
113
|
-
|
|
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={
|
|
142
|
-
className=
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
{
|
|
182
|
-
<TicketCardSkeleton key={
|
|
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
|
|
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) =>
|
|
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] =
|
|
72
|
-
const isDraggingRef =
|
|
68
|
+
const [items, setItems] = useState<BoardColumnDef[]>(columns)
|
|
69
|
+
const isDraggingRef = useRef(false)
|
|
73
70
|
|
|
74
|
-
|
|
71
|
+
useEffect(() => {
|
|
75
72
|
if (!isDraggingRef.current) setItems(columns)
|
|
76
73
|
}, [columns])
|
|
77
74
|
|
|
78
|
-
const dragOriginRef =
|
|
79
|
-
const [activeTicket, setActiveTicket] =
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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={
|
|
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
|
-
<
|
|
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={(
|
|
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
|
-
</
|
|
287
|
+
</Fragment>
|
|
286
288
|
)
|
|
287
289
|
})}
|
|
288
290
|
</div>
|