@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
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
4
+ import { createContext, useContext } from 'react'
5
+ import type { BoardTicket } from './types'
6
+
7
+ /**
8
+ * Where the drop currently points, resolved once for the whole board.
9
+ *
10
+ * Each card used to work this out for itself, from its own hover events. That
11
+ * only ever answers for a card the pointer is physically over, so the moment the
12
+ * pointer left the lanes — over the filters, between two columns, below the last
13
+ * card — the preview vanished even though the drop would still have landed
14
+ * somewhere. Resolving it in one place and handing it down means the preview
15
+ * follows the answer wherever the answer came from.
16
+ */
17
+ export interface DropAim {
18
+ columnId: string
19
+ /** The card the preview hangs off, or `null` for a lane with nothing in it. */
20
+ ticketId: string | null
21
+ /** Which side of that card. Meaningless when `ticketId` is `null`. */
22
+ edge: Edge | null
23
+ /** The card being carried, drawn faded in the room it opened. */
24
+ ticket: BoardTicket
25
+ }
26
+
27
+ export const DropAimContext = createContext<DropAim | null>(null)
28
+
29
+ /**
30
+ * Picking a card up from the keyboard. Separate from the aim so a card can offer
31
+ * it without subscribing to something that changes on every pointer move.
32
+ */
33
+ export const BoardLiftContext = createContext<((ticketId: string) => void) | null>(null)
34
+
35
+ export function useBoardLift(): ((ticketId: string) => void) | null {
36
+ return useContext(BoardLiftContext)
37
+ }
38
+
39
+ /** Null while no drag is pointing anywhere the board would accept. */
40
+ export function useDropAim(): DropAim | null {
41
+ return useContext(DropAimContext)
42
+ }
43
+
44
+ /** Whether two aims would draw the same thing — used to keep a pointer moving
45
+ * inside one card from re-rendering the board on every frame. */
46
+ export function isSameAim(a: DropAim | null, b: DropAim | null): boolean {
47
+ if (a === b) return true
48
+ if (!a || !b) return false
49
+ return a.columnId === b.columnId && a.ticketId === b.ticketId && a.edge === b.edge
50
+ }
@@ -0,0 +1,44 @@
1
+ import { TICKET_ID_ATTRIBUTE } from './use-lane-scroll-anchor'
2
+
3
+ /** Marks the line showing where a drop would land, wherever it is drawn — on a
4
+ * card or in an empty lane. The keyboard needs to find it to scroll it into
5
+ * view; a pointer drag brings its own scrolling. */
6
+ export const DROP_LINE_ATTRIBUTE = 'data-board-drop-line'
7
+
8
+ /**
9
+ * The card in `lane` whose middle is nearest `pointerY`, ignoring the one being
10
+ * dragged, or `null` if the lane holds no other card.
11
+ *
12
+ * Live rects rather than anything cached: cards carry margins mid-drag that push
13
+ * them about. The dragged card is skipped by name and not by having no box —
14
+ * it keeps its slot in the lane until another card takes over holding the room,
15
+ * so it is a full-height rectangle sitting right where the pointer is.
16
+ */
17
+ export function nearestCardIn(lane: ParentNode, pointerY: number, draggedTicketId: string): HTMLElement | null {
18
+ let best: HTMLElement | null = null
19
+ let bestDistance = Number.POSITIVE_INFINITY
20
+ for (const card of lane.querySelectorAll<HTMLElement>(`[${TICKET_ID_ATTRIBUTE}]`)) {
21
+ if (card.dataset.ticketId === draggedTicketId) continue
22
+ const rect = card.getBoundingClientRect()
23
+ if (rect.height === 0) continue
24
+ const distance = Math.abs(pointerY - (rect.top + rect.height / 2))
25
+ if (distance < bestDistance) {
26
+ bestDistance = distance
27
+ best = card
28
+ }
29
+ }
30
+ return best
31
+ }
32
+
33
+ /**
34
+ * One card by ticket id, anywhere under `root`.
35
+ *
36
+ * A loop rather than an attribute selector: ticket ids are opaque global ids
37
+ * from the host and go straight into a selector string otherwise.
38
+ */
39
+ export function cardById(root: ParentNode, ticketId: string): HTMLElement | null {
40
+ for (const card of root.querySelectorAll<HTMLElement>(`[${TICKET_ID_ATTRIBUTE}]`)) {
41
+ if (card.dataset.ticketId === ticketId) return card
42
+ }
43
+ return null
44
+ }
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext } from 'react'
4
+ import type { BoardColumnDef } from './types'
5
+
6
+ /** The move that was reported, so we can show it and recognise it arriving. */
7
+ export interface PendingMove {
8
+ ticketId: string
9
+ /** The lane it came from — what tells a lane change apart from a reorder. */
10
+ fromColumnId: string
11
+ toColumnId: string
12
+ /** The card it was dropped after, or `null` for the head of the lane. */
13
+ afterTicketId: string | null
14
+ }
15
+
16
+ /**
17
+ * `columns` with a move that has been reported but not yet answered already
18
+ * applied — what the board renders between the drop and the host's new data.
19
+ *
20
+ * A drop cannot repaint the board from the host's data in the same frame it
21
+ * happens: the host is told about the move through `onChange` and answers with
22
+ * new `columns`, and no data layer worth using is synchronous about that —
23
+ * react-query, for one, notifies its subscribers from a `setTimeout(0)`, which
24
+ * is a whole macrotask (and therefore at least one paint) after the drop event.
25
+ * Rendering the host's answer literally therefore means the card is still in its
26
+ * old lane for that gap and arrives in the new one afterwards.
27
+ *
28
+ * Hiding the card across the gap was the previous answer, and it is why a drop
29
+ * read as the ticket vanishing and reappearing somewhere else. Showing the move
30
+ * immediately is the same optimistic update every board of this kind does, and
31
+ * it makes the handover invisible: the host's answer, when it comes, describes
32
+ * exactly the picture already on screen.
33
+ *
34
+ * Untouched columns keep their identity so the memoized lanes can skip the
35
+ * render.
36
+ */
37
+ export function applyPendingMove(columns: BoardColumnDef[], move: PendingMove): BoardColumnDef[] {
38
+ const ticket = columns.flatMap(column => column.tickets).find(t => t.id === move.ticketId)
39
+ if (!ticket || !columns.some(column => column.id === move.toColumnId)) return columns
40
+
41
+ return columns.map(column => {
42
+ const without = column.tickets.filter(t => t.id !== move.ticketId)
43
+ if (column.id !== move.toColumnId) {
44
+ return without.length === column.tickets.length ? column : { ...column, tickets: without }
45
+ }
46
+ return { ...column, tickets: insertAfter(without, ticket, move.afterTicketId) }
47
+ })
48
+ }
49
+
50
+ function insertAfter<T extends { id: string }>(tickets: T[], ticket: T, afterId: string | null): T[] {
51
+ if (afterId === null) return [ticket, ...tickets]
52
+ const after = tickets.findIndex(t => t.id === afterId)
53
+ // The card it was dropped after is gone — archived, or moved by someone else
54
+ // while this drag was in the air. The end of the lane is the honest answer,
55
+ // and the host's own reply overrides it a moment later anyway.
56
+ const at = after < 0 ? tickets.length : after + 1
57
+ return [...tickets.slice(0, at), ticket, ...tickets.slice(at)]
58
+ }
59
+
60
+ /**
61
+ * The id of the card currently flying from the pointer into the slot it was
62
+ * dropped in — hidden for exactly that long, because the drag preview is
63
+ * covering it.
64
+ *
65
+ * Only the one card cares, which is why this is an id and not a flag on every
66
+ * card.
67
+ */
68
+ export const LandingCardContext = createContext<string | null>(null)
69
+
70
+ /** True while this card is under the preview travelling into its slot. */
71
+ export function useIsLanding(ticketId: string): boolean {
72
+ return useContext(LandingCardContext) === ticketId
73
+ }
74
+
75
+ /**
76
+ * Whether THIS move has reached `columns`, so the board can stop standing in for
77
+ * the host and render its data plainly again.
78
+ *
79
+ * Two weaker tests were tried and both hand back too early, at which point the
80
+ * board snaps to whatever the host currently says — the card jumping back to its
81
+ * old lane for a frame:
82
+ *
83
+ * - The identity of the `columns` array. A host on react-query hands the board a
84
+ * fresh one whenever anything notifies — a cancelled refetch, a loading flag,
85
+ * a live update on an untouched lane.
86
+ * - The card no longer sitting at its old index. Each lane here polls on its own
87
+ * timer and takes live updates, so a ticket arriving above this one shifts the
88
+ * index and reads as "the move landed" when nothing of the sort happened.
89
+ *
90
+ * A lane change and a reorder need different tests, though, and using the strict
91
+ * one for both is what left a card to jump seconds after it landed:
92
+ *
93
+ * - **Across lanes**, arriving in the new lane IS the host applying the move.
94
+ * Which slot it ends up in is then the host's business — a server that sorts a
95
+ * lane its own way will not reproduce the dropped index, and holding out for
96
+ * one it will never send means showing a position that is quietly wrong until
97
+ * the timeout corrects it in a visible jump. The old lane has to have let go
98
+ * of the card as well: the two lanes are separate queries answering at their
99
+ * own pace, and standing aside in between would show the card in both.
100
+ * - **Within one lane** the card is in that lane either way, so only the exact
101
+ * slot distinguishes "applied" from "not yet".
102
+ *
103
+ * Answering "not yet" for a move the host never applies is safe: the board gives
104
+ * up on it after a timeout regardless, and the card returns to where the host
105
+ * says it is — which is the correct thing to show once a move has failed.
106
+ */
107
+ export function hasMoveSettled(columns: readonly BoardColumnDef[], move: PendingMove): boolean {
108
+ const tickets = columns.find(c => c.id === move.toColumnId)?.tickets
109
+ if (!tickets) return false
110
+ const index = tickets.findIndex(t => t.id === move.ticketId)
111
+ if (index < 0) return false
112
+ if (move.fromColumnId === move.toColumnId) {
113
+ return (tickets[index - 1]?.id ?? null) === move.afterTicketId
114
+ }
115
+ return !columns.find(c => c.id === move.fromColumnId)?.tickets.some(t => t.id === move.ticketId)
116
+ }
@@ -0,0 +1,28 @@
1
+ import { aimFromTarget, aimToChange } from './aim'
2
+ import type { BoardChange, BoardColumnDef } from './types'
3
+
4
+ /** What a drop carries: the card that moved, and what it was dropped onto. */
5
+ interface DropInput {
6
+ /** `getInitialData` from the dragged card. */
7
+ source: Record<string | symbol, unknown>
8
+ /** The innermost drop target under the pointer — a card, or a lane. */
9
+ target: Record<string | symbol, unknown> | undefined
10
+ }
11
+
12
+ /**
13
+ * Turns a drop into the move to report, or `null` when nothing moved.
14
+ *
15
+ * Both steps live in `aim.ts`, shared with the keyboard and with the line drawn
16
+ * during the drag: the target becomes a slot, the slot becomes a change. Having
17
+ * the drop work it out separately is how a board ends up drawing a line in one
18
+ * place and committing a move to another.
19
+ */
20
+ export function resolveBoardDrop(
21
+ columns: readonly BoardColumnDef[],
22
+ { source, target }: DropInput,
23
+ ): BoardChange | null {
24
+ if (!target) return null
25
+ const ticketId = String(source.ticketId)
26
+ const aim = aimFromTarget(columns, ticketId, target)
27
+ return aim && aimToChange(columns, ticketId, aim)
28
+ }
@@ -1,18 +1,43 @@
1
1
  'use client'
2
2
 
3
- import { useSortable } from '@dnd-kit/sortable'
4
- import { CSS } from '@dnd-kit/utilities'
3
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
4
+ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
5
+ import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'
6
+ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
7
+ import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
5
8
  import Link from '../../../embed-shims/next-link'
6
- import * as React from 'react'
7
- import { ClockIcon, DotsLoaderIcon, LaptopIcon, Flag02Icon, MessagesIcon, UserCheckIcon } from '../../icons-v2-generated'
9
+ import {
10
+ memo,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ type CSSProperties,
16
+ type KeyboardEvent,
17
+ type MouseEvent,
18
+ type ReactNode,
19
+ } from 'react'
20
+ import {
21
+ ClockIcon,
22
+ DotsLoaderIcon,
23
+ LaptopIcon,
24
+ Flag02Icon,
25
+ MessagesIcon,
26
+ UserCheckIcon,
27
+ } from '../../icons-v2-generated'
8
28
  import { DeletedUserAvatar } from '../../ui/deleted-user-avatar'
9
29
  import { SquareAvatar } from '../../ui/square-avatar'
10
30
  import { Tag } from '../../ui/tag'
11
31
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
12
32
  import { cn } from '../../../utils/cn'
33
+ import { holdMoveDragEffect } from '../../../utils/drag-effect'
13
34
  import { getReadableTextColor } from '../../../utils/ods-color-utils'
14
35
  import { formatTicketRelativeTime, formatTicketFullTimestamp } from '../../../utils/date-utils'
15
36
  import { BoardTicketApproval } from './board-ticket-approval'
37
+ import { useBoardLift, useDropAim } from './drop-aim'
38
+ import { useIsLanding } from './pending-move'
39
+ import { DROP_LINE_ATTRIBUTE } from './lane-geometry'
40
+ import { TICKET_ID_ATTRIBUTE } from './use-lane-scroll-anchor'
16
41
  import type { BoardPriority, BoardTicket, BoardTicketActivityKind } from './types'
17
42
 
18
43
  const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
@@ -22,6 +47,13 @@ const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
22
47
  urgent: 'text-ods-error',
23
48
  }
24
49
 
50
+ /** How faded the card is where it would land. Matches what dnd-kit left behind. */
51
+ const DRAGGED_CARD_OPACITY = 0.4
52
+
53
+ /** How solid the card under the pointer is. Jira's look: carried, not lifted off
54
+ * the page entirely. */
55
+ export const DRAG_PREVIEW_OPACITY = 0.9
56
+
25
57
  const MAX_VISIBLE_TAGS = 2
26
58
  const MAX_VISIBLE_ASSIGNEES = 3
27
59
 
@@ -45,7 +77,7 @@ const TICKET_CARD_SHELL =
45
77
  export interface TicketCardBodyProps {
46
78
  ticket: BoardTicket
47
79
  columnColor?: string
48
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
80
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
49
81
  /** Approval callbacks receive the request id directly (the draggable
50
82
  * `TicketCard` adapts its own `(ticketId, requestId)` signature onto these). */
51
83
  onApprove?: (requestId?: string) => void | Promise<void>
@@ -169,20 +201,23 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
169
201
 
170
202
  export interface TicketCardViewProps extends TicketCardBodyProps {
171
203
  className?: string
204
+ /** Merged over the shell's own — the board uses it to fade the copy it leaves
205
+ * behind and to place the one that follows the pointer. */
206
+ style?: CSSProperties
172
207
  }
173
208
 
174
209
  /**
175
210
  * The exact board `TicketCard` visual, standalone — same shell + `TicketCardBody`
176
- * as the draggable card, but without the `useSortable` dnd-kit dependency, so it
177
- * renders anywhere from static props. Use this outside a `<Board>`.
211
+ * as the draggable card, but registering no drag behaviour, so it renders
212
+ * anywhere from static props. Use this outside a `<Board>`.
178
213
  */
179
- export function TicketCardView({ className, ...bodyProps }: TicketCardViewProps) {
214
+ export function TicketCardView({ className, style, ...bodyProps }: TicketCardViewProps) {
180
215
  const { ticket, columnColor } = bodyProps
181
216
  const showNewMessage = !!ticket.hasNewMessage && !!columnColor
182
217
  return (
183
218
  <div
184
219
  className={cn(TICKET_CARD_SHELL, className)}
185
- style={showNewMessage ? { borderColor: columnColor } : undefined}
220
+ style={showNewMessage ? { borderColor: columnColor, ...style } : style}
186
221
  >
187
222
  <div className="relative z-10 flex flex-col gap-[var(--spacing-system-sf)]">
188
223
  <TicketCardBody {...bodyProps} />
@@ -200,85 +235,256 @@ export interface TicketCardProps {
200
235
  columnId: string
201
236
  columnColor?: string
202
237
  href?: string
203
- isOverlay?: boolean
204
238
  dragDisabled?: boolean
205
- renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
239
+ /** The owning column's drop rules. A card is a drop target in its own right,
240
+ * and Pragmatic does not let a lane's `canDrop` veto its children — so the
241
+ * rules have to be answered here too, or a blocked lane still accepts a drop
242
+ * straight onto one of its cards. */
243
+ dropDisabled?: boolean
244
+ allowedFromColumns?: string[]
245
+ renderAssignSlot?: (ticket: BoardTicket) => ReactNode
206
246
  onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
207
247
  onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
208
248
  }
209
249
 
210
- export function TicketCard({
250
+ /** Memoized: a drag moves one card, but it re-renders the column lists around
251
+ * it, and a board carries dozens of cards whose props did not change. Hosts
252
+ * must pass stable `renderAssignSlot` / `onApprove` / `onReject` (useCallback)
253
+ * for this to bite — an inline arrow re-renders every card on every keystroke
254
+ * anywhere in the page. */
255
+ export const TicketCard = memo(function TicketCard({
211
256
  ticket,
212
257
  columnId,
213
258
  columnColor,
214
259
  href,
215
- isOverlay = false,
216
260
  dragDisabled,
261
+ dropDisabled,
262
+ allowedFromColumns,
217
263
  renderAssignSlot,
218
264
  onApprove,
219
265
  onReject,
220
266
  }: TicketCardProps) {
221
- const sortableData = React.useMemo(() => ({ columnId, type: 'ticket' as const }), [columnId])
222
- const sortable = useSortable({ id: ticket.id, data: sortableData, disabled: dragDisabled })
267
+ const ref = useRef<HTMLDivElement>(null)
268
+ // Read at drag start rather than captured: the registration below is keyed on
269
+ // the ticket's id, so capturing the object would hand a renamed or re-tagged
270
+ // ticket to the drag — and to the preview drawn from it — as it was when the
271
+ // card first mounted.
272
+ const ticketRef = useRef(ticket)
273
+ ticketRef.current = ticket
274
+ const [isDragging, setIsDragging] = useState(false)
275
+ // Set by the board for the moment after a drop in which this card is already
276
+ // in its new slot and the drag preview is still gliding into it.
277
+ const isLanding = useIsLanding(ticket.id)
278
+
279
+ // Where the drop points, worked out once for the whole board — see
280
+ // `drop-aim.ts` for why this stopped being each card's own business.
281
+ const aim = useDropAim()
282
+ // Matched on the LANE as well as the card, and both halves are load-bearing.
283
+ // The board resolves one landing place, in one named lane; a ticket id is only
284
+ // unique within a lane here, because each lane is its own query and a ticket
285
+ // whose status just changed is listed by the new lane before the old one has
286
+ // caught up. Matching on the id alone drew the line in both lanes at once —
287
+ // including a lane that refuses the card altogether, since the aim it was
288
+ // drawing came from the other one.
289
+ const closestEdge = aim && aim.columnId === columnId && aim.ticketId === ticket.id ? aim.edge : null
290
+ // Not lane-scoped, and deliberately: `aim.columnId` is where the card is
291
+ // GOING, while the card being carried is still sitting in the lane it came
292
+ // from. True for a pointer drag and for a keyboard lift alike — the aim always
293
+ // names the card being moved, whichever picked it up.
294
+ const isSource = isDragging || aim?.ticket.id === ticket.id
295
+
296
+ const lift = useBoardLift()
297
+ const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
298
+ // Space lifts; Enter is left to the link, which opens the ticket.
299
+ if (e.key !== ' ' || dragDisabled || !lift) return
300
+ e.preventDefault()
301
+ lift(ticket.id)
302
+ }
303
+
304
+ // Both roles live on the same element, and both keep their state HERE. That is
305
+ // the point of the whole drag stack: hovering this card re-renders this card,
306
+ // not the board — nothing subscribes upward, so cost does not grow with how
307
+ // many cards are mounted.
308
+ useEffect(() => {
309
+ const element = ref.current
310
+ if (!element || dragDisabled) return
311
+
312
+ const identity = { type: 'ticket' as const, ticketId: ticket.id, columnId }
313
+
314
+ return combine(
315
+ // Before anything else: the drag has to start as a "move", or its first
316
+ // frame is drawn with the copy cursor — see `drag-effect.ts`.
317
+ holdMoveDragEffect(element),
318
+ draggable({
319
+ element,
320
+ // Read at drag start rather than captured, so a card renamed or re-tagged
321
+ // since it mounted travels as it is now. No height: it was here for a
322
+ // lane that opened room for the card, and nothing opens room any more —
323
+ // it was costing a forced layout read at the start of every drag to
324
+ // produce a field no one read.
325
+ getInitialData: () => ({ ...identity, ticket: ticketRef.current }),
326
+ /**
327
+ * The card under the pointer is the browser's own drag image, styled by
328
+ * us. Drawing it ourselves as a positioned element was tried and has one
329
+ * unfixable flaw: an element in the page cannot leave the page. Carry a
330
+ * card up towards the address bar and the pointer crosses into the
331
+ * browser's own chrome, where no `dragover` reaches the document — so
332
+ * the card freezes at the top edge and only catches up when the drag
333
+ * ends. A native drag image is moved by the browser itself and floats
334
+ * over everything, which is why Jira's card does not stick there.
335
+ *
336
+ * A clone rather than a second React tree: this is a photograph, taken
337
+ * once, and a copy of the card's own DOM is a photograph of exactly what
338
+ * is on screen — assignee slot and all — with no providers to re-supply
339
+ * and no chance of it rendering a loading state into the picture.
340
+ */
341
+ onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => {
342
+ const { width } = source.element.getBoundingClientRect()
343
+ setCustomNativeDragPreview({
344
+ nativeSetDragImage,
345
+ // Holds the card under the same part of itself it was grabbed by.
346
+ getOffset: preserveOffsetOnSource({ element: source.element, input: location.current.input }),
347
+ render: ({ container }) => {
348
+ const clone = source.element.cloneNode(true) as HTMLElement
349
+ // The card is a flex item stretched by its lane; on its own it
350
+ // would shrink to its contents.
351
+ clone.style.width = `${width}px`
352
+ clone.style.opacity = String(DRAG_PREVIEW_OPACITY)
353
+ clone.classList.add('shadow-card-hover')
354
+ container.appendChild(clone)
355
+ },
356
+ })
357
+ },
358
+ onDragStart: () => setIsDragging(true),
359
+ onDrop: () => setIsDragging(false),
360
+ }),
361
+ dropTargetForElements({
362
+ element,
363
+ canDrop: ({ source }) => {
364
+ if (source.data.type !== 'ticket' || source.data.ticketId === ticket.id) return false
365
+ const from = String(source.data.columnId)
366
+ if (from === columnId) return true
367
+ if (dropDisabled) return false
368
+ return !allowedFromColumns || allowedFromColumns.includes(from)
369
+ },
370
+ // The edge rides along in the drop target's own data, so the board reads
371
+ // "above or below this card" straight off the drop it receives.
372
+ getData: ({ input, element: self }) =>
373
+ attachClosestEdge({ ...identity }, { input, element: self, allowedEdges: ['top', 'bottom'] }),
374
+ // Sticky, so crossing the gap between two cards — which is lane padding
375
+ // and belongs to no card's box — keeps answering with the card just
376
+ // left, rather than handing the question to the lane for those few
377
+ // pixels and back again. Both answer with the same slot (the lane
378
+ // resolves the nearest card and its edge), so this is about not
379
+ // re-deriving it from a different target every few pixels, NOT about
380
+ // reaching room a card has opened: nothing moves during a drag here.
381
+ getIsSticky: () => true,
382
+ }),
383
+ )
384
+ }, [ticket.id, columnId, dragDisabled, dropDisabled, allowedFromColumns])
223
385
 
224
386
  const showNewMessage = !!ticket.hasNewMessage && !!columnColor
225
387
 
226
- const style: React.CSSProperties = isOverlay
227
- ? {}
228
- : {
229
- transform: CSS.Transform.toString(sortable.transform),
230
- transition: sortable.transition,
231
- }
388
+ const style: CSSProperties = {}
232
389
  if (showNewMessage) style.borderColor = columnColor
233
390
 
234
- const handleClick = (e: React.MouseEvent) => {
235
- if (sortable.isDragging) e.preventDefault()
391
+ const handleClick = (e: MouseEvent) => {
392
+ if (isDragging) e.preventDefault()
236
393
  }
237
394
 
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
- />
395
+ // Held as one memoized element, not re-created per render. A card being
396
+ // dragged over re-renders on every edge flip — it owns the hover state that
397
+ // draws the preview — and the body is the expensive half: avatars, tags,
398
+ // tooltips, a host's assignee picker with its own queries. Keeping the same
399
+ // element object lets React bail out of that whole subtree, so only the thin
400
+ // wrapper around it re-renders.
401
+ const body = useMemo(
402
+ () => (
403
+ <TicketCardBody
404
+ ticket={ticket}
405
+ columnColor={columnColor}
406
+ renderAssignSlot={renderAssignSlot}
407
+ onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
408
+ onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
409
+ />
410
+ ),
411
+ [ticket, columnColor, renderAssignSlot, onApprove, onReject],
246
412
  )
247
413
 
248
- const cardClasses = cn(
249
- TICKET_CARD_SHELL,
250
- !dragDisabled && 'cursor-pointer',
251
- sortable.isDragging && !isOverlay && 'opacity-40',
252
- isOverlay && 'rotate-1 shadow-card-hover',
253
- )
414
+ // No transition on the margins below, and that is not a style choice: room is
415
+ // opened at one card and given back at another, and a transition interrupted
416
+ // part-way — which is every target change while the pointer keeps moving —
417
+ // restarts from where it got to, so the two ends run at different rates and
418
+ // the lane's height wanders. The rearrangement is instant instead.
419
+ const cardClasses = cn(TICKET_CARD_SHELL, !dragDisabled && 'cursor-pointer')
254
420
 
255
421
  const outerProps = {
256
- ref: isOverlay ? undefined : sortable.setNodeRef,
422
+ ref,
257
423
  style,
258
424
  className: cardClasses,
259
- ...(isOverlay ? {} : sortable.attributes),
260
- ...(isOverlay ? {} : sortable.listeners),
425
+ // Read by the lane's scroll anchor to keep the list still when tickets are
426
+ // inserted above the viewport — see `use-lane-scroll-anchor.ts`.
427
+ [TICKET_ID_ATTRIBUTE]: ticket.id,
261
428
  }
262
429
 
263
430
  const innerWrapperClass = 'relative z-10 flex flex-col gap-[var(--spacing-system-sf)]'
264
431
 
265
- if (isOverlay) {
266
- return (
267
- <div {...outerProps}>
268
- <div className={innerWrapperClass}>{body}</div>
269
- </div>
270
- )
432
+ if (isSource) {
433
+ // Stays exactly where it is, faded. NOTHING about the lane moves during a
434
+ // drag — no room opens, no slot closes — so there is no layout to keep
435
+ // balanced and no height to wander. That is what makes the line below
436
+ // affordable, and it is why this is also the cheap option.
437
+ //
438
+ // Safe to do from state: React commits this AFTER the `dragstart` handler
439
+ // returns, and it is only removing the source synchronously inside that
440
+ // handler that lets a browser abort the drag.
441
+ style.opacity = DRAGGED_CARD_OPACITY
442
+ } else if (isLanding) {
443
+ // Already here, and the preview is on its way to this exact box. Hidden and
444
+ // not removed: the slot it holds is what the preview is aiming at, and it is
445
+ // what keeps the lane from moving again the instant it settled.
446
+ style.visibility = 'hidden'
271
447
  }
272
448
 
449
+ /**
450
+ * Where the card would land, drawn as a line rather than as an opened gap.
451
+ *
452
+ * Opening a card's worth of room and drawing a copy of the card in it costs a
453
+ * lane relayout plus a whole card render — avatars, tags, tooltips, the host's
454
+ * assignee slot — on every edge flip, and the flips come as fast as the
455
+ * pointer moves. A line is one absolutely positioned span, changes no layout,
456
+ * and says the same thing.
457
+ */
458
+ const insertionLine = closestEdge ? (
459
+ <span
460
+ aria-hidden
461
+ {...{ [DROP_LINE_ATTRIBUTE]: '' }}
462
+ className={cn(
463
+ // Nothing sticks out sideways or beyond the line's own 2px: the lane
464
+ // scrolls, and a scroll container clips whatever leaves its padding box
465
+ // — an end cap or a knob on this would be cut off against the first and
466
+ // last card in every lane.
467
+ 'pointer-events-none absolute inset-x-0 z-10 h-0.5 rounded-full bg-ods-accent',
468
+ // Centred in the lane's own gap, so it reads as sitting between two
469
+ // cards rather than as belonging to one of them. The lane reserves the
470
+ // matching sliver at its ends (see `board-column.tsx`).
471
+ closestEdge === 'top' ? '-top-[5px]' : '-bottom-[5px]',
472
+ )}
473
+ />
474
+ ) : null
475
+
273
476
  if (href) {
274
477
  return (
275
478
  <div {...outerProps}>
479
+ {insertionLine}
276
480
  <Link
277
481
  href={href}
278
482
  draggable={false}
279
483
  prefetch={false}
280
484
  onClick={handleClick}
485
+ onKeyDown={handleKeyDown}
281
486
  aria-label={ticket.title}
487
+ aria-keyshortcuts="Space"
282
488
  className="absolute inset-0 z-0 rounded-md focus-visible:outline-none"
283
489
  />
284
490
  <div className={cn('pointer-events-none', innerWrapperClass)}>{body}</div>
@@ -288,16 +494,19 @@ export function TicketCard({
288
494
 
289
495
  return (
290
496
  <div {...outerProps}>
497
+ {insertionLine}
291
498
  <button
292
499
  type="button"
293
500
  onClick={handleClick}
501
+ onKeyDown={handleKeyDown}
294
502
  aria-label={ticket.title}
503
+ aria-keyshortcuts="Space"
295
504
  className="absolute inset-0 z-0 cursor-pointer rounded-md focus-visible:outline-none"
296
505
  />
297
506
  <div className={cn('pointer-events-none', innerWrapperClass)}>{body}</div>
298
507
  </div>
299
508
  )
300
- }
509
+ })
301
510
 
302
511
  function TicketTagRow({ tags }: { tags: string[] }) {
303
512
  const visible = tags.slice(0, MAX_VISIBLE_TAGS)