@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.
- package/dist/{chunk-RMWLUKTC.cjs → chunk-2EOL4GLM.cjs} +11 -11
- package/dist/{chunk-RMWLUKTC.cjs.map → chunk-2EOL4GLM.cjs.map} +1 -1
- package/dist/{chunk-4XG7TG7B.cjs → chunk-2JWKBJIP.cjs} +7 -7
- package/dist/{chunk-4XG7TG7B.cjs.map → chunk-2JWKBJIP.cjs.map} +1 -1
- package/dist/{chunk-PVPXI5V6.cjs → chunk-2T5ZC2RO.cjs} +31 -31
- package/dist/{chunk-PVPXI5V6.cjs.map → chunk-2T5ZC2RO.cjs.map} +1 -1
- package/dist/{chunk-RMTTW7LG.cjs → chunk-5FMWTAX5.cjs} +27 -27
- package/dist/{chunk-RMTTW7LG.cjs.map → chunk-5FMWTAX5.cjs.map} +1 -1
- package/dist/{chunk-MVE2YQVG.cjs → chunk-6746LTZC.cjs} +20 -20
- package/dist/{chunk-MVE2YQVG.cjs.map → chunk-6746LTZC.cjs.map} +1 -1
- package/dist/{chunk-6MHMEF5W.cjs → chunk-6POBPMSN.cjs} +39 -39
- package/dist/{chunk-6MHMEF5W.cjs.map → chunk-6POBPMSN.cjs.map} +1 -1
- package/dist/{chunk-6J6Q5QEM.cjs → chunk-7BUY7VKW.cjs} +4 -4
- package/dist/{chunk-6J6Q5QEM.cjs.map → chunk-7BUY7VKW.cjs.map} +1 -1
- package/dist/{chunk-Z5UK7SKC.js → chunk-7FWRJBNW.js} +4 -4
- package/dist/{chunk-KSGQG6WU.js → chunk-AADAEOJA.js} +2 -2
- package/dist/{chunk-SIXNMDYY.js → chunk-AXUS7Y7N.js} +2 -2
- package/dist/{chunk-7TRIUG7Q.js → chunk-CM75WCSO.js} +5 -5
- package/dist/{chunk-SXXIV7IL.cjs → chunk-DG5D5XCS.cjs} +86 -86
- package/dist/{chunk-SXXIV7IL.cjs.map → chunk-DG5D5XCS.cjs.map} +1 -1
- package/dist/{chunk-RJSDYOFD.js → chunk-HECGJFP4.js} +2 -2
- package/dist/{chunk-X4RCWYYF.cjs → chunk-HUZD2MJC.cjs} +807 -695
- package/dist/chunk-HUZD2MJC.cjs.map +1 -0
- package/dist/{chunk-HK5YRHEN.cjs → chunk-I7Y2S3DR.cjs} +9 -9
- package/dist/{chunk-HK5YRHEN.cjs.map → chunk-I7Y2S3DR.cjs.map} +1 -1
- package/dist/{chunk-TI75367M.js → chunk-K5SQ625X.js} +660 -548
- package/dist/chunk-K5SQ625X.js.map +1 -0
- package/dist/{chunk-DOPZ5LCH.cjs → chunk-LXLAVFK3.cjs} +14 -14
- package/dist/{chunk-DOPZ5LCH.cjs.map → chunk-LXLAVFK3.cjs.map} +1 -1
- package/dist/{chunk-6XOJTTWY.js → chunk-M2XQSLNS.js} +2 -2
- package/dist/{chunk-XEOZB5DX.cjs → chunk-PNNO5CMG.cjs} +3 -3
- package/dist/{chunk-XEOZB5DX.cjs.map → chunk-PNNO5CMG.cjs.map} +1 -1
- package/dist/{chunk-OVQ3O66O.cjs → chunk-QD7WBLQ7.cjs} +5 -5
- package/dist/{chunk-OVQ3O66O.cjs.map → chunk-QD7WBLQ7.cjs.map} +1 -1
- package/dist/{chunk-CMDPARDV.js → chunk-QJ4KTTJB.js} +2 -2
- package/dist/{chunk-7MPOW46J.js → chunk-RXMDOLDH.js} +2 -2
- package/dist/{chunk-Y64725GI.cjs → chunk-SQADRYGF.cjs} +61 -61
- package/dist/{chunk-Y64725GI.cjs.map → chunk-SQADRYGF.cjs.map} +1 -1
- package/dist/{chunk-Y6PD76R6.js → chunk-SUGA42SR.js} +2 -2
- package/dist/{chunk-WXDDPK7T.js → chunk-VW5KSRGZ.js} +4 -4
- package/dist/{chunk-BFOXIIM4.js → chunk-Y7SI6UU5.js} +2 -2
- package/dist/{chunk-RJEP74Z4.js → chunk-YHMRC24T.js} +2 -2
- package/dist/{chunk-4DD3223G.js → chunk-ZK2UHBEH.js} +6 -6
- 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 +79 -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-drag-guard.test.tsx +146 -0
- package/src/__tests__/board-drop-target.test.ts +262 -0
- package/src/components/features/board/board-column.tsx +50 -40
- package/src/components/features/board/board.tsx +48 -40
- package/src/components/features/board/drop-target.ts +188 -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-Z5UK7SKC.js.map → chunk-7FWRJBNW.js.map} +0 -0
- /package/dist/{chunk-KSGQG6WU.js.map → chunk-AADAEOJA.js.map} +0 -0
- /package/dist/{chunk-SIXNMDYY.js.map → chunk-AXUS7Y7N.js.map} +0 -0
- /package/dist/{chunk-7TRIUG7Q.js.map → chunk-CM75WCSO.js.map} +0 -0
- /package/dist/{chunk-RJSDYOFD.js.map → chunk-HECGJFP4.js.map} +0 -0
- /package/dist/{chunk-6XOJTTWY.js.map → chunk-M2XQSLNS.js.map} +0 -0
- /package/dist/{chunk-CMDPARDV.js.map → chunk-QJ4KTTJB.js.map} +0 -0
- /package/dist/{chunk-7MPOW46J.js.map → chunk-RXMDOLDH.js.map} +0 -0
- /package/dist/{chunk-Y6PD76R6.js.map → chunk-SUGA42SR.js.map} +0 -0
- /package/dist/{chunk-WXDDPK7T.js.map → chunk-VW5KSRGZ.js.map} +0 -0
- /package/dist/{chunk-BFOXIIM4.js.map → chunk-Y7SI6UU5.js.map} +0 -0
- /package/dist/{chunk-RJEP74Z4.js.map → chunk-YHMRC24T.js.map} +0 -0
- /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
|
|
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,54 @@ 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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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={
|
|
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
|
-
<
|
|
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={(
|
|
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
|
-
</
|
|
293
|
+
</Fragment>
|
|
286
294
|
)
|
|
287
295
|
})}
|
|
288
296
|
</div>
|