@flamingo-stack/openframe-frontend-core 0.0.556 → 0.0.557-1828.4873.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 (163) hide show
  1. package/dist/{chunk-5756Q6UN.js → chunk-25YLMIWU.js} +2 -2
  2. package/dist/{chunk-FLSEJSTN.js → chunk-26HAJBSQ.js} +2 -2
  3. package/dist/{chunk-JDUPAHJR.cjs → chunk-33FOBAWS.cjs} +11 -11
  4. package/dist/{chunk-JDUPAHJR.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
  5. package/dist/{chunk-CRGIXNUE.cjs → chunk-4EGNYOZA.cjs} +20 -20
  6. package/dist/{chunk-CRGIXNUE.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
  7. package/dist/{chunk-SXMJLNOF.cjs → chunk-4WREPWRL.cjs} +3 -3
  8. package/dist/{chunk-SXMJLNOF.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
  9. package/dist/{chunk-LZD3VSPS.cjs → chunk-5HNZTPAB.cjs} +9 -9
  10. package/dist/{chunk-LZD3VSPS.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
  11. package/dist/{chunk-HYABNMBN.cjs → chunk-5JF6H7OJ.cjs} +62 -62
  12. package/dist/{chunk-HYABNMBN.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
  13. package/dist/{chunk-BNMKHG2L.cjs → chunk-64HHDGRN.cjs} +4 -4
  14. package/dist/{chunk-BNMKHG2L.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
  15. package/dist/{chunk-Z3R3LOC5.cjs → chunk-6YXEZCVG.cjs} +5 -5
  16. package/dist/{chunk-Z3R3LOC5.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
  17. package/dist/{chunk-BZKUID4R.cjs → chunk-6ZOVSD4M.cjs} +14 -14
  18. package/dist/{chunk-BZKUID4R.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
  19. package/dist/{chunk-Q5SZ2QTZ.cjs → chunk-A6TAVIBX.cjs} +39 -39
  20. package/dist/{chunk-Q5SZ2QTZ.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
  21. package/dist/{chunk-ICG7JRYT.cjs → chunk-BE6TRUKY.cjs} +31 -31
  22. package/dist/{chunk-ICG7JRYT.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
  23. package/dist/{chunk-JRWOHH7K.js → chunk-DRNSIK3V.js} +2 -2
  24. package/dist/{chunk-VF4DCOPD.js → chunk-FXVMRZIQ.js} +4 -4
  25. package/dist/{chunk-XGBVHQP7.js → chunk-G3NBTL4I.js} +3244 -2555
  26. package/dist/chunk-G3NBTL4I.js.map +1 -0
  27. package/dist/{chunk-JXJ4FTKN.js → chunk-G7ROZAY5.js} +2 -2
  28. package/dist/{chunk-S4LL6QI6.cjs → chunk-IPO24G7U.cjs} +1630 -941
  29. package/dist/chunk-IPO24G7U.cjs.map +1 -0
  30. package/dist/{chunk-5M4PS3XX.js → chunk-LJWTXODF.js} +2 -2
  31. package/dist/{chunk-CGUSNV6R.js → chunk-NNGQ6ONX.js} +6 -6
  32. package/dist/{chunk-HOEOGFQM.cjs → chunk-QCBBX6DO.cjs} +86 -86
  33. package/dist/{chunk-HOEOGFQM.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
  34. package/dist/{chunk-VXNMOGRP.js → chunk-RPCY2FAC.js} +4 -4
  35. package/dist/{chunk-WX5UCLL7.js → chunk-RRWWAH37.js} +2 -2
  36. package/dist/{chunk-5JWLZJK5.js → chunk-SBKX2VSG.js} +2 -2
  37. package/dist/{chunk-5EES5HD4.js → chunk-UTEQHK3H.js} +5 -5
  38. package/dist/{chunk-7Q7GFIFB.cjs → chunk-V5EYGEX7.cjs} +7 -7
  39. package/dist/{chunk-7Q7GFIFB.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
  40. package/dist/{chunk-YM2WHYYK.js → chunk-VJL46NHW.js} +2 -2
  41. package/dist/{chunk-FZT7BYNF.js → chunk-Y6TXTBIQ.js} +2 -2
  42. package/dist/{chunk-C74PZU6M.cjs → chunk-YHRE4JBF.cjs} +26 -26
  43. package/dist/{chunk-C74PZU6M.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/board/aim.d.ts +57 -0
  57. package/dist/components/features/board/aim.d.ts.map +1 -0
  58. package/dist/components/features/board/board-column-header.d.ts.map +1 -1
  59. package/dist/components/features/board/board-column.d.ts +8 -4
  60. package/dist/components/features/board/board-column.d.ts.map +1 -1
  61. package/dist/components/features/board/board.d.ts +15 -4
  62. package/dist/components/features/board/board.d.ts.map +1 -1
  63. package/dist/components/features/board/drop-aim.d.ts +34 -0
  64. package/dist/components/features/board/drop-aim.d.ts.map +1 -0
  65. package/dist/components/features/board/lane-geometry.d.ts +22 -0
  66. package/dist/components/features/board/lane-geometry.d.ts.map +1 -0
  67. package/dist/components/features/board/pending-move.d.ts +77 -0
  68. package/dist/components/features/board/pending-move.d.ts.map +1 -0
  69. package/dist/components/features/board/resolve-drop.d.ts +19 -0
  70. package/dist/components/features/board/resolve-drop.d.ts.map +1 -0
  71. package/dist/components/features/board/ticket-card.d.ts +25 -9
  72. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  73. package/dist/components/features/board/use-board-collapse.d.ts.map +1 -1
  74. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +27 -0
  75. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  76. package/dist/components/features/index.cjs +8 -2
  77. package/dist/components/features/index.cjs.map +1 -1
  78. package/dist/components/features/index.d.ts +1 -0
  79. package/dist/components/features/index.d.ts.map +1 -1
  80. package/dist/components/features/index.js +7 -1
  81. package/dist/components/features/sortable-list/index.d.ts +4 -0
  82. package/dist/components/features/sortable-list/index.d.ts.map +1 -0
  83. package/dist/components/features/sortable-list/slot-geometry.d.ts +57 -0
  84. package/dist/components/features/sortable-list/slot-geometry.d.ts.map +1 -0
  85. package/dist/components/features/sortable-list/sortable-list.d.ts +50 -0
  86. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -0
  87. package/dist/components/features/sortable-list/use-sortable-item.d.ts +29 -0
  88. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -0
  89. package/dist/components/help-center-pages/index.cjs +22 -22
  90. package/dist/components/help-center-pages/index.js +13 -13
  91. package/dist/components/index.cjs +137 -131
  92. package/dist/components/index.cjs.map +1 -1
  93. package/dist/components/index.js +17 -11
  94. package/dist/components/index.js.map +1 -1
  95. package/dist/components/meeting-scheduler/index.cjs +28 -28
  96. package/dist/components/meeting-scheduler/index.js +3 -3
  97. package/dist/components/navigation/index.cjs +2 -2
  98. package/dist/components/navigation/index.js +1 -1
  99. package/dist/components/onboarding-guides/index.cjs +5 -5
  100. package/dist/components/onboarding-guides/index.js +4 -4
  101. package/dist/components/related-content/index.cjs +3 -3
  102. package/dist/components/related-content/index.js +2 -2
  103. package/dist/components/tickets/index.cjs +6 -6
  104. package/dist/components/tickets/index.js +5 -5
  105. package/dist/components/ui/index.cjs +2 -2
  106. package/dist/components/ui/index.js +1 -1
  107. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.cjs.map +1 -1
  110. package/dist/index.js +7 -1
  111. package/dist/utils/auto-scroll-ancestors.d.ts +43 -0
  112. package/dist/utils/auto-scroll-ancestors.d.ts.map +1 -0
  113. package/dist/utils/drag-effect.d.ts +21 -0
  114. package/dist/utils/drag-effect.d.ts.map +1 -0
  115. package/dist/utils/scroll-parent.d.ts +11 -0
  116. package/dist/utils/scroll-parent.d.ts.map +1 -0
  117. package/package.json +6 -3
  118. package/src/__tests__/board-aim.test.ts +152 -0
  119. package/src/__tests__/board-pending-move.test.ts +113 -0
  120. package/src/__tests__/board-resolve-drop.test.ts +68 -0
  121. package/src/__tests__/sortable-slot-geometry.test.ts +105 -0
  122. package/src/components/features/board/.board-column.md +17 -4
  123. package/src/components/features/board/.board.md +11 -7
  124. package/src/components/features/board/.ticket-card.md +27 -5
  125. package/src/components/features/board/aim.ts +131 -0
  126. package/src/components/features/board/board-column-header.tsx +2 -9
  127. package/src/components/features/board/board-column.tsx +137 -66
  128. package/src/components/features/board/board.tsx +501 -258
  129. package/src/components/features/board/drop-aim.ts +50 -0
  130. package/src/components/features/board/lane-geometry.ts +44 -0
  131. package/src/components/features/board/pending-move.ts +116 -0
  132. package/src/components/features/board/resolve-drop.ts +28 -0
  133. package/src/components/features/board/ticket-card.tsx +256 -47
  134. package/src/components/features/board/use-board-collapse.ts +16 -14
  135. package/src/components/features/board/use-lane-scroll-anchor.ts +79 -0
  136. package/src/components/features/index.ts +1 -0
  137. package/src/components/features/sortable-list/.sortable-list.md +82 -0
  138. package/src/components/features/sortable-list/index.ts +3 -0
  139. package/src/components/features/sortable-list/slot-geometry.ts +100 -0
  140. package/src/components/features/sortable-list/sortable-list.tsx +270 -0
  141. package/src/components/features/sortable-list/use-sortable-item.ts +92 -0
  142. package/src/components/ui/square-avatar.tsx +5 -0
  143. package/src/stories/.TicketCard.stories.md +6 -12
  144. package/src/stories/Board.stories.tsx +16 -7
  145. package/src/stories/TicketCard.stories.tsx +38 -76
  146. package/src/utils/auto-scroll-ancestors.ts +90 -0
  147. package/src/utils/drag-effect.ts +26 -0
  148. package/src/utils/scroll-parent.ts +23 -0
  149. package/dist/chunk-S4LL6QI6.cjs.map +0 -1
  150. package/dist/chunk-XGBVHQP7.js.map +0 -1
  151. /package/dist/{chunk-5756Q6UN.js.map → chunk-25YLMIWU.js.map} +0 -0
  152. /package/dist/{chunk-FLSEJSTN.js.map → chunk-26HAJBSQ.js.map} +0 -0
  153. /package/dist/{chunk-JRWOHH7K.js.map → chunk-DRNSIK3V.js.map} +0 -0
  154. /package/dist/{chunk-VF4DCOPD.js.map → chunk-FXVMRZIQ.js.map} +0 -0
  155. /package/dist/{chunk-JXJ4FTKN.js.map → chunk-G7ROZAY5.js.map} +0 -0
  156. /package/dist/{chunk-5M4PS3XX.js.map → chunk-LJWTXODF.js.map} +0 -0
  157. /package/dist/{chunk-CGUSNV6R.js.map → chunk-NNGQ6ONX.js.map} +0 -0
  158. /package/dist/{chunk-VXNMOGRP.js.map → chunk-RPCY2FAC.js.map} +0 -0
  159. /package/dist/{chunk-WX5UCLL7.js.map → chunk-RRWWAH37.js.map} +0 -0
  160. /package/dist/{chunk-5JWLZJK5.js.map → chunk-SBKX2VSG.js.map} +0 -0
  161. /package/dist/{chunk-5EES5HD4.js.map → chunk-UTEQHK3H.js.map} +0 -0
  162. /package/dist/{chunk-YM2WHYYK.js.map → chunk-VJL46NHW.js.map} +0 -0
  163. /package/dist/{chunk-FZT7BYNF.js.map → chunk-Y6TXTBIQ.js.map} +0 -0
@@ -1,29 +1,59 @@
1
1
  'use client'
2
2
 
3
- import * as React from 'react'
4
- import {
5
- DndContext,
6
- DragOverlay,
7
- KeyboardSensor,
8
- PointerSensor,
9
- closestCorners,
10
- pointerWithin,
11
- rectIntersection,
12
- useSensor,
13
- useSensors,
14
- type CollisionDetection,
15
- type DragEndEvent,
16
- type DragOverEvent,
17
- type DragStartEvent,
18
- } from '@dnd-kit/core'
19
- import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
3
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
4
+ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
5
+ import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled'
6
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
7
+ import { createPortal } from 'react-dom'
20
8
  import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollbar'
9
+ import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect'
10
+ import { autoScrollAncestors, autoScrollBothWays } from '../../../utils/auto-scroll-ancestors'
11
+ import { nearestScrollableAncestor } from '../../../utils/scroll-parent'
21
12
  import { cn } from '../../../utils/cn'
22
13
  import { BoardColumn } from './board-column'
23
- import { TicketCard } from './ticket-card'
14
+ import { BoardLiftContext, type DropAim, DropAimContext, isSameAim } from './drop-aim'
15
+ import { aimAnchor, aimFromTarget, aimToChange, type ArrowKey, type BoardAim, initialAim, laneOf, moveAim } from './aim'
16
+ import { cardById, DROP_LINE_ATTRIBUTE } from './lane-geometry'
17
+ import { DRAG_PREVIEW_OPACITY, TicketCardView } from './ticket-card'
18
+ import { applyPendingMove, hasMoveSettled, LandingCardContext, type PendingMove } from './pending-move'
19
+ import { resolveBoardDrop } from './resolve-drop'
24
20
  import type { BoardChange, BoardColumnDef, BoardTicket } from './types'
25
21
  import { useBoardCollapse } from './use-board-collapse'
26
22
 
23
+ /** How long the board may keep standing in for `columns` while waiting for the
24
+ * host's answer. Only a host that drops the change on the floor ever reaches
25
+ * this — it exists so a failed move can't be shown as done forever, not as a
26
+ * timing assumption. */
27
+ const PENDING_MOVE_TIMEOUT_MS = 2000
28
+
29
+ /** How long a dropped card takes to travel from the pointer into its slot. */
30
+ const DROP_MS = 160
31
+ const DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)'
32
+
33
+ /** Above the header, modals and drawers, below toasts — a card being carried
34
+ * has to clear everything it is being carried over. */
35
+ const DRAG_PREVIEW_Z = 1400
36
+
37
+ const ARROW_KEYS: ArrowKey[] = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
38
+
39
+ /** What it takes to draw the carried card again, at rest, exactly where the
40
+ * browser's drag image was when it was let go. Measured once at drag start. */
41
+ interface DragGeometry {
42
+ ticket: BoardColumnDef['tickets'][number]
43
+ columnColor?: string
44
+ width: number
45
+ /** Pointer-to-card-corner offset, so the copy lands under the same grip. */
46
+ grabX: number
47
+ grabY: number
48
+ }
49
+
50
+ /** The short-lived copy that glides into the slot after a drop. */
51
+ interface LandingPreview extends DragGeometry {
52
+ /** Where the pointer was when the card was released. */
53
+ startX: number
54
+ startY: number
55
+ }
56
+
27
57
  export interface BoardProps {
28
58
  columns: BoardColumnDef[]
29
59
  onChange: (change: BoardChange) => void
@@ -31,7 +61,7 @@ export interface BoardProps {
31
61
  onAddTicket?: (columnId: string) => void
32
62
  onArchiveColumn?: (columnId: string) => void
33
63
  getTicketHref?: (ticketId: string) => string
34
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
64
+ renderAssignSlot?: (ticket: BoardColumnDef['tickets'][number]) => ReactNode
35
65
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
36
66
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
37
67
  collapseStorageKey?: string
@@ -39,6 +69,17 @@ export interface BoardProps {
39
69
  className?: string
40
70
  }
41
71
 
72
+ /**
73
+ * The board renders `columns` and nothing else — there is no local mirror of
74
+ * them, and a drag in progress changes no state here at all.
75
+ *
76
+ * That is the whole design. A card that previews its move by actually moving in
77
+ * the data has to re-enter React on every lane crossing, which re-mounts it
78
+ * under a new parent and re-measures the board; guarding that cascade is what
79
+ * the old dnd-kit implementation spent a frame-freeze and a unit-tested rules
80
+ * module on. Here the preview is a line drawn by the card being hovered, the
81
+ * data moves once, on drop, and the cascade has nothing to feed on.
82
+ */
42
83
  export function Board({
43
84
  columns,
44
85
  onChange,
@@ -68,269 +109,471 @@ export function Board({
68
109
  onThumbPointerUp,
69
110
  } = useHorizontalScrollbar()
70
111
 
71
- const [items, setItems] = React.useState<BoardColumnDef[]>(columns)
72
- const isDraggingRef = React.useRef(false)
73
-
74
- React.useEffect(() => {
75
- if (!isDraggingRef.current) setItems(columns)
76
- }, [columns])
77
-
78
- const dragOriginRef = React.useRef<{ ticketId: string; fromColumnId: string } | null>(null)
79
- const [activeTicket, setActiveTicket] = React.useState<{ ticket: BoardTicket; columnId: string } | null>(null)
112
+ // The monitor is registered once for the board's lifetime; these keep it
113
+ // reading current values without tearing it down on every render.
114
+ const columnsRef = useRef(columns)
115
+ const onChangeRef = useRef(onChange)
116
+ onChangeRef.current = onChange
117
+
118
+ // `useHorizontalScrollbar` hands back a callback ref, and auto-scroll needs
119
+ // the node itself so keep our own alongside it on the same element.
120
+ const boardRef = useRef<HTMLDivElement | null>(null)
121
+ const scrollerRef = useRef<HTMLDivElement | null>(null)
122
+ const setScroller = useCallback(
123
+ (node: HTMLDivElement | null) => {
124
+ scrollerRef.current = node
125
+ scrollRef(node)
126
+ },
127
+ [scrollRef],
128
+ )
80
129
 
81
- const sensors = useSensors(
82
- useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
83
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
130
+ // Where the drop points. One answer for the whole board, handed to the cards
131
+ // so the preview follows it wherever it came from — see `drop-aim.ts`.
132
+ const [aim, setAim] = useState<DropAim | null>(null)
133
+
134
+ // A card lifted with the keyboard. Jira's model, and the only one that works
135
+ // here: an arrow key cannot commit a move of its own, because each one would
136
+ // be a separate write to the server — so the card is lifted, walked to where
137
+ // it belongs, and put down once.
138
+ const [lifted, setLifted] = useState<{ ticketId: string; aim: BoardAim } | null>(null)
139
+ const [announcement, setAnnouncement] = useState('')
140
+
141
+ // The card being carried is the browser's own drag image (see
142
+ // `ticket-card.tsx`), so nothing here follows the pointer and a drag re-renders
143
+ // nothing. This is measured at drag start and only used at the end, to put a
144
+ // copy back where that image was and glide it into place.
145
+ const dragGeometry = useRef<DragGeometry | null>(null)
146
+ const [landingPreview, setLandingPreview] = useState<LandingPreview | null>(null)
147
+ const previewRef = useRef<HTMLDivElement | null>(null)
148
+
149
+ // A move that has been reported but not answered yet. The board shows it
150
+ // itself for that gap — see `pending-move.ts` for why the card cannot simply
151
+ // wait for the host, and why waiting is what made a drop look like the ticket
152
+ // disappearing and reappearing.
153
+ const [pendingMove, setPendingMove] = useState<PendingMove | null>(null)
154
+ const view = useMemo(() => (pendingMove ? applyPendingMove(columns, pendingMove) : columns), [columns, pendingMove])
155
+ // EVERYTHING downstream reads `view`, never `columns`: it is what is on
156
+ // screen, so it is also what a drag, a keyboard move and a drop are about.
157
+ // `columns` is only ever consulted to ask whether the host has caught up.
158
+ columnsRef.current = view
159
+
160
+ // The card still travelling from the pointer into the slot it landed in. The
161
+ // preview is over it for that moment, so the card itself is hidden.
162
+ const [landing, setLanding] = useState<string | null>(null)
163
+ const landingTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
164
+ /** Where the pointer was last seen, in the same terms the preview is placed. */
165
+ const pointRef = useRef({ x: 0, y: 0 })
166
+
167
+ // ---- keyboard move -------------------------------------------------------
168
+
169
+ const laneLabel = useCallback(
170
+ (columnId: string) => view.find(c => c.id === columnId)?.label ?? columnId,
171
+ [view],
84
172
  )
85
173
 
86
- const collisionDetection = React.useCallback<CollisionDetection>((args) => {
87
- const pointer = pointerWithin(args)
88
- const intersections = pointer.length > 0 ? pointer : rectIntersection(args)
174
+ /** Lanes the lifted card is allowed into — the same rules a pointer drop obeys. */
175
+ const acceptsLifted = useCallback(
176
+ (fromColumnId: string) => (column: BoardColumnDef) => {
177
+ if (column.dropDisabled || collapsed[column.id]) return false
178
+ if (column.id === fromColumnId) return true
179
+ return !column.allowedFromColumns || column.allowedFromColumns.includes(fromColumnId)
180
+ },
181
+ [collapsed],
182
+ )
89
183
 
90
- const ticketHit = intersections.find(
91
- c => c.data?.droppableContainer?.data?.current?.type === 'ticket',
92
- )
93
- if (ticketHit) return [ticketHit]
184
+ const lift = useCallback(
185
+ (ticketId: string) => {
186
+ const start = initialAim(view, ticketId)
187
+ if (!start) return
188
+ setLifted({ ticketId, aim: start })
189
+ const title = laneOf(view, ticketId)?.tickets.find(t => t.id === ticketId)?.title ?? 'Ticket'
190
+ setAnnouncement(`${title} lifted. Use the arrow keys to move it, space to drop it, escape to put it back.`)
191
+ },
192
+ [view],
193
+ )
94
194
 
95
- const columnHit = intersections.find(
96
- c => c.data?.droppableContainer?.data?.current?.type === 'column',
97
- )
98
- if (columnHit) {
99
- const columnId = columnHit.data?.droppableContainer?.data?.current?.columnId
100
- const ticketsInColumn = args.droppableContainers.filter(c => {
101
- const d = c.data.current as { type?: string; columnId?: string } | undefined
102
- return d?.type === 'ticket' && d.columnId === columnId
103
- })
104
- if (ticketsInColumn.length > 0) {
105
- const closest = closestCorners({ ...args, droppableContainers: ticketsInColumn })
106
- if (closest.length > 0) return closest
107
- }
108
- return [columnHit]
195
+ const drop = useCallback(() => {
196
+ if (!lifted) return
197
+ const change = aimToChange(view, lifted.ticketId, lifted.aim)
198
+ setLifted(null)
199
+ if (!change) {
200
+ setAnnouncement('Put back where it was.')
201
+ return
109
202
  }
203
+ setAnnouncement(`Dropped into ${laneLabel(change.toColumnId)}.`)
204
+ setPendingMove({
205
+ ticketId: change.ticketId,
206
+ fromColumnId: change.fromColumnId,
207
+ toColumnId: change.toColumnId,
208
+ afterTicketId: change.afterTicketId,
209
+ })
210
+ onChange(change)
211
+ }, [lifted, view, laneLabel, onChange])
212
+
213
+ const walk = useCallback(
214
+ (key: ArrowKey) => {
215
+ if (!lifted) return
216
+ const from = laneOf(view, lifted.ticketId)?.id ?? lifted.aim.columnId
217
+ const next = moveAim(view, lifted.ticketId, lifted.aim, key, acceptsLifted(from))
218
+ if (next === lifted.aim) return
219
+ setLifted({ ...lifted, aim: next })
220
+ const total = (view.find(c => c.id === next.columnId)?.tickets ?? []).filter(
221
+ t => t.id !== lifted.ticketId,
222
+ ).length
223
+ setAnnouncement(`${laneLabel(next.columnId)}, position ${next.index + 1} of ${total + 1}.`)
224
+ },
225
+ [lifted, view, acceptsLifted, laneLabel],
226
+ )
110
227
 
111
- return closestCorners(args)
112
- }, [])
113
-
114
- const handleDragStart = (e: DragStartEvent) => {
115
- const id = String(e.active.id)
116
- const located = locate(items, id)
117
- if (!located) return
118
- isDraggingRef.current = true
119
- dragOriginRef.current = { ticketId: id, fromColumnId: located.columnId }
120
- setActiveTicket({ ticket: located.ticket, columnId: located.columnId })
121
- }
122
-
123
- const handleDragOver = (e: DragOverEvent) => {
124
- const { active, over } = e
125
- if (!over) return
126
-
127
- const activeId = String(active.id)
128
- const overId = String(over.id)
129
- if (activeId === overId) return
130
-
131
- const overData = over.data.current as { columnId?: string; type?: string } | undefined
132
- const fromColumnId = locate(items, activeId)?.columnId
133
- const toColumnId = overData?.columnId
134
- if (!fromColumnId || !toColumnId || fromColumnId === toColumnId) return
135
-
136
- const origin = dragOriginRef.current
137
- const isReturnToOrigin = origin?.fromColumnId === toColumnId
138
- const targetCol = items.find(c => c.id === toColumnId)
139
- const blockedBySource =
140
- !isReturnToOrigin &&
141
- !!targetCol?.allowedFromColumns &&
142
- !!origin &&
143
- !targetCol.allowedFromColumns.includes(origin.fromColumnId)
144
- if ((targetCol?.dropDisabled && !isReturnToOrigin) || blockedBySource) return
145
-
146
- setItems(prev => {
147
- const fromIndex = findIndexInColumn(prev, fromColumnId, activeId)
148
- const toCol = prev.find(c => c.id === toColumnId)
149
- if (fromIndex < 0 || !toCol) return prev
150
-
151
- let toIndex: number
152
- if (overData?.type === 'column') {
153
- toIndex = toCol.tickets.length
228
+ // Handled on the board rather than on each card: once a card is lifted the
229
+ // keys belong to the move, not to whatever happens to hold focus.
230
+ useEffect(() => {
231
+ if (!lifted) return
232
+ const onKeyDown = (event: KeyboardEvent) => {
233
+ if (event.key === 'Escape') {
234
+ setLifted(null)
235
+ setAnnouncement('Move cancelled.')
236
+ } else if (event.key === ' ' || event.key === 'Enter') {
237
+ drop()
238
+ } else if (ARROW_KEYS.includes(event.key as ArrowKey)) {
239
+ walk(event.key as ArrowKey)
154
240
  } else {
155
- const overIndex = toCol.tickets.findIndex(t => t.id === overId)
156
- if (overIndex < 0) {
157
- toIndex = toCol.tickets.length
158
- } else {
159
- const activeRect = active.rect.current.translated
160
- const overRect = over.rect
161
- const isBelow = !!activeRect && activeRect.top > overRect.top + overRect.height / 2
162
- toIndex = overIndex + (isBelow ? 1 : 0)
163
- }
241
+ return
164
242
  }
243
+ // Arrows would scroll the lane out from under the card, and space would
244
+ // scroll the page.
245
+ event.preventDefault()
246
+ }
247
+ window.addEventListener('keydown', onKeyDown)
248
+ return () => window.removeEventListener('keydown', onKeyDown)
249
+ }, [lifted, drop, walk])
250
+
251
+ // A card walked with the keyboard has to be visible where it is being walked
252
+ // to. A pointer drag brings its own scrolling — auto-scroll follows it to the
253
+ // edges — but arrow keys move nothing on screen, so a slot below the fold, or
254
+ // in a lane scrolled off the side of the board, was being aimed at completely
255
+ // out of sight. `lifted` is a new object on every step, so this runs per step.
256
+ useEffect(() => {
257
+ if (!lifted) return
258
+ boardRef.current?.querySelector(`[${DROP_LINE_ATTRIBUTE}]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
259
+ }, [lifted])
260
+
261
+ /** The lifted card feeds the same preview a pointer drag does, so the room
262
+ * opening and the faded copy need to know nothing about keyboards. */
263
+ const keyboardAim = useMemo<DropAim | null>(() => {
264
+ if (!lifted) return null
265
+ const ticket = laneOf(view, lifted.ticketId)?.tickets.find(t => t.id === lifted.ticketId)
266
+ if (!ticket) return null
267
+ const anchor = aimToChange(view, lifted.ticketId, lifted.aim)
268
+ ? aimAnchor(view, lifted.ticketId, lifted.aim)
269
+ : { ticketId: null, edge: null }
270
+ return { columnId: lifted.aim.columnId, ...anchor, ticket }
271
+ }, [lifted, view])
272
+
273
+ // Deliberately reads the RAW `columns` — the question is whether the host has
274
+ // caught up, and `view` already has the answer painted into it.
275
+ useEffect(() => {
276
+ if (!pendingMove) return
277
+ // Handing back once the move has landed matters: a host that rolls a failed
278
+ // move back puts the card in its old slot, and a `pendingMove` still sitting
279
+ // here would keep showing the move as though it had worked.
280
+ if (hasMoveSettled(columns, pendingMove)) {
281
+ setPendingMove(null)
282
+ return
283
+ }
284
+ const timer = setTimeout(() => setPendingMove(null), PENDING_MOVE_TIMEOUT_MS)
285
+ return () => clearTimeout(timer)
286
+ }, [columns, pendingMove])
287
+
288
+ useEffect(() => {
289
+ const scroller = scrollerRef.current
290
+
291
+ /** What the innermost drop target is currently saying, in the one shape the
292
+ * cards need. A card and a lane answer in the same terms, so this does not
293
+ * care which one replied — and nothing outside a lane replies at all. */
294
+ const readAim = (source: { data: Record<string | symbol, unknown> }, target?: Record<string | symbol, unknown>) => {
295
+ const ticket = source.data.ticket as BoardTicket | undefined
296
+ if (!target || !ticket) return null
297
+ const ticketId = String(source.data.ticketId)
298
+ const slot = aimFromTarget(columnsRef.current, ticketId, target)
299
+ if (!slot) return null
300
+ // No line where the card already is. A drop there changes nothing, and
301
+ // marking a place that is not a move is noise around the card you are
302
+ // holding — the one thing every board gets right by NOT drawing it.
303
+ const anchor = aimToChange(columnsRef.current, ticketId, slot)
304
+ ? aimAnchor(columnsRef.current, ticketId, slot)
305
+ : { ticketId: null, edge: null }
306
+ return { columnId: slot.columnId, ...anchor, ticket }
307
+ }
165
308
 
166
- const next = prev.map(c =>
167
- c.id === fromColumnId || c.id === toColumnId ? { ...c, tickets: [...c.tickets] } : c,
168
- )
169
- const nextFrom = next.find(c => c.id === fromColumnId)
170
- const nextTo = next.find(c => c.id === toColumnId)
171
- if (!nextFrom || !nextTo) return prev
172
- const [moved] = nextFrom.tickets.splice(fromIndex, 1)
173
- nextTo.tickets.splice(toIndex, 0, moved)
174
- return next
175
- })
176
- }
309
+ const aimAt = (
310
+ source: { data: Record<string | symbol, unknown> },
311
+ targets: { data: Record<string | symbol, unknown> }[],
312
+ ) => {
313
+ const next = readAim(source, targets[0]?.data)
314
+ // A pointer moving inside one card re-resolves to the same answer every
315
+ // frame; keeping the previous object stops that re-rendering the board.
316
+ setAim(prev => (isSameAim(prev, next) ? prev : next))
317
+ }
177
318
 
178
- const handleDragEnd = (e: DragEndEvent) => {
179
- const origin = dragOriginRef.current
180
- dragOriginRef.current = null
181
- setActiveTicket(null)
182
- isDraggingRef.current = false
319
+ return combine(
320
+ // Dragging towards the edge of the board scrolls the lanes sideways —
321
+ // and keeps scrolling once the pointer is past that edge.
322
+ scroller ? autoScrollBothWays(scroller) : () => {},
323
+ // ...and towards the edge of the SCREEN scrolls whatever the page is
324
+ // actually scrolled by, which is the layout's `<main>`, not the document.
325
+ // Auto-scroll is registered on those; a DROP is not. Nothing outside the
326
+ // lanes is a drop target: a card carried over the filters, the header or
327
+ // the sidebar is aimed at nothing, draws nothing, and cancels when
328
+ // released. Resolving a lane from a pointer that has left the board — by
329
+ // matching it horizontally to the column above or below it — was tried and
330
+ // is not what a board does; the answer has to be somewhere the user is
331
+ // actually pointing.
332
+ monitorForElements({
333
+ canMonitor: ({ source }) => source.data.type === 'ticket',
334
+
335
+ onDragStart: ({ source, location }) => {
336
+ const ticket = source.data.ticket as BoardColumnDef['tickets'][number] | undefined
337
+ if (!ticket) return
338
+ const rect = source.element.getBoundingClientRect()
339
+ const { clientX, clientY } = location.current.input
340
+ pointRef.current = { x: clientX, y: clientY }
341
+ // Nothing outside the lanes is a drop target, and a native drag with
342
+ // no target under it falls back to the browser's own drop effect —
343
+ // which is `copy`, i.e. the green plus cursor, over the filters, the
344
+ // header and the sidebar. This is Pragmatic's own answer: it holds the
345
+ // effect at `move` everywhere, so the cursor stays a plain drag cursor
346
+ // wherever the card is carried. It changes nothing about WHERE the
347
+ // card can land — the board still resolves that from its own targets,
348
+ // and releasing outside them still cancels.
349
+ preventUnhandled.start()
350
+ // A previous card may still be gliding into place; this drag owns the
351
+ // preview from here, so that one arrives instantly instead.
352
+ setLanding(null)
353
+ dragGeometry.current = {
354
+ ticket,
355
+ columnColor: columnsRef.current.find(c => c.id === source.data.columnId)?.color,
356
+ width: rect.width,
357
+ grabX: clientX - rect.left,
358
+ grabY: clientY - rect.top,
359
+ }
360
+ },
361
+
362
+ onDropTargetChange: ({ source, location }) => aimAt(source, location.current.dropTargets),
363
+
364
+ onDrag: ({ source, location }) => {
365
+ // The edge can flip without the set of targets changing, so the aim is
366
+ // re-read here too — `onDrag` is throttled to an animation frame.
367
+ aimAt(source, location.current.dropTargets)
368
+ const { clientX, clientY } = location.current.input
369
+ pointRef.current = { x: clientX, y: clientY }
370
+ },
371
+
372
+ onDrop: ({ source, location }) => {
373
+ preventUnhandled.stop()
374
+ setAim(null)
375
+ // Innermost first: a card if the pointer was over one, otherwise the
376
+ // lane it was dropped into. Empty means dropped outside the board.
377
+ const change = resolveBoardDrop(columnsRef.current, {
378
+ source: source.data,
379
+ target: location.current.dropTargets[0]?.data,
380
+ })
381
+ // Released where nothing would change, or outside the board entirely.
382
+ // The browser takes its drag image away by itself and the card un-fades
383
+ // on its own `onDrop`, in this same event, so there is nothing to undo.
384
+ if (!change) return
385
+
386
+ // The move goes on screen NOW, from here, and the host's answer
387
+ // replaces an identical picture whenever it arrives.
388
+ setPendingMove({
389
+ ticketId: change.ticketId,
390
+ fromColumnId: change.fromColumnId,
391
+ toColumnId: change.toColumnId,
392
+ afterTicketId: change.afterTicketId,
393
+ })
394
+ // ...and a copy of the card takes over from the drag image for one
395
+ // short glide into the slot that move just opened. Both land in the
396
+ // same commit, so the slot is already there to be measured — see the
397
+ // effect below.
398
+ const geometry = dragGeometry.current
399
+ if (geometry) {
400
+ setLandingPreview({ ...geometry, startX: pointRef.current.x, startY: pointRef.current.y })
401
+ setLanding(change.ticketId)
402
+ }
403
+ onChangeRef.current(change)
404
+ },
405
+ }),
406
+ )
407
+ }, [])
183
408
 
184
- const { over } = e
185
- if (!over || !origin) {
186
- setItems(columns)
187
- return
409
+ /**
410
+ * The dropped card's glide from where it was released into its slot.
411
+ *
412
+ * Runs in the commit that carries the move itself, so the slot already exists
413
+ * and can be measured; the card in it is hidden (see `LandingCardContext`) and
414
+ * this copy is what the eye follows the rest of the way. Without it the
415
+ * browser's drag image simply vanishes at the pointer and the card is suddenly
416
+ * elsewhere — the discontinuity the whole drop sequence exists to remove.
417
+ *
418
+ * A plain FLIP, written out rather than split between the render and here: the
419
+ * copy is placed where the drag image was, that position is forced to resolve,
420
+ * and only then is it given a destination. Skipping the forced resolve means
421
+ * the browser only ever sees the final value and animates from nothing — an
422
+ * element's very first style resolution never starts a transition.
423
+ */
424
+ useIsomorphicLayoutEffect(() => {
425
+ if (!landing) return
426
+
427
+ const finish = () => {
428
+ landingTimer.current = null
429
+ setLanding(null)
430
+ setLandingPreview(null)
188
431
  }
189
432
 
190
- const located = locate(items, origin.ticketId)
191
- if (!located) {
192
- setItems(columns)
433
+ const preview = previewRef.current
434
+ const card = boardRef.current && cardById(boardRef.current, landing)
435
+ if (!preview || !landingPreview || !card) {
436
+ finish()
193
437
  return
194
438
  }
195
439
 
196
- const toColumnId = located.columnId
197
- const isCrossColumn = origin.fromColumnId !== toColumnId
198
- const targetCol = items.find(c => c.id === toColumnId)
199
- if (
200
- isCrossColumn &&
201
- (targetCol?.dropDisabled ||
202
- (targetCol?.allowedFromColumns && !targetCol.allowedFromColumns.includes(origin.fromColumnId)))
203
- ) {
204
- setItems(columns)
440
+ const to = card.getBoundingClientRect()
441
+ // The slot has to be somewhere the eye can actually follow the card to. A
442
+ // lane clips its own overflow, so a slot hanging over its edge would take a
443
+ // card that is whole one frame and cut in half the next; the card simply
444
+ // appears in place instead, which is never wrong — only less pretty.
445
+ const lane = nearestScrollableAncestor(card)?.getBoundingClientRect()
446
+ if (lane && (to.top < lane.top || to.bottom > lane.bottom)) {
447
+ finish()
205
448
  return
206
449
  }
207
450
 
208
- let finalIndex = located.index
209
- let finalColumnTickets = items.find(c => c.id === toColumnId)?.tickets ?? []
210
-
211
- const overData = over.data.current as { columnId?: string; type?: string } | undefined
212
- if (overData?.type === 'ticket') {
213
- const overIndex = findIndexInColumn(items, toColumnId, String(over.id))
214
- if (overIndex >= 0 && overIndex !== located.index) {
215
- finalColumnTickets = arrayMove(finalColumnTickets, located.index, overIndex)
216
- setItems(
217
- items.map(c =>
218
- c.id !== toColumnId ? c : { ...c, tickets: finalColumnTickets },
219
- ),
220
- )
221
- finalIndex = overIndex
222
- }
451
+ const { startX, startY } = landingPreview
452
+ preview.style.transition = 'none'
453
+ preview.style.opacity = String(DRAG_PREVIEW_OPACITY)
454
+ preview.style.transform = `translate(${startX}px, ${startY}px)`
455
+ const from = preview.getBoundingClientRect()
456
+ void preview.offsetHeight
457
+
458
+ // Settles solid as it lands, so handing over to the real card underneath is
459
+ // not also a jump in brightness.
460
+ preview.style.transition = `transform ${DROP_MS}ms ${DROP_EASING}, opacity ${DROP_MS}ms ${DROP_EASING}`
461
+ preview.style.opacity = '1'
462
+ preview.style.transform = `translate(${startX + to.left - from.left}px, ${startY + to.top - from.top}px)`
463
+ landingTimer.current = setTimeout(finish, DROP_MS)
464
+ return () => {
465
+ if (landingTimer.current) clearTimeout(landingTimer.current)
466
+ landingTimer.current = null
223
467
  }
224
-
225
- if (origin.fromColumnId === toColumnId) {
226
- const originIndex = findIndexInColumn(columns, origin.fromColumnId, origin.ticketId)
227
- if (originIndex === finalIndex) return
228
- }
229
-
230
- onChange({
231
- ticketId: origin.ticketId,
232
- fromColumnId: origin.fromColumnId,
233
- toColumnId,
234
- afterTicketId: finalColumnTickets[finalIndex - 1]?.id ?? null,
235
- beforeTicketId: finalColumnTickets[finalIndex + 1]?.id ?? null,
236
- })
237
- }
238
-
239
- const handleDragCancel = () => {
240
- dragOriginRef.current = null
241
- setActiveTicket(null)
242
- isDraggingRef.current = false
243
- setItems(columns)
244
- }
468
+ }, [landing, landingPreview])
469
+
470
+ // The released card, on its way into its slot. Alive for one short animation
471
+ // and no longer: while the card is actually being carried there is nothing
472
+ // here at all — that is the browser's drag image (see `ticket-card.tsx`).
473
+ //
474
+ // A portal, because a lane clips its own overflow and this travels across the
475
+ // whole board. Position, opacity and transition are all written by the effect
476
+ // above and deliberately not by React: a re-render for an unrelated reason
477
+ // must not put the card back at the pointer half way down.
478
+ const preview =
479
+ landingPreview &&
480
+ createPortal(
481
+ <div
482
+ ref={previewRef}
483
+ aria-hidden
484
+ // `[&_*]:!pointer-events-none` and not just the root: the card body
485
+ // re-enables pointer events on its own parts (the assignee slot, the
486
+ // timestamp tooltip), and this sits over the board while it settles.
487
+ className="pointer-events-none fixed left-0 top-0 [&_*]:!pointer-events-none"
488
+ style={{
489
+ zIndex: DRAG_PREVIEW_Z,
490
+ width: landingPreview.width,
491
+ // The grip offset lives here, so the animated property is a plain
492
+ // translate in pointer coordinates and nothing else.
493
+ margin: `${-landingPreview.grabY}px 0 0 ${-landingPreview.grabX}px`,
494
+ }}
495
+ >
496
+ {/* Rendered with the host's assignee slot, like the drag image it takes
497
+ over from: without it the card visibly changes at the handover. It is
498
+ a live control, but an inert copy of one — nothing in this subtree
499
+ can be pointed at (see above) — and it shares its queries with the
500
+ card it was copied from. */}
501
+ <TicketCardView
502
+ ticket={landingPreview.ticket}
503
+ columnColor={landingPreview.columnColor}
504
+ renderAssignSlot={renderAssignSlot}
505
+ // Still held, to match the drag image at the moment it hands over.
506
+ className="shadow-card-hover"
507
+ />
508
+ </div>,
509
+ document.body,
510
+ )
245
511
 
246
512
  return (
247
- <DndContext
248
- sensors={sensors}
249
- collisionDetection={collisionDetection}
250
- onDragStart={handleDragStart}
251
- onDragOver={handleDragOver}
252
- onDragEnd={handleDragEnd}
253
- onDragCancel={handleDragCancel}
254
- >
255
- <div className={cn('flex flex-col h-full', className)}>
256
- <div
257
- ref={scrollRef}
258
- onScroll={onScroll}
259
- className="flex flex-1 min-h-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
260
- >
261
- {items.map((column, i) => {
262
- const prev = items[i - 1]
263
- const next = items[i + 1]
264
- const joinLeft = !!(column.system && prev?.system)
265
- const joinRight = !!(column.system && next?.system)
266
- const showGap = i > 0 && !joinLeft
267
- return (
268
- <React.Fragment key={column.id}>
269
- {showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
270
- <BoardColumn
271
- column={column}
272
- collapsed={!!collapsed[column.id]}
273
- onToggleCollapse={() => toggle(column.id)}
274
- onAddTicket={onAddTicket}
275
- onArchive={onArchiveColumn}
276
- getTicketHref={getTicketHref}
277
- renderAssignSlot={renderAssignSlot}
278
- onApprove={onApprove}
279
- onReject={onReject}
280
- onLoadMore={onLoadMore}
281
- loadMoreRootMargin={loadMoreRootMargin}
282
- joinLeft={joinLeft}
283
- joinRight={joinRight}
284
- />
285
- </React.Fragment>
286
- )
287
- })}
288
- </div>
289
-
290
- {thumbRatio > 0 && (
291
- <div
292
- ref={trackRef}
293
- onClick={onTrackClick}
294
- onWheel={onTrackWheel}
295
- className="relative h-2 mt-[var(--spacing-system-mf)] rounded-full bg-ods-border cursor-pointer shrink-0"
296
- >
513
+ <LandingCardContext.Provider value={landing}>
514
+ <DropAimContext.Provider value={aim ?? keyboardAim}>
515
+ <BoardLiftContext.Provider value={lift}>
516
+ {preview}
517
+ <div ref={boardRef} className={cn('flex flex-col h-full', className)}>
297
518
  <div
298
- ref={thumbRef}
299
- data-scrollbar-thumb
300
- className="absolute top-0 h-full rounded-full bg-ods-text-secondary transition-colors"
301
- style={{ width: `${thumbRatio * 100}%`, cursor: 'grab' }}
302
- onPointerDown={onThumbPointerDown}
303
- onPointerMove={onThumbPointerMove}
304
- onPointerUp={onThumbPointerUp}
305
- />
519
+ ref={setScroller}
520
+ onScroll={onScroll}
521
+ className="flex flex-1 min-h-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
522
+ >
523
+ {view.map((column, i) => {
524
+ const prev = view[i - 1]
525
+ const next = view[i + 1]
526
+ const joinLeft = !!(column.system && prev?.system)
527
+ const joinRight = !!(column.system && next?.system)
528
+ const showGap = i > 0 && !joinLeft
529
+ return (
530
+ <Fragment key={column.id}>
531
+ {showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
532
+ <BoardColumn
533
+ column={column}
534
+ collapsed={!!collapsed[column.id]}
535
+ onToggleCollapse={toggle}
536
+ onAddTicket={onAddTicket}
537
+ onArchive={onArchiveColumn}
538
+ getTicketHref={getTicketHref}
539
+ renderAssignSlot={renderAssignSlot}
540
+ onApprove={onApprove}
541
+ onReject={onReject}
542
+ onLoadMore={onLoadMore}
543
+ loadMoreRootMargin={loadMoreRootMargin}
544
+ joinLeft={joinLeft}
545
+ joinRight={joinRight}
546
+ />
547
+ </Fragment>
548
+ )
549
+ })}
550
+ </div>
551
+
552
+ <p aria-live="polite" className="sr-only">
553
+ {announcement}
554
+ </p>
555
+
556
+ {thumbRatio > 0 && (
557
+ <div
558
+ ref={trackRef}
559
+ onClick={onTrackClick}
560
+ onWheel={onTrackWheel}
561
+ className="relative h-2 mt-[var(--spacing-system-mf)] rounded-full bg-ods-border cursor-pointer shrink-0"
562
+ >
563
+ <div
564
+ ref={thumbRef}
565
+ data-scrollbar-thumb
566
+ className="absolute top-0 h-full rounded-full bg-ods-text-secondary transition-colors"
567
+ style={{ width: `${thumbRatio * 100}%`, cursor: 'grab' }}
568
+ onPointerDown={onThumbPointerDown}
569
+ onPointerMove={onThumbPointerMove}
570
+ onPointerUp={onThumbPointerUp}
571
+ />
572
+ </div>
573
+ )}
306
574
  </div>
307
- )}
308
- </div>
309
- <DragOverlay dropAnimation={null}>
310
- {activeTicket ? (
311
- <TicketCard
312
- ticket={activeTicket.ticket}
313
- columnId={activeTicket.columnId}
314
- columnColor={items.find(c => c.id === activeTicket.columnId)?.color}
315
- isOverlay
316
- />
317
- ) : null}
318
- </DragOverlay>
319
- </DndContext>
575
+ </BoardLiftContext.Provider>
576
+ </DropAimContext.Provider>
577
+ </LandingCardContext.Provider>
320
578
  )
321
579
  }
322
-
323
- function locate(
324
- cols: BoardColumnDef[],
325
- ticketId: string,
326
- ): { ticket: BoardTicket; columnId: string; index: number } | null {
327
- for (const c of cols) {
328
- const idx = c.tickets.findIndex(t => t.id === ticketId)
329
- if (idx >= 0) return { ticket: c.tickets[idx], columnId: c.id, index: idx }
330
- }
331
- return null
332
- }
333
-
334
- function findIndexInColumn(cols: BoardColumnDef[], columnId: string, ticketId: string): number {
335
- return cols.find(c => c.id === columnId)?.tickets.findIndex(t => t.id === ticketId) ?? -1
336
- }