@flamingo-stack/openframe-frontend-core 0.0.552 → 0.0.553-1815.4815.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/{chunk-RMWLUKTC.cjs → chunk-2EOL4GLM.cjs} +11 -11
  2. package/dist/{chunk-RMWLUKTC.cjs.map → chunk-2EOL4GLM.cjs.map} +1 -1
  3. package/dist/{chunk-4XG7TG7B.cjs → chunk-2JWKBJIP.cjs} +7 -7
  4. package/dist/{chunk-4XG7TG7B.cjs.map → chunk-2JWKBJIP.cjs.map} +1 -1
  5. package/dist/{chunk-PVPXI5V6.cjs → chunk-2T5ZC2RO.cjs} +31 -31
  6. package/dist/{chunk-PVPXI5V6.cjs.map → chunk-2T5ZC2RO.cjs.map} +1 -1
  7. package/dist/{chunk-RMTTW7LG.cjs → chunk-5FMWTAX5.cjs} +27 -27
  8. package/dist/{chunk-RMTTW7LG.cjs.map → chunk-5FMWTAX5.cjs.map} +1 -1
  9. package/dist/{chunk-MVE2YQVG.cjs → chunk-6746LTZC.cjs} +20 -20
  10. package/dist/{chunk-MVE2YQVG.cjs.map → chunk-6746LTZC.cjs.map} +1 -1
  11. package/dist/{chunk-6MHMEF5W.cjs → chunk-6POBPMSN.cjs} +39 -39
  12. package/dist/{chunk-6MHMEF5W.cjs.map → chunk-6POBPMSN.cjs.map} +1 -1
  13. package/dist/{chunk-6J6Q5QEM.cjs → chunk-7BUY7VKW.cjs} +4 -4
  14. package/dist/{chunk-6J6Q5QEM.cjs.map → chunk-7BUY7VKW.cjs.map} +1 -1
  15. package/dist/{chunk-Z5UK7SKC.js → chunk-7FWRJBNW.js} +4 -4
  16. package/dist/{chunk-KSGQG6WU.js → chunk-AADAEOJA.js} +2 -2
  17. package/dist/{chunk-SIXNMDYY.js → chunk-AXUS7Y7N.js} +2 -2
  18. package/dist/{chunk-7TRIUG7Q.js → chunk-CM75WCSO.js} +5 -5
  19. package/dist/{chunk-SXXIV7IL.cjs → chunk-DG5D5XCS.cjs} +86 -86
  20. package/dist/{chunk-SXXIV7IL.cjs.map → chunk-DG5D5XCS.cjs.map} +1 -1
  21. package/dist/{chunk-RJSDYOFD.js → chunk-HECGJFP4.js} +2 -2
  22. package/dist/{chunk-X4RCWYYF.cjs → chunk-HUZD2MJC.cjs} +807 -695
  23. package/dist/chunk-HUZD2MJC.cjs.map +1 -0
  24. package/dist/{chunk-HK5YRHEN.cjs → chunk-I7Y2S3DR.cjs} +9 -9
  25. package/dist/{chunk-HK5YRHEN.cjs.map → chunk-I7Y2S3DR.cjs.map} +1 -1
  26. package/dist/{chunk-TI75367M.js → chunk-K5SQ625X.js} +660 -548
  27. package/dist/chunk-K5SQ625X.js.map +1 -0
  28. package/dist/{chunk-DOPZ5LCH.cjs → chunk-LXLAVFK3.cjs} +14 -14
  29. package/dist/{chunk-DOPZ5LCH.cjs.map → chunk-LXLAVFK3.cjs.map} +1 -1
  30. package/dist/{chunk-6XOJTTWY.js → chunk-M2XQSLNS.js} +2 -2
  31. package/dist/{chunk-XEOZB5DX.cjs → chunk-PNNO5CMG.cjs} +3 -3
  32. package/dist/{chunk-XEOZB5DX.cjs.map → chunk-PNNO5CMG.cjs.map} +1 -1
  33. package/dist/{chunk-OVQ3O66O.cjs → chunk-QD7WBLQ7.cjs} +5 -5
  34. package/dist/{chunk-OVQ3O66O.cjs.map → chunk-QD7WBLQ7.cjs.map} +1 -1
  35. package/dist/{chunk-CMDPARDV.js → chunk-QJ4KTTJB.js} +2 -2
  36. package/dist/{chunk-7MPOW46J.js → chunk-RXMDOLDH.js} +2 -2
  37. package/dist/{chunk-Y64725GI.cjs → chunk-SQADRYGF.cjs} +61 -61
  38. package/dist/{chunk-Y64725GI.cjs.map → chunk-SQADRYGF.cjs.map} +1 -1
  39. package/dist/{chunk-Y6PD76R6.js → chunk-SUGA42SR.js} +2 -2
  40. package/dist/{chunk-WXDDPK7T.js → chunk-VW5KSRGZ.js} +4 -4
  41. package/dist/{chunk-BFOXIIM4.js → chunk-Y7SI6UU5.js} +2 -2
  42. package/dist/{chunk-RJEP74Z4.js → chunk-YHMRC24T.js} +2 -2
  43. package/dist/{chunk-4DD3223G.js → chunk-ZK2UHBEH.js} +6 -6
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/board/board-column.d.ts +6 -3
  57. package/dist/components/features/board/board-column.d.ts.map +1 -1
  58. package/dist/components/features/board/board.d.ts +3 -3
  59. package/dist/components/features/board/board.d.ts.map +1 -1
  60. package/dist/components/features/board/drop-target.d.ts +79 -0
  61. package/dist/components/features/board/drop-target.d.ts.map +1 -0
  62. package/dist/components/features/board/ticket-card.d.ts +11 -6
  63. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  64. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +24 -0
  65. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  66. package/dist/components/features/index.cjs +2 -2
  67. package/dist/components/features/index.js +1 -1
  68. package/dist/components/help-center-pages/index.cjs +22 -22
  69. package/dist/components/help-center-pages/index.js +13 -13
  70. package/dist/components/index.cjs +131 -131
  71. package/dist/components/index.js +11 -11
  72. package/dist/components/meeting-scheduler/index.cjs +28 -28
  73. package/dist/components/meeting-scheduler/index.js +3 -3
  74. package/dist/components/navigation/index.cjs +2 -2
  75. package/dist/components/navigation/index.js +1 -1
  76. package/dist/components/onboarding-guides/index.cjs +5 -5
  77. package/dist/components/onboarding-guides/index.js +4 -4
  78. package/dist/components/related-content/index.cjs +3 -3
  79. package/dist/components/related-content/index.js +2 -2
  80. package/dist/components/tickets/index.cjs +6 -6
  81. package/dist/components/tickets/index.js +5 -5
  82. package/dist/components/ui/index.cjs +2 -2
  83. package/dist/components/ui/index.js +1 -1
  84. package/dist/index.cjs +2 -2
  85. package/dist/index.js +1 -1
  86. package/package.json +1 -1
  87. package/src/__tests__/board-drag-guard.test.tsx +146 -0
  88. package/src/__tests__/board-drop-target.test.ts +262 -0
  89. package/src/components/features/board/board-column.tsx +50 -40
  90. package/src/components/features/board/board.tsx +48 -40
  91. package/src/components/features/board/drop-target.ts +188 -0
  92. package/src/components/features/board/ticket-card.tsx +36 -16
  93. package/src/components/features/board/use-lane-scroll-anchor.ts +75 -0
  94. package/src/stories/Board.stories.tsx +16 -7
  95. package/dist/chunk-TI75367M.js.map +0 -1
  96. package/dist/chunk-X4RCWYYF.cjs.map +0 -1
  97. /package/dist/{chunk-Z5UK7SKC.js.map → chunk-7FWRJBNW.js.map} +0 -0
  98. /package/dist/{chunk-KSGQG6WU.js.map → chunk-AADAEOJA.js.map} +0 -0
  99. /package/dist/{chunk-SIXNMDYY.js.map → chunk-AXUS7Y7N.js.map} +0 -0
  100. /package/dist/{chunk-7TRIUG7Q.js.map → chunk-CM75WCSO.js.map} +0 -0
  101. /package/dist/{chunk-RJSDYOFD.js.map → chunk-HECGJFP4.js.map} +0 -0
  102. /package/dist/{chunk-6XOJTTWY.js.map → chunk-M2XQSLNS.js.map} +0 -0
  103. /package/dist/{chunk-CMDPARDV.js.map → chunk-QJ4KTTJB.js.map} +0 -0
  104. /package/dist/{chunk-7MPOW46J.js.map → chunk-RXMDOLDH.js.map} +0 -0
  105. /package/dist/{chunk-Y6PD76R6.js.map → chunk-SUGA42SR.js.map} +0 -0
  106. /package/dist/{chunk-WXDDPK7T.js.map → chunk-VW5KSRGZ.js.map} +0 -0
  107. /package/dist/{chunk-BFOXIIM4.js.map → chunk-Y7SI6UU5.js.map} +0 -0
  108. /package/dist/{chunk-RJEP74Z4.js.map → chunk-YHMRC24T.js.map} +0 -0
  109. /package/dist/{chunk-4DD3223G.js.map → chunk-ZK2UHBEH.js.map} +0 -0
@@ -0,0 +1,188 @@
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
+ /**
82
+ * Call from an effect keyed on the moved list, so the freeze outlives the
83
+ * commit that caused it rather than a fixed slice of wall clock.
84
+ *
85
+ * `freeze` alone releases one frame after the HANDLER runs, which assumes the
86
+ * state update it guards has committed by then. That holds today — dnd-kit
87
+ * dispatches `onDragOver` from an effect, so the update flushes in the same
88
+ * commit phase — but it is an assumption about scheduling, and React is free
89
+ * to break it. Re-arming from the commit removes the assumption.
90
+ *
91
+ * A no-op while nothing is frozen, and the frame `freeze` scheduled stays as a
92
+ * ceiling: a move that resolves to no change commits nothing, and the guard
93
+ * still has to open on its own.
94
+ */
95
+ settle: () => void
96
+ /** Call on drag start / end / cancel. */
97
+ release: () => void
98
+ /** Call on unmount — drops any pending frame. */
99
+ dispose: () => void
100
+ }
101
+
102
+ /**
103
+ * {@link detectCollisions} plus the two rules that keep the answer stable. Both
104
+ * exist because moving a card to another column re-mounts it under a different
105
+ * parent, so dnd-kit unregisters + re-registers its droppable and re-measures
106
+ * EVERY rect — and dnd-kit dispatches `onDragOver` from an effect keyed on the
107
+ * over id, so any target change that a move causes schedules another move.
108
+ *
109
+ * 1. **Freeze after a move.** For one frame after a cross-column move the last
110
+ * target is returned verbatim. The re-measure → re-collide → `onDragOver`
111
+ * cascade runs synchronously off the state update, so releasing on the next
112
+ * frame lands strictly after it: a move can never trigger the next one.
113
+ * Without this the two columns keep handing the card back and forth inside a
114
+ * single commit until React aborts the tree ("Maximum update depth exceeded").
115
+ * 2. **Hold when the pointer is over nothing.** In the lane between two columns
116
+ * or off the board, the rect-based fallback ranks columns by how much the
117
+ * dragged card's rect overlaps each — and that ranking flips as soon as a
118
+ * move re-lays out the lists, so the card ping-pongs while the pointer sits
119
+ * perfectly still. The drop target only changes when the pointer is inside
120
+ * something.
121
+ *
122
+ * Kept out of the component (and out of React) so both rules are unit-testable:
123
+ * pass a manual {@link FrameScheduler} to step frames by hand.
124
+ */
125
+ export function createDropTargetResolver(scheduleFrame: FrameScheduler = animationFrameScheduler): DropTargetResolver {
126
+ let settling = false
127
+ let cancelFrame: (() => void) | null = null
128
+ let lastOverId: UniqueIdentifier | null = null
129
+
130
+ const clearFrame = () => {
131
+ cancelFrame?.()
132
+ cancelFrame = null
133
+ }
134
+
135
+ /** Replaces any pending release with a fresh one, one frame out. */
136
+ const scheduleRelease = () => {
137
+ clearFrame()
138
+ cancelFrame = scheduleFrame(() => {
139
+ cancelFrame = null
140
+ settling = false
141
+ })
142
+ }
143
+
144
+ const keepLastTarget = (args: CollisionArgs): Collision[] | null => {
145
+ if (lastOverId === null) return null
146
+ const last = args.droppableContainers.find(c => c.id === lastOverId)
147
+ return last ? [{ id: last.id, data: { droppableContainer: last, value: 0 } }] : null
148
+ }
149
+
150
+ return {
151
+ detect: args => {
152
+ if (settling) {
153
+ const frozen = keepLastTarget(args)
154
+ if (frozen) return frozen
155
+ }
156
+
157
+ const pointerHits = args.pointerCoordinates ? pointerWithin(args) : []
158
+ if (args.pointerCoordinates && pointerHits.length === 0) {
159
+ const held = keepLastTarget(args)
160
+ if (held) return held
161
+ }
162
+
163
+ const collisions = detectCollisions(args, pointerHits)
164
+ // Only remember a real answer: forgetting on an empty result would drop
165
+ // the guard exactly when it is needed, and the next frame would fall back
166
+ // to the rect ranking that ping-pongs.
167
+ if (collisions.length > 0) lastOverId = collisions[0].id
168
+ return collisions
169
+ },
170
+
171
+ freeze: () => {
172
+ settling = true
173
+ scheduleRelease()
174
+ },
175
+
176
+ settle: () => {
177
+ if (settling) scheduleRelease()
178
+ },
179
+
180
+ release: () => {
181
+ clearFrame()
182
+ settling = false
183
+ lastOverId = null
184
+ },
185
+
186
+ dispose: clearFrame,
187
+ }
188
+ }
@@ -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 * as React from 'react'
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) => React.ReactNode
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) => React.ReactNode
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
- export function TicketCard({
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 = React.useMemo(() => ({ columnId, type: 'ticket' as const }), [columnId])
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: React.CSSProperties = isOverlay
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: React.MouseEvent) => {
240
+ const handleClick = (e: MouseEvent) => {
235
241
  if (sortable.isDragging) e.preventDefault()
236
242
  }
237
243
 
238
- const body = (
239
- <TicketCardBody
240
- ticket={ticket}
241
- columnColor={columnColor}
242
- renderAssignSlot={renderAssignSlot}
243
- onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
244
- onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
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
- const allTickets = Array.from({ length: TOTAL }, (_, i) => baseTicket(i, 'ACTIVE'))
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
- if (c.id !== columnId) return c
249
- const nextTickets = allTickets.slice(0, c.tickets.length + PAGE)
250
- const hasMore = nextTickets.length < TOTAL
251
- return { ...c, tickets: nextTickets, hasMore, isLoadingMore: false }
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
  }