@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,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCorners,
|
|
3
|
+
pointerWithin,
|
|
4
|
+
rectIntersection,
|
|
5
|
+
type Collision,
|
|
6
|
+
type CollisionDetection,
|
|
7
|
+
type UniqueIdentifier,
|
|
8
|
+
} from '@dnd-kit/core'
|
|
9
|
+
|
|
10
|
+
export type CollisionArgs = Parameters<CollisionDetection>[0]
|
|
11
|
+
|
|
12
|
+
/** Data every board droppable carries, so a collision can be read back. */
|
|
13
|
+
export interface BoardDroppableData {
|
|
14
|
+
type?: 'ticket' | 'column'
|
|
15
|
+
columnId?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function droppableDataOf(collision: Collision): BoardDroppableData | undefined {
|
|
19
|
+
return collision.data?.droppableContainer?.data?.current as BoardDroppableData | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Picks the one droppable a drop would land in. The board registers two kinds —
|
|
24
|
+
* a ticket (`type: 'ticket'`) and a column (`type: 'column'`) — and the order
|
|
25
|
+
* below is what turns "somewhere over that lane" into a concrete position:
|
|
26
|
+
*
|
|
27
|
+
* 1. a ticket under the pointer wins outright: it is the anchor the move is
|
|
28
|
+
* ordered against (`afterTicketId` / `beforeTicketId`);
|
|
29
|
+
* 2. otherwise a column under the pointer (its empty space, its header, the gap
|
|
30
|
+
* between two cards) resolves to the nearest ticket *inside that column*, so
|
|
31
|
+
* the drop still gets a position; an empty column returns itself, which the
|
|
32
|
+
* board reads as "append";
|
|
33
|
+
* 3. otherwise nothing is under the pointer and geometry alone decides.
|
|
34
|
+
*
|
|
35
|
+
* `pointerHits` is passed in already computed: the caller has to know whether
|
|
36
|
+
* the pointer hit anything at all before it decides to run this — see
|
|
37
|
+
* {@link createDropTargetResolver}. With a pointer, step 3 and the
|
|
38
|
+
* `rectIntersection` fallback are only ever reached on the first collision of a
|
|
39
|
+
* drag; they stay for the keyboard sensor, which drags with no pointer at all.
|
|
40
|
+
*/
|
|
41
|
+
export function detectCollisions(args: CollisionArgs, pointerHits: Collision[]): Collision[] {
|
|
42
|
+
const intersections = pointerHits.length > 0 ? pointerHits : rectIntersection(args)
|
|
43
|
+
|
|
44
|
+
const ticketHit = intersections.find(c => droppableDataOf(c)?.type === 'ticket')
|
|
45
|
+
if (ticketHit) return [ticketHit]
|
|
46
|
+
|
|
47
|
+
const columnHit = intersections.find(c => droppableDataOf(c)?.type === 'column')
|
|
48
|
+
if (columnHit) {
|
|
49
|
+
const { columnId } = droppableDataOf(columnHit) ?? {}
|
|
50
|
+
// The dragged card is a droppable too, and it is the one card that must not
|
|
51
|
+
// be the answer: a collision on the active id reads as "no change" and the
|
|
52
|
+
// move is dropped, which would swallow a legitimate hover over a column.
|
|
53
|
+
const ticketsInColumn = args.droppableContainers.filter(container => {
|
|
54
|
+
if (container.id === args.active.id) return false
|
|
55
|
+
const data = container.data.current as BoardDroppableData | undefined
|
|
56
|
+
return data?.type === 'ticket' && data.columnId === columnId
|
|
57
|
+
})
|
|
58
|
+
if (ticketsInColumn.length > 0) {
|
|
59
|
+
const closest = closestCorners({ ...args, droppableContainers: ticketsInColumn })
|
|
60
|
+
if (closest.length > 0) return closest
|
|
61
|
+
}
|
|
62
|
+
return [columnHit]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return closestCorners(args)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Schedules `callback` for after the current frame; returns a canceller. */
|
|
69
|
+
export type FrameScheduler = (callback: () => void) => () => void
|
|
70
|
+
|
|
71
|
+
const animationFrameScheduler: FrameScheduler = callback => {
|
|
72
|
+
const id = requestAnimationFrame(callback)
|
|
73
|
+
return () => cancelAnimationFrame(id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DropTargetResolver {
|
|
77
|
+
/** Pass straight to `DndContext`'s `collisionDetection`. */
|
|
78
|
+
detect: CollisionDetection
|
|
79
|
+
/** Call right before a state update moves a card between columns. */
|
|
80
|
+
freeze: () => void
|
|
81
|
+
/** Call on drag start / end / cancel. */
|
|
82
|
+
release: () => void
|
|
83
|
+
/** Call on unmount — drops any pending frame. */
|
|
84
|
+
dispose: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* {@link detectCollisions} plus the two rules that keep the answer stable. Both
|
|
89
|
+
* exist because moving a card to another column re-mounts it under a different
|
|
90
|
+
* parent, so dnd-kit unregisters + re-registers its droppable and re-measures
|
|
91
|
+
* EVERY rect — and dnd-kit dispatches `onDragOver` from an effect keyed on the
|
|
92
|
+
* over id, so any target change that a move causes schedules another move.
|
|
93
|
+
*
|
|
94
|
+
* 1. **Freeze after a move.** For one frame after a cross-column move the last
|
|
95
|
+
* target is returned verbatim. The re-measure → re-collide → `onDragOver`
|
|
96
|
+
* cascade runs synchronously off the state update, so releasing on the next
|
|
97
|
+
* frame lands strictly after it: a move can never trigger the next one.
|
|
98
|
+
* Without this the two columns keep handing the card back and forth inside a
|
|
99
|
+
* single commit until React aborts the tree ("Maximum update depth exceeded").
|
|
100
|
+
* 2. **Hold when the pointer is over nothing.** In the lane between two columns
|
|
101
|
+
* or off the board, the rect-based fallback ranks columns by how much the
|
|
102
|
+
* dragged card's rect overlaps each — and that ranking flips as soon as a
|
|
103
|
+
* move re-lays out the lists, so the card ping-pongs while the pointer sits
|
|
104
|
+
* perfectly still. The drop target only changes when the pointer is inside
|
|
105
|
+
* something.
|
|
106
|
+
*
|
|
107
|
+
* Kept out of the component (and out of React) so both rules are unit-testable:
|
|
108
|
+
* pass a manual {@link FrameScheduler} to step frames by hand.
|
|
109
|
+
*/
|
|
110
|
+
export function createDropTargetResolver(scheduleFrame: FrameScheduler = animationFrameScheduler): DropTargetResolver {
|
|
111
|
+
let settling = false
|
|
112
|
+
let cancelFrame: (() => void) | null = null
|
|
113
|
+
let lastOverId: UniqueIdentifier | null = null
|
|
114
|
+
|
|
115
|
+
const clearFrame = () => {
|
|
116
|
+
cancelFrame?.()
|
|
117
|
+
cancelFrame = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const keepLastTarget = (args: CollisionArgs): Collision[] | null => {
|
|
121
|
+
if (lastOverId === null) return null
|
|
122
|
+
const last = args.droppableContainers.find(c => c.id === lastOverId)
|
|
123
|
+
return last ? [{ id: last.id, data: { droppableContainer: last, value: 0 } }] : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
detect: args => {
|
|
128
|
+
if (settling) {
|
|
129
|
+
const frozen = keepLastTarget(args)
|
|
130
|
+
if (frozen) return frozen
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pointerHits = args.pointerCoordinates ? pointerWithin(args) : []
|
|
134
|
+
if (args.pointerCoordinates && pointerHits.length === 0) {
|
|
135
|
+
const held = keepLastTarget(args)
|
|
136
|
+
if (held) return held
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const collisions = detectCollisions(args, pointerHits)
|
|
140
|
+
// Only remember a real answer: forgetting on an empty result would drop
|
|
141
|
+
// the guard exactly when it is needed, and the next frame would fall back
|
|
142
|
+
// to the rect ranking that ping-pongs.
|
|
143
|
+
if (collisions.length > 0) lastOverId = collisions[0].id
|
|
144
|
+
return collisions
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
freeze: () => {
|
|
148
|
+
settling = true
|
|
149
|
+
clearFrame()
|
|
150
|
+
cancelFrame = scheduleFrame(() => {
|
|
151
|
+
cancelFrame = null
|
|
152
|
+
settling = false
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
release: () => {
|
|
157
|
+
clearFrame()
|
|
158
|
+
settling = false
|
|
159
|
+
lastOverId = null
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
dispose: clearFrame,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useSortable } from '@dnd-kit/sortable'
|
|
4
4
|
import { CSS } from '@dnd-kit/utilities'
|
|
5
5
|
import Link from '../../../embed-shims/next-link'
|
|
6
|
-
import
|
|
6
|
+
import { memo, useMemo, type CSSProperties, type MouseEvent, type ReactNode } from 'react'
|
|
7
7
|
import { ClockIcon, DotsLoaderIcon, LaptopIcon, Flag02Icon, MessagesIcon, UserCheckIcon } from '../../icons-v2-generated'
|
|
8
8
|
import { DeletedUserAvatar } from '../../ui/deleted-user-avatar'
|
|
9
9
|
import { SquareAvatar } from '../../ui/square-avatar'
|
|
@@ -13,6 +13,7 @@ import { cn } from '../../../utils/cn'
|
|
|
13
13
|
import { getReadableTextColor } from '../../../utils/ods-color-utils'
|
|
14
14
|
import { formatTicketRelativeTime, formatTicketFullTimestamp } from '../../../utils/date-utils'
|
|
15
15
|
import { BoardTicketApproval } from './board-ticket-approval'
|
|
16
|
+
import { TICKET_ID_ATTRIBUTE } from './use-lane-scroll-anchor'
|
|
16
17
|
import type { BoardPriority, BoardTicket, BoardTicketActivityKind } from './types'
|
|
17
18
|
|
|
18
19
|
const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
|
|
@@ -45,7 +46,7 @@ const TICKET_CARD_SHELL =
|
|
|
45
46
|
export interface TicketCardBodyProps {
|
|
46
47
|
ticket: BoardTicket
|
|
47
48
|
columnColor?: string
|
|
48
|
-
renderAssignSlot?: (ticket: BoardTicket) =>
|
|
49
|
+
renderAssignSlot?: (ticket: BoardTicket) => ReactNode
|
|
49
50
|
/** Approval callbacks receive the request id directly (the draggable
|
|
50
51
|
* `TicketCard` adapts its own `(ticketId, requestId)` signature onto these). */
|
|
51
52
|
onApprove?: (requestId?: string) => void | Promise<void>
|
|
@@ -202,12 +203,17 @@ export interface TicketCardProps {
|
|
|
202
203
|
href?: string
|
|
203
204
|
isOverlay?: boolean
|
|
204
205
|
dragDisabled?: boolean
|
|
205
|
-
renderAssignSlot?: (ticket: BoardTicket) =>
|
|
206
|
+
renderAssignSlot?: (ticket: BoardTicket) => ReactNode
|
|
206
207
|
onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
207
208
|
onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
|
|
211
|
+
/** Memoized: a drag moves one card, but it re-renders the column lists around
|
|
212
|
+
* it, and a board carries dozens of cards whose props did not change. Hosts
|
|
213
|
+
* must pass stable `renderAssignSlot` / `onApprove` / `onReject` (useCallback)
|
|
214
|
+
* for this to bite — an inline arrow re-renders every card on every keystroke
|
|
215
|
+
* anywhere in the page. dnd-kit recommends memoizing sortable items. */
|
|
216
|
+
export const TicketCard = memo(function TicketCard({
|
|
211
217
|
ticket,
|
|
212
218
|
columnId,
|
|
213
219
|
columnColor,
|
|
@@ -218,12 +224,12 @@ export function TicketCard({
|
|
|
218
224
|
onApprove,
|
|
219
225
|
onReject,
|
|
220
226
|
}: TicketCardProps) {
|
|
221
|
-
const sortableData =
|
|
227
|
+
const sortableData = useMemo(() => ({ columnId, type: 'ticket' as const }), [columnId])
|
|
222
228
|
const sortable = useSortable({ id: ticket.id, data: sortableData, disabled: dragDisabled })
|
|
223
229
|
|
|
224
230
|
const showNewMessage = !!ticket.hasNewMessage && !!columnColor
|
|
225
231
|
|
|
226
|
-
const style:
|
|
232
|
+
const style: CSSProperties = isOverlay
|
|
227
233
|
? {}
|
|
228
234
|
: {
|
|
229
235
|
transform: CSS.Transform.toString(sortable.transform),
|
|
@@ -231,18 +237,29 @@ export function TicketCard({
|
|
|
231
237
|
}
|
|
232
238
|
if (showNewMessage) style.borderColor = columnColor
|
|
233
239
|
|
|
234
|
-
const handleClick = (e:
|
|
240
|
+
const handleClick = (e: MouseEvent) => {
|
|
235
241
|
if (sortable.isDragging) e.preventDefault()
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// Held as one memoized element, not re-created per render. `useSortable`
|
|
245
|
+
// subscribes to dnd-kit's context, and that context changes whenever a drag
|
|
246
|
+
// starts, ends or moves a card between columns — memo cannot stop a
|
|
247
|
+
// context update, so every card on the board re-renders at those moments. By
|
|
248
|
+
// keeping the same element object, React bails out of the whole subtree and
|
|
249
|
+
// only this thin wrapper (transform + drag classes) re-renders. That is what
|
|
250
|
+
// keeps a heavy card body — avatars, tags, tooltips, a host's assignee
|
|
251
|
+
// picker with its own queries — out of the drag's hot path.
|
|
252
|
+
const body = useMemo(
|
|
253
|
+
() => (
|
|
254
|
+
<TicketCardBody
|
|
255
|
+
ticket={ticket}
|
|
256
|
+
columnColor={columnColor}
|
|
257
|
+
renderAssignSlot={renderAssignSlot}
|
|
258
|
+
onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
|
|
259
|
+
onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
|
|
260
|
+
/>
|
|
261
|
+
),
|
|
262
|
+
[ticket, columnColor, renderAssignSlot, onApprove, onReject],
|
|
246
263
|
)
|
|
247
264
|
|
|
248
265
|
const cardClasses = cn(
|
|
@@ -256,6 +273,9 @@ export function TicketCard({
|
|
|
256
273
|
ref: isOverlay ? undefined : sortable.setNodeRef,
|
|
257
274
|
style,
|
|
258
275
|
className: cardClasses,
|
|
276
|
+
// Read by the lane's scroll anchor to keep the list still when tickets are
|
|
277
|
+
// inserted above the viewport — see `use-lane-scroll-anchor.ts`.
|
|
278
|
+
...(isOverlay ? {} : { [TICKET_ID_ATTRIBUTE]: ticket.id }),
|
|
259
279
|
...(isOverlay ? {} : sortable.attributes),
|
|
260
280
|
...(isOverlay ? {} : sortable.listeners),
|
|
261
281
|
}
|
|
@@ -297,7 +317,7 @@ export function TicketCard({
|
|
|
297
317
|
<div className={cn('pointer-events-none', innerWrapperClass)}>{body}</div>
|
|
298
318
|
</div>
|
|
299
319
|
)
|
|
300
|
-
}
|
|
320
|
+
})
|
|
301
321
|
|
|
302
322
|
function TicketTagRow({ tags }: { tags: string[] }) {
|
|
303
323
|
const visible = tags.slice(0, MAX_VISIBLE_TAGS)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef, type RefObject } from 'react'
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect'
|
|
5
|
+
import type { BoardTicket } from './types'
|
|
6
|
+
|
|
7
|
+
/** Attribute the lane's cards carry so the anchor can find them again. */
|
|
8
|
+
export const TICKET_ID_ATTRIBUTE = 'data-ticket-id'
|
|
9
|
+
|
|
10
|
+
interface CardPosition {
|
|
11
|
+
id: string
|
|
12
|
+
/**
|
|
13
|
+
* Position in LAYOUT space, absolute within the lane — not the painted one.
|
|
14
|
+
* `offsetTop` ignores transforms, and during a drag dnd-kit transforms every
|
|
15
|
+
* card in the lane; anchoring on painted positions would read a card's shifted
|
|
16
|
+
* position before the drop and its resting position after, then "correct" the
|
|
17
|
+
* scroll by that difference and throw the lane hundreds of pixels off. Absolute
|
|
18
|
+
* rather than viewport-relative so that scrolling between two list changes —
|
|
19
|
+
* the drag's own auto-scroll, most of all — cannot stale the reading.
|
|
20
|
+
*/
|
|
21
|
+
offsetTop: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cardPositions(root: HTMLElement): CardPosition[] {
|
|
25
|
+
return Array.from(root.querySelectorAll<HTMLElement>(`[${TICKET_ID_ATTRIBUTE}]`)).map(element => ({
|
|
26
|
+
id: element.getAttribute(TICKET_ID_ATTRIBUTE) ?? '',
|
|
27
|
+
offsetTop: element.offsetTop,
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Keeps a scrolled lane visually still when its list changes above the viewport.
|
|
33
|
+
*
|
|
34
|
+
* Browsers do this themselves — it is called scroll anchoring — but Chrome skips
|
|
35
|
+
* candidates that carry a `transform`, and during a drag dnd-kit transforms every
|
|
36
|
+
* card in the lane to open the gap. So a ticket arriving from a live update (or
|
|
37
|
+
* landing when a drop reveals one that arrived mid-drag) is inserted above the
|
|
38
|
+
* viewport with nothing to compensate, and the whole list slides down by a card.
|
|
39
|
+
* From the outside that reads as: you drop a card and it lands one row lower than
|
|
40
|
+
* where you left it.
|
|
41
|
+
*
|
|
42
|
+
* The fix is one measurement: whichever card sits at the top of the viewport,
|
|
43
|
+
* note where it is, and after the list changes put the viewport back the same
|
|
44
|
+
* distance from it.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately inert at the very top of a lane (`scrollTop === 0`), matching what
|
|
47
|
+
* browsers do — there a new ticket is meant to be seen, not scrolled away.
|
|
48
|
+
*/
|
|
49
|
+
export function useLaneScrollAnchor(scrollRef: RefObject<HTMLDivElement | null>, tickets: readonly BoardTicket[]): void {
|
|
50
|
+
const positionsRef = useRef<CardPosition[]>([])
|
|
51
|
+
|
|
52
|
+
useIsomorphicLayoutEffect(() => {
|
|
53
|
+
const root = scrollRef.current
|
|
54
|
+
if (!root) return
|
|
55
|
+
|
|
56
|
+
const previous = positionsRef.current
|
|
57
|
+
const scrollTop = root.scrollTop
|
|
58
|
+
// Measured once and reused: this runs for every lane on every list change —
|
|
59
|
+
// live updates, the host's refetch, each step of a cross-lane drag — and
|
|
60
|
+
// reading `offsetTop` after the DOM changed forces a synchronous layout.
|
|
61
|
+
const current = cardPositions(root)
|
|
62
|
+
|
|
63
|
+
if (previous.length > 0 && scrollTop > 0) {
|
|
64
|
+
// The card at the top of the viewport right now, by its previous position.
|
|
65
|
+
const anchor = previous.find(card => card.offsetTop >= scrollTop) ?? previous[previous.length - 1]
|
|
66
|
+
const moved = current.find(card => card.id === anchor.id)
|
|
67
|
+
if (moved) {
|
|
68
|
+
const drift = moved.offsetTop - anchor.offsetTop
|
|
69
|
+
if (Math.abs(drift) >= 1) root.scrollTop = scrollTop + drift
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
positionsRef.current = current
|
|
74
|
+
}, [tickets])
|
|
75
|
+
}
|
|
@@ -232,7 +232,10 @@ export const Pagination: Story = {
|
|
|
232
232
|
render: function Render() {
|
|
233
233
|
const TOTAL = 50
|
|
234
234
|
const PAGE = 10
|
|
235
|
-
|
|
235
|
+
// Built once: rebuilding per render hands every card a new object, which
|
|
236
|
+
// defeats the memoization the board relies on to stay smooth under a drag.
|
|
237
|
+
const allTickets = React.useMemo(() => Array.from({ length: TOTAL }, (_, i) => baseTicket(i, 'ACTIVE')), [])
|
|
238
|
+
const loadedRef = React.useRef(PAGE)
|
|
236
239
|
|
|
237
240
|
const [columns, setColumns] = React.useState<BoardColumnDef[]>([
|
|
238
241
|
columnFromTicketStatus('ACTIVE', allTickets.slice(0, PAGE), { total: TOTAL, hasMore: true }),
|
|
@@ -243,13 +246,19 @@ export const Pagination: Story = {
|
|
|
243
246
|
const handleLoadMore = (columnId: string) => {
|
|
244
247
|
setColumns(prev => prev.map(c => (c.id === columnId ? { ...c, isLoadingMore: true } : c)))
|
|
245
248
|
setTimeout(() => {
|
|
249
|
+
// APPEND the next page. Re-slicing the seed from 0 also threw away every
|
|
250
|
+
// drop the person had made, and a drag that auto-scrolls a lane to its
|
|
251
|
+
// bottom loads a page mid-drag — so the card silently snapped back to
|
|
252
|
+
// where it started, moments after landing where it was dropped.
|
|
253
|
+
const from = loadedRef.current
|
|
254
|
+
loadedRef.current = Math.min(from + PAGE, TOTAL)
|
|
255
|
+
const page = allTickets.slice(from, loadedRef.current)
|
|
246
256
|
setColumns(prev =>
|
|
247
|
-
prev.map(c =>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}),
|
|
257
|
+
prev.map(c =>
|
|
258
|
+
c.id === columnId
|
|
259
|
+
? { ...c, tickets: [...c.tickets, ...page], hasMore: loadedRef.current < TOTAL, isLoadingMore: false }
|
|
260
|
+
: c,
|
|
261
|
+
),
|
|
253
262
|
)
|
|
254
263
|
}, 600)
|
|
255
264
|
}
|