@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,21 @@
1
+ /**
2
+ * Holds a native drag started from `element` to the "move" effect, from its very
3
+ * first frame.
4
+ *
5
+ * The browser picks the drag cursor from `dataTransfer.dropEffect`, and the
6
+ * value it starts each drag with comes from `effectAllowed`. Pragmatic never
7
+ * writes `effectAllowed`, so it stays `uninitialized` — which the browser reads
8
+ * as "all", whose initial `dropEffect` is `copy`. That is the green plus cursor
9
+ * flashing for one frame at the start of every drag, before the first `dragover`
10
+ * has had a chance to say otherwise.
11
+ *
12
+ * This is a raw listener rather than a Pragmatic callback because `effectAllowed`
13
+ * can only be written during the `dragstart` event itself, and none of
14
+ * Pragmatic's callbacks are handed the event.
15
+ *
16
+ * Distinct from `preventUnhandled`, which answers the other half of the same
17
+ * question: this sets what the drag starts as, that keeps it from falling back
18
+ * to "no drop" over everything that is not a drop target.
19
+ */
20
+ export declare function holdMoveDragEffect(element: HTMLElement): () => void;
21
+ //# sourceMappingURL=drag-effect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drag-effect.d.ts","sourceRoot":"","sources":["../../src/utils/drag-effect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,IAAI,CAMnE"}
@@ -0,0 +1,11 @@
1
+ /** Every scrollable ancestor of `element`, innermost first. */
2
+ export declare function scrollableAncestorsOf(element: HTMLElement): HTMLElement[];
3
+ /**
4
+ * The nearest scrollable ancestor, or `null`.
5
+ *
6
+ * Stands in for "the working area this thing lives in": in an app whose layout
7
+ * scrolls a `<main>` rather than the document, that element is exactly the
8
+ * region a user considers to be the page.
9
+ */
10
+ export declare function nearestScrollableAncestor(element: HTMLElement): HTMLElement | null;
11
+ //# sourceMappingURL=scroll-parent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-parent.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-parent.ts"],"names":[],"mappings":"AAGA,+DAA+D;AAC/D,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAOzE;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CAElF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.556",
3
+ "version": "0.0.557-1828.4873.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -321,6 +321,9 @@
321
321
  }
322
322
  },
323
323
  "dependencies": {
324
+ "@atlaskit/pragmatic-drag-and-drop": "^3.0.0",
325
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1",
326
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.1.0",
324
327
  "@dnd-kit/core": "^6.3.1",
325
328
  "@dnd-kit/sortable": "^10.0.0",
326
329
  "@dnd-kit/utilities": "^3.2.2",
@@ -379,6 +382,7 @@
379
382
  "react-easy-crop": "^5.5.0",
380
383
  "react-hook-form": "^7.62.0",
381
384
  "react-markdown": "^10.1.0",
385
+ "react-remove-scroll": "^2.7.1",
382
386
  "react-resizable-panels": "^3.0.4",
383
387
  "react-scroll": "^1.9.0",
384
388
  "recharts": "^3.1.0",
@@ -393,8 +397,7 @@
393
397
  "tailwindcss-animate": "^1.0.7",
394
398
  "unist-util-visit": "^5.0.0",
395
399
  "use-stick-to-bottom": "^1.1.4",
396
- "vaul": "^1.1.2",
397
- "react-remove-scroll": "^2.7.1"
400
+ "vaul": "^1.1.2"
398
401
  },
399
402
  "devDependencies": {
400
403
  "@biomejs/biome": "2.4.4",
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
3
+ import { aimAnchor, aimFromTarget, aimToChange, initialAim, moveAim } from '../components/features/board/aim'
4
+ import type { BoardColumnDef } from '../components/features/board/types'
5
+
6
+ const ticket = (id: string) => ({ id, title: id, ticketNumber: `#${id}`, status: 'open' })
7
+
8
+ const COLUMNS: BoardColumnDef[] = [
9
+ { id: 'open', label: 'Open', color: '#5ea700', tickets: ['a1', 'a2', 'a3'].map(ticket) },
10
+ { id: 'locked', label: 'Locked', color: '#888888', tickets: ['c1'].map(ticket), dropDisabled: true },
11
+ { id: 'done', label: 'Done', color: '#1f6feb', tickets: ['b1', 'b2'].map(ticket) },
12
+ { id: 'empty', label: 'Empty', color: '#333333', tickets: [] },
13
+ ]
14
+
15
+ const accepts = (column: BoardColumnDef) => !column.dropDisabled
16
+
17
+ describe('initialAim', () => {
18
+ it('starts a card exactly where it already sits', () => {
19
+ expect(initialAim(COLUMNS, 'a2')).toEqual({ columnId: 'open', index: 1 })
20
+ })
21
+
22
+ it('has nowhere to start from for a card that is on no lane', () => {
23
+ expect(initialAim(COLUMNS, 'nope')).toBeNull()
24
+ })
25
+ })
26
+
27
+ describe('moveAim', () => {
28
+ const aim = { columnId: 'open', index: 1 }
29
+
30
+ it('steps through slots with up and down', () => {
31
+ expect(moveAim(COLUMNS, 'a2', aim, 'ArrowUp', accepts)).toEqual({ columnId: 'open', index: 0 })
32
+ expect(moveAim(COLUMNS, 'a2', aim, 'ArrowDown', accepts)).toEqual({ columnId: 'open', index: 2 })
33
+ })
34
+
35
+ // The dragged card is not among the slots it can take, so a lane of three has
36
+ // three landing places for it, not four.
37
+ it('stops at the ends of a lane', () => {
38
+ expect(moveAim(COLUMNS, 'a2', { columnId: 'open', index: 0 }, 'ArrowUp', accepts)).toEqual({
39
+ columnId: 'open',
40
+ index: 0,
41
+ })
42
+ expect(moveAim(COLUMNS, 'a2', { columnId: 'open', index: 2 }, 'ArrowDown', accepts)).toEqual({
43
+ columnId: 'open',
44
+ index: 2,
45
+ })
46
+ })
47
+
48
+ it('skips a lane that will not take the card rather than stopping at it', () => {
49
+ expect(moveAim(COLUMNS, 'a2', aim, 'ArrowRight', accepts)).toEqual({ columnId: 'done', index: 1 })
50
+ })
51
+
52
+ it('keeps the slot it had, as far as the new lane allows', () => {
53
+ expect(moveAim(COLUMNS, 'a2', { columnId: 'open', index: 2 }, 'ArrowRight', accepts)).toEqual({
54
+ columnId: 'done',
55
+ index: 2,
56
+ })
57
+ expect(moveAim(COLUMNS, 'a2', { columnId: 'done', index: 2 }, 'ArrowRight', accepts)).toEqual({
58
+ columnId: 'empty',
59
+ index: 0,
60
+ })
61
+ })
62
+
63
+ it('stays put at the ends of the board', () => {
64
+ const first = { columnId: 'open', index: 1 }
65
+ expect(moveAim(COLUMNS, 'a2', first, 'ArrowLeft', accepts)).toBe(first)
66
+ const last = { columnId: 'empty', index: 0 }
67
+ expect(moveAim(COLUMNS, 'a2', last, 'ArrowRight', accepts)).toBe(last)
68
+ })
69
+ })
70
+
71
+ describe('aimFromTarget', () => {
72
+ const overCard = (ticketId: string, columnId: string, edge: 'top' | 'bottom') => {
73
+ const element = {
74
+ getBoundingClientRect: () => ({ top: 0, left: 0, right: 300, bottom: 100, width: 300, height: 100, x: 0, y: 0 }),
75
+ } as unknown as Element
76
+ return attachClosestEdge(
77
+ { type: 'ticket', ticketId, columnId },
78
+ { input: { clientX: 150, clientY: edge === 'top' ? 5 : 95 } as never, element, allowedEdges: ['top', 'bottom'] },
79
+ )
80
+ }
81
+
82
+ // The flicker this exists to stop: a hit test answers with whichever card the
83
+ // pointer happens to be over, so ONE landing place arrives as "below a3" one
84
+ // frame and "above b-nothing" the next. As an index they are the same slot, so
85
+ // the line has one position to be drawn at instead of two to jump between.
86
+ it('reads the same slot from either side of one boundary', () => {
87
+ const below = aimFromTarget(COLUMNS, 'a2', overCard('a1', 'open', 'bottom'))
88
+ const above = aimFromTarget(COLUMNS, 'a2', overCard('a3', 'open', 'top'))
89
+ expect(below).toEqual(above)
90
+ })
91
+
92
+ it('counts slots among the other cards, never the one being moved', () => {
93
+ expect(aimFromTarget(COLUMNS, 'a2', overCard('a3', 'open', 'bottom'))).toEqual({ columnId: 'open', index: 2 })
94
+ expect(aimFromTarget(COLUMNS, 'a2', overCard('a1', 'open', 'top'))).toEqual({ columnId: 'open', index: 0 })
95
+ })
96
+
97
+ it('reads a lane target as past the last card', () => {
98
+ expect(aimFromTarget(COLUMNS, 'a2', { type: 'column', columnId: 'done' })).toEqual({ columnId: 'done', index: 2 })
99
+ })
100
+
101
+ it('has no answer for a lane the board does not have', () => {
102
+ expect(aimFromTarget(COLUMNS, 'a2', { type: 'column', columnId: 'gone' })).toBeNull()
103
+ })
104
+ })
105
+
106
+ describe('aimAnchor', () => {
107
+ it('hangs the preview above the card it would push down', () => {
108
+ expect(aimAnchor(COLUMNS, 'a2', { columnId: 'open', index: 0 })).toEqual({ ticketId: 'a1', edge: 'top' })
109
+ })
110
+
111
+ it('hangs it below the last card when it would land at the end', () => {
112
+ expect(aimAnchor(COLUMNS, 'a2', { columnId: 'open', index: 2 })).toEqual({ ticketId: 'a3', edge: 'bottom' })
113
+ })
114
+
115
+ it('has nothing to hang off in an empty lane', () => {
116
+ expect(aimAnchor(COLUMNS, 'a2', { columnId: 'empty', index: 0 })).toEqual({ ticketId: null, edge: null })
117
+ })
118
+ })
119
+
120
+ describe('aimToChange', () => {
121
+ it('reports nothing when the card would land back where it started', () => {
122
+ expect(aimToChange(COLUMNS, 'a2', { columnId: 'open', index: 1 })).toBeNull()
123
+ })
124
+
125
+ it('reports a move within one lane', () => {
126
+ expect(aimToChange(COLUMNS, 'a2', { columnId: 'open', index: 0 })).toEqual({
127
+ ticketId: 'a2',
128
+ fromColumnId: 'open',
129
+ toColumnId: 'open',
130
+ afterTicketId: null,
131
+ beforeTicketId: 'a1',
132
+ })
133
+ })
134
+
135
+ it('reports a move to another lane', () => {
136
+ expect(aimToChange(COLUMNS, 'a2', { columnId: 'done', index: 1 })).toEqual({
137
+ ticketId: 'a2',
138
+ fromColumnId: 'open',
139
+ toColumnId: 'done',
140
+ afterTicketId: 'b1',
141
+ beforeTicketId: 'b2',
142
+ })
143
+ })
144
+
145
+ it('reports a move into an empty lane', () => {
146
+ expect(aimToChange(COLUMNS, 'a2', { columnId: 'empty', index: 0 })).toMatchObject({
147
+ toColumnId: 'empty',
148
+ afterTicketId: null,
149
+ beforeTicketId: null,
150
+ })
151
+ })
152
+ })
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { applyPendingMove, hasMoveSettled, type PendingMove } from '../components/features/board/pending-move'
3
+ import type { BoardColumnDef } from '../components/features/board/types'
4
+
5
+ const ticket = (id: string) => ({ id, title: id, ticketNumber: `#${id}`, status: 'open' })
6
+
7
+ const columns = (open: string[], done: string[]): BoardColumnDef[] => [
8
+ { id: 'open', label: 'Open', color: '#5ea700', tickets: open.map(ticket) },
9
+ { id: 'done', label: 'Done', color: '#1f6feb', tickets: done.map(ticket) },
10
+ ]
11
+
12
+ /** `a2` was dragged out of Open and dropped into Done, straight after `b1`. */
13
+ const MOVE: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: 'b1' }
14
+
15
+ describe('hasMoveSettled', () => {
16
+ it('is unsettled while the card is still in the lane it came from', () => {
17
+ expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], ['b1', 'b2']), MOVE)).toBe(false)
18
+ })
19
+
20
+ it('settles when the card turns up exactly where it was dropped', () => {
21
+ expect(hasMoveSettled(columns(['a1', 'a3'], ['b1', 'a2', 'b2']), MOVE)).toBe(true)
22
+ })
23
+
24
+ // The failure this test exists for: each lane polls on its own timer and takes
25
+ // live updates, so the lane a card came FROM changes for reasons that have
26
+ // nothing to do with the move. Reading any of that as "landed" uncovers the
27
+ // card in its old place — the flash the whole mechanism exists to remove.
28
+ it('stays unsettled when the lane it came from changes for unrelated reasons', () => {
29
+ expect(hasMoveSettled(columns(['a0', 'a1', 'a2', 'a3'], ['b1', 'b2']), MOVE)).toBe(false)
30
+ expect(hasMoveSettled(columns(['a2'], ['b1', 'b2']), MOVE)).toBe(false)
31
+ })
32
+
33
+ // A server free to sort the destination lane its own way will never reproduce
34
+ // the dropped index. Holding out for one it will not send means the board
35
+ // shows a position it has already been told is wrong, until the timeout jerks
36
+ // it into place seconds later.
37
+ it('settles anywhere in the new lane once the old one has let go', () => {
38
+ expect(hasMoveSettled(columns(['a1', 'a3'], ['a2', 'b1', 'b2']), MOVE)).toBe(true)
39
+ expect(hasMoveSettled(columns(['a1', 'a3'], ['b1', 'b2', 'a2']), MOVE)).toBe(true)
40
+ })
41
+
42
+ // The two lanes are separate queries and answer at their own pace. Handing
43
+ // back while both still list the card shows it in two places at once.
44
+ it('stays unsettled while both lanes still list the card', () => {
45
+ expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], ['b1', 'a2', 'b2']), MOVE)).toBe(false)
46
+ })
47
+
48
+ it('recognises a drop at the head of a lane', () => {
49
+ const toHead: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: null }
50
+ expect(hasMoveSettled(columns(['a1', 'a3'], ['a2', 'b1']), toHead)).toBe(true)
51
+ })
52
+
53
+ // Within one lane the card is in that lane whether the move landed or not, so
54
+ // here the exact slot is the only thing that can answer.
55
+ it('holds a reorder inside one lane to the exact slot', () => {
56
+ const withinLane: PendingMove = { ticketId: 'a1', fromColumnId: 'open', toColumnId: 'open', afterTicketId: 'a2' }
57
+ expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], []), withinLane)).toBe(false)
58
+ expect(hasMoveSettled(columns(['a2', 'a1', 'a3'], []), withinLane)).toBe(true)
59
+ })
60
+
61
+ it('is unsettled when the lane it was dropped into is gone', () => {
62
+ expect(hasMoveSettled([{ id: 'open', label: 'Open', color: '#5ea700', tickets: [] }], MOVE)).toBe(false)
63
+ })
64
+ })
65
+
66
+ describe('applyPendingMove', () => {
67
+ it('shows the card in the lane it was dropped into, in the slot it was dropped in', () => {
68
+ const moved = applyPendingMove(columns(['a1', 'a2', 'a3'], ['b1', 'b2']), MOVE)
69
+ expect(moved[0].tickets.map(t => t.id)).toEqual(['a1', 'a3'])
70
+ expect(moved[1].tickets.map(t => t.id)).toEqual(['b1', 'a2', 'b2'])
71
+ })
72
+
73
+ it('drops the card at the head of the lane', () => {
74
+ const toHead: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: null }
75
+ const moved = applyPendingMove(columns(['a1', 'a2'], ['b1']), toHead)
76
+ expect(moved[1].tickets.map(t => t.id)).toEqual(['a2', 'b1'])
77
+ })
78
+
79
+ it('reorders inside one lane', () => {
80
+ const within: PendingMove = { ticketId: 'a1', fromColumnId: 'open', toColumnId: 'open', afterTicketId: 'a3' }
81
+ const moved = applyPendingMove(columns(['a1', 'a2', 'a3'], []), within)
82
+ expect(moved[0].tickets.map(t => t.id)).toEqual(['a2', 'a3', 'a1'])
83
+ })
84
+
85
+ // The whole point of the optimistic view: once the host agrees, applying the
86
+ // move to its answer has to be a no-op, or handing back would move the card
87
+ // a second time.
88
+ it('is idempotent once the host has caught up', () => {
89
+ const settled = columns(['a1', 'a3'], ['b1', 'a2', 'b2'])
90
+ expect(hasMoveSettled(settled, MOVE)).toBe(true)
91
+ const moved = applyPendingMove(settled, MOVE)
92
+ expect(moved[0].tickets.map(t => t.id)).toEqual(['a1', 'a3'])
93
+ expect(moved[1].tickets.map(t => t.id)).toEqual(['b1', 'a2', 'b2'])
94
+ })
95
+
96
+ it('appends when the card it was dropped after is gone', () => {
97
+ const moved = applyPendingMove(columns(['a1', 'a2'], ['b2']), MOVE)
98
+ expect(moved[1].tickets.map(t => t.id)).toEqual(['b2', 'a2'])
99
+ })
100
+
101
+ it('leaves untouched lanes identical, so memoized lanes can skip the render', () => {
102
+ const before = columns(['a1', 'a2'], ['b1'])
103
+ const extra = [...before, { id: 'cold', label: 'Cold', color: '#333', tickets: [ticket('c1')] }]
104
+ const moved = applyPendingMove(extra, MOVE)
105
+ expect(moved[2]).toBe(extra[2])
106
+ })
107
+
108
+ it('changes nothing when the card or the lane is unknown', () => {
109
+ const before = columns(['a1'], ['b1'])
110
+ expect(applyPendingMove(before, { ...MOVE, ticketId: 'ghost' })).toBe(before)
111
+ expect(applyPendingMove(before, { ...MOVE, toColumnId: 'ghost' })).toBe(before)
112
+ })
113
+ })
@@ -0,0 +1,68 @@
1
+ import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { resolveBoardDrop } from '../components/features/board/resolve-drop'
4
+ import type { BoardColumnDef } from '../components/features/board/types'
5
+
6
+ const ticket = (id: string) => ({ id, title: id, ticketNumber: `#${id}`, status: 'open' })
7
+
8
+ const COLUMNS: BoardColumnDef[] = [
9
+ { id: 'open', label: 'Open', color: '#5ea700', tickets: ['a1', 'a2', 'a3'].map(ticket) },
10
+ { id: 'done', label: 'Done', color: '#1f6feb', tickets: ['b1', 'b2'].map(ticket) },
11
+ ]
12
+
13
+ /** A card drop target, with the edge attached the way the hitbox does it. */
14
+ function overCard(ticketId: string, columnId: string, edge: 'top' | 'bottom') {
15
+ const height = 100
16
+ const top = edge === 'top' ? 0 : 0
17
+ const element = {
18
+ getBoundingClientRect: () => ({ top, left: 0, right: 300, bottom: top + height, width: 300, height, x: 0, y: top }),
19
+ } as unknown as Element
20
+ return attachClosestEdge(
21
+ { type: 'ticket', ticketId, columnId },
22
+ // A pointer near the top of the box resolves to 'top', near the bottom to
23
+ // 'bottom' — that is the hitbox's own rule, so the fixture uses it rather
24
+ // than hand-writing the symbol it stores the edge under.
25
+ { input: { clientX: 150, clientY: edge === 'top' ? top + 5 : top + height - 5 } as never, element, allowedEdges: ['top', 'bottom'] },
26
+ )
27
+ }
28
+
29
+ const overLane = (columnId: string) => ({ type: 'column', columnId })
30
+ const dragging = (ticketId: string, columnId: string) => ({ type: 'ticket', ticketId, columnId })
31
+
32
+ describe('resolveBoardDrop', () => {
33
+ it('reports nothing when the card is released outside every target', () => {
34
+ expect(resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: undefined })).toBeNull()
35
+ })
36
+
37
+ it('lands above the card whose top edge is closest', () => {
38
+ const change = resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: overCard('b2', 'done', 'top') })
39
+ expect(change).toMatchObject({ toColumnId: 'done', afterTicketId: 'b1', beforeTicketId: 'b2' })
40
+ })
41
+
42
+ it('lands below the card whose bottom edge is closest', () => {
43
+ const change = resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: overCard('b1', 'done', 'bottom') })
44
+ expect(change).toMatchObject({ toColumnId: 'done', afterTicketId: 'b1', beforeTicketId: 'b2' })
45
+ })
46
+
47
+ it('appends when the card is dropped on the lane rather than on a card', () => {
48
+ const change = resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: overLane('done') })
49
+ expect(change).toMatchObject({ toColumnId: 'done', afterTicketId: 'b2', beforeTicketId: null })
50
+ })
51
+
52
+ it('does not count the dragged card as its own neighbour', () => {
53
+ // Moving a1 below a2 within its own lane: if a1 were still in the list, the
54
+ // neighbour would come out as a1 itself and the move would be a no-op.
55
+ const change = resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: overCard('a2', 'open', 'bottom') })
56
+ expect(change).toMatchObject({ afterTicketId: 'a2', beforeTicketId: 'a3' })
57
+ })
58
+
59
+ it('reports nothing when the card is dropped back where it already was', () => {
60
+ // a2 released just below a1 — exactly its current slot.
61
+ expect(resolveBoardDrop(COLUMNS, { source: dragging('a2', 'open'), target: overCard('a1', 'open', 'bottom') })).toBeNull()
62
+ })
63
+
64
+ it('treats a drop on the first card top edge as the head of the lane', () => {
65
+ const change = resolveBoardDrop(COLUMNS, { source: dragging('a1', 'open'), target: overCard('b1', 'done', 'top') })
66
+ expect(change).toMatchObject({ afterTicketId: null, beforeTicketId: 'b1' })
67
+ })
68
+ })
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { type DragSlots, resolveSlot, shiftFor } from '../components/features/sortable-list/slot-geometry'
3
+
4
+ // Four items of different heights, 24px apart — mixed heights on purpose, since
5
+ // that is where "nearest neighbour's centre" and "nearest resting place" differ.
6
+ const HEIGHTS = [50, 70, 90, 60]
7
+ const GAP = 24
8
+
9
+ const TOPS = [0, 74, 168, 282]
10
+ const BOTTOMS = [50, 144, 258, 342]
11
+
12
+ const slotsFor = (from: number): DragSlots => ({
13
+ tops: TOPS,
14
+ bottoms: BOTTOMS,
15
+ height: HEIGHTS[from],
16
+ from,
17
+ step: HEIGHTS[from] + GAP,
18
+ })
19
+
20
+ /**
21
+ * The first offset, walking from `start` in `direction`, at which the held slot
22
+ * changes. `start` matters: the return boundary has to be walked back from where
23
+ * the item actually IS once it holds the new slot, not from zero.
24
+ */
25
+ function boundary(slots: DragSlots, held: number, direction: 1 | -1, start = 0): number {
26
+ for (let offset = start; Math.abs(offset) <= 600; offset += direction) {
27
+ if (resolveSlot(slots, held, offset) !== held) return offset
28
+ }
29
+ throw new Error('no boundary found')
30
+ }
31
+
32
+ describe('resolveSlot', () => {
33
+ const slots = slotsFor(0)
34
+
35
+ it('holds its own slot until the item has travelled half a slot', () => {
36
+ expect(resolveSlot(slots, 0, 0)).toBe(0)
37
+ expect(resolveSlot(slots, 0, 40)).toBe(0)
38
+ expect(resolveSlot(slots, 0, 80)).toBe(1)
39
+ })
40
+
41
+ it('walks a slot at a time rather than jumping to the end', () => {
42
+ expect(resolveSlot(slots, 1, 160)).toBe(1)
43
+ expect(resolveSlot(slots, 1, 200)).toBe(2)
44
+ expect(resolveSlot(slots, 2, 300)).toBe(3)
45
+ })
46
+
47
+ // The complaint this exists to answer: without the handicap the item ends up
48
+ // sitting exactly ON the boundary it just crossed, so leaving a slot costs
49
+ // half an item of dragging and coming back costs a pixel.
50
+ it('costs the same movement to leave a slot and to come back', () => {
51
+ const out = boundary(slots, 0, 1)
52
+ const back = boundary(slots, 1, -1, out)
53
+ expect(out).toBeGreaterThan(back)
54
+
55
+ // Both sit the same distance either side of the midpoint between the two
56
+ // resting places — 25 and 119 here, so 72, i.e. an offset of 47.
57
+ const midpoint = 47
58
+ expect(out - midpoint).toBeCloseTo(midpoint - back, 0)
59
+ })
60
+
61
+ it('measures against resting places, not the neighbours own centres', () => {
62
+ // Landing at index 1 puts a 50px item against item 1's BOTTOM edge (144),
63
+ // i.e. centred at 119 — not at item 1's own centre of 109.
64
+ expect(resolveSlot(slots, 0, 94)).toBe(1)
65
+ expect(resolveSlot(slots, 1, 94)).toBe(1)
66
+ })
67
+
68
+ it('is symmetric for a drag that starts at the end of the list', () => {
69
+ const fromLast = slotsFor(3)
70
+ expect(resolveSlot(fromLast, 3, 0)).toBe(3)
71
+ expect(resolveSlot(fromLast, 3, -120)).toBe(2)
72
+ expect(resolveSlot(fromLast, 2, -10)).toBe(3)
73
+ })
74
+ })
75
+
76
+ describe('shiftFor', () => {
77
+ it('stands the passed items aside by exactly one slot', () => {
78
+ const slots = slotsFor(0)
79
+ expect(shiftFor(slots, 0, 2)).toBe(0)
80
+ expect(shiftFor(slots, 1, 2)).toBe(-slots.step)
81
+ expect(shiftFor(slots, 2, 2)).toBe(-slots.step)
82
+ expect(shiftFor(slots, 3, 2)).toBe(0)
83
+ })
84
+
85
+ it('pushes the other way when the item is dragged upwards', () => {
86
+ const slots = slotsFor(3)
87
+ expect(shiftFor(slots, 3, 1)).toBe(0)
88
+ expect(shiftFor(slots, 2, 1)).toBe(slots.step)
89
+ expect(shiftFor(slots, 1, 1)).toBe(slots.step)
90
+ expect(shiftFor(slots, 0, 1)).toBe(0)
91
+ })
92
+
93
+ it('moves nothing while the drop is still aimed at the slot it came from', () => {
94
+ const slots = slotsFor(1)
95
+ expect([0, 1, 2, 3].map(index => shiftFor(slots, index, 1))).toEqual([0, 0, 0, 0])
96
+ })
97
+
98
+ // Every displaced item travels the same distance whatever its own height:
99
+ // removing the item from one slot and inserting it at another moves each item
100
+ // in between by exactly the dragged item's height plus the gap.
101
+ it('travels the dragged items height regardless of the displaced items own', () => {
102
+ const slots = slotsFor(0)
103
+ expect(new Set([1, 2, 3].map(index => shiftFor(slots, index, 3))).size).toBe(1)
104
+ })
105
+ })
@@ -7,13 +7,26 @@ A kanban-style board column component with drag-and-drop support, infinite scrol
7
7
  The primary column container. Renders a collapsible column with a header, divider, and body. Supports visual joining with adjacent columns via `joinLeft`/`joinRight` props and applies tinted background colors for non-system columns.
8
8
 
9
9
  ### `ColumnBody` (internal)
10
- Handles the droppable zone (`@dnd-kit/core`), sortable ticket list (`@dnd-kit/sortable`), and infinite scroll via `IntersectionObserver`. Manages refs for both the scroll container and the droppable drop target.
10
+ Handles the lane-wide drop target, drag auto-scroll, and infinite scroll via `IntersectionObserver`.
11
+
12
+ Each card is a drop target in its own right and is reported ahead of the lane, so the lane only ever
13
+ answers for the gaps between them — and it answers with the **nearest card and the edge of it the
14
+ pointer is on**, exactly as that card would have. Reporting a bare lane there sent the ticket to the
15
+ BOTTOM of the column, which made small moves look like they had not registered and large ones like
16
+ the only thing that worked.
17
+
18
+ Auto-scroll on the lane is `autoScrollColumn`: vertical-only overflow reach, so a pointer carried
19
+ below the board scrolls exactly the one lane it is under — a reach that also spread sideways put
20
+ that pointer inside every lane's region at once and scrolled them all. The page's own scrollers are
21
+ registered once by the board, not per lane. The lane body reserves a `py-[5px]` sliver so the
22
+ insertion line is not clipped at the first and last card.
11
23
 
12
24
  ### `SkeletonStack` (internal)
13
25
  Renders a configurable number of `TicketCardSkeleton` placeholders during initial load.
14
26
 
15
27
  ### `EmptyState` (internal)
16
- Displays a centered empty state with a tag icon when a column has no tickets.
28
+ Displays a centered empty state with a tag icon when a column has no tickets — and carries the
29
+ insertion line when a drop is aimed at the empty lane, since there is no card to hang it off.
17
30
 
18
31
  ## Key Props (`BoardColumnProps`)
19
32
 
@@ -21,7 +34,7 @@ Displays a centered empty state with a tag icon when a column has no tickets.
21
34
  |---|---|---|
22
35
  | `column` | `BoardColumnDef` | Column definition including tickets, color, and state flags |
23
36
  | `collapsed` | `boolean` | Collapses column to `w-14` |
24
- | `onToggleCollapse` | `() => void` | Toggle handler |
37
+ | `onToggleCollapse` | `(columnId: string) => void` | Toggle handler — takes the id so one stable handler can serve every lane |
25
38
  | `onLoadMore` | `(columnId: string) => void` | Triggered by intersection sentinel |
26
39
  | `joinLeft` / `joinRight` | `boolean` | Removes borders/radius for adjacent column groups |
27
40
  | `renderAssignSlot` | `(ticket: BoardTicket) => ReactNode` | Custom assignee slot renderer |
@@ -32,7 +45,7 @@ Displays a centered empty state with a tag icon when a column has no tickets.
32
45
  <BoardColumn
33
46
  column={column}
34
47
  collapsed={false}
35
- onToggleCollapse={() => setCollapsed(id, !collapsed)}
48
+ onToggleCollapse={toggle}
36
49
  onAddTicket={(colId) => openNewTicketModal(colId)}
37
50
  getTicketHref={(ticketId) => `/tickets/${ticketId}`}
38
51
  renderAssignSlot={(ticket) => <AvatarPicker ticket={ticket} />}
@@ -1,22 +1,26 @@
1
1
  <!-- source-hash: eec2d729297b9f5f1be4e627c13aa43f -->
2
- Kanban-style drag-and-drop board component built on `@dnd-kit/core`, managing multi-column ticket reordering with cross-column movement, collapse state, and a custom horizontal scrollbar.
2
+ Kanban-style drag-and-drop board built on Pragmatic drag and drop, managing multi-column ticket reordering with cross-column movement, collapse state, and a custom horizontal scrollbar. The board holds no copy of `columns` and changes no state during a drag: a single `monitorForElements` turns the drop into one `onChange`.
3
3
 
4
4
  ## Key Components
5
5
 
6
6
  | Export | Description |
7
7
  |---|---|
8
- | `Board` | Main board component rendering columns with full DnD context |
8
+ | `Board` | Main board component rendering the columns it is given |
9
9
  | `BoardProps` | Interface defining all board configuration and callback props |
10
- | `locate()` | Internal helper to find a ticket across all columns by ID |
11
- | `findIndexInColumn()` | Internal helper to find a ticket's index within a specific column |
12
10
 
13
11
  ## Key Behaviors
14
12
 
15
- - **Collision detection** — Custom strategy combining `pointerWithin`, `rectIntersection`, and `closestCorners` to prioritize ticket-level hits over column-level hits
16
- - **Cross-column guards** — Respects `dropDisabled` and `allowedFromColumns` constraints; reverts state on invalid drops
17
- - **Optimistic UI** — `isDraggingRef` prevents external `columns` prop updates from overwriting in-flight drag state
13
+ - **One monitor, one change** — A single `monitorForElements` watches the whole board; `resolveBoardDrop` (own module, unit-tested) turns the drop into a `BoardChange` or `null`
14
+ - **Which target** — Two nested drop targets answer in the same terms, innermost first: a card, then its lane (for the gaps between cards, resolved to the nearest card and the edge of it the pointer is on). **Outside the lanes there is deliberately no target at all and releasing cancels** — Jira's model; landing a card "somewhere" because the pointer happened to be nearest to it makes a mis-drop unrecoverable. The aim arithmetic itself lives in `aim.ts` (slot indices, so one landing place can never be described two ways), shared verbatim by the pointer, the keyboard and the drop
15
+ - **Nothing moves during a drag** — The dragged card stays put and fades; the landing place is a line. No margins open, no slots close, so the lane's height cannot wander and an edge flip costs one span instead of a relayout plus a card render
16
+ - **One aim, one preview** — Where the drop points is resolved once, in the board's monitor, and handed down (`drop-aim.ts`). Each card used to work it out from its own hover events, which only ever answers for a card the pointer is physically over — so the preview vanished the moment the pointer left the lanes, even though the drop would still have landed. The board's answer is compared before it is stored, so a pointer moving inside one card does not re-render anything
17
+ - **Cross-column guards** — `dropDisabled` and `allowedFromColumns` are enforced by each target's `canDrop`, so an invalid drop is never accepted and nothing has to be reverted
18
+ - **Optimistic view** — The board renders `applyPendingMove(columns, pendingMove)` from the drop until the host's answer arrives (`pending-move.ts`), so the move is on screen instantly and the host's data replaces an identical picture. "Landed" is judged per kind of move: across lanes, arriving in the new lane once the old one has let go (a server may re-sort the lane, so the exact index would never come); within one lane, the exact slot. Weaker tests (a fresh `columns` array, the card leaving its old index) fire on a lane's own poll and snap the card back mid-flight. A 2s timeout is the backstop for a host that never applies the change
19
+ - **Dragged card** — The card under the pointer is the browser's own native drag image (a styled DOM clone via `setCustomNativeDragPreview`), so the browser moves it — over its own chrome included — and carrying a card re-renders nothing and cannot freeze at the viewport edge. On drop, a portal copy takes over for one 160ms FLIP glide from the release point into the slot the move just opened, then hands over to the real card (hidden under it via `LandingCardContext` for exactly that long)
20
+ - **Keyboard move** — Space on a focused card lifts it; the arrows walk it (up/down through slots, left/right through lanes, skipping any that will not take it); space or enter drops it; escape puts it back. Lift-and-place rather than one-press-one-move, because on a board every press would otherwise be its own write to the server. The lifted card feeds the same aim a pointer drag does, and the drop line is scrolled into view on every step — arrow keys bring no auto-scroll of their own. Keys are handled on the board, not the card: once a card is lifted they belong to the move rather than to whatever holds focus. Rules live in `aim.ts`, pure and unit-tested
18
21
  - **Collapse persistence** — Column collapsed state stored via `useBoardCollapse` (keyed by `collapseStorageKey`)
19
22
  - **Custom scrollbar** — Horizontal scrollbar rendered via `useHorizontalScrollbar` with pointer-drag thumb support
23
+ - **Auto-scroll** — The board registers its own scroller and every scrollable ancestor once (`autoScrollAncestors`); each lane registers itself with a vertical-only overflow reach (`autoScrollColumn`), so a pointer below the board scrolls exactly the one lane it is under. `preventUnhandled` + `holdMoveDragEffect` keep the cursor a plain move cursor for the whole drag — without them the first frame and everything outside the lanes draw the browser's green copy cursor
20
24
  - **`onChange` payload** — Emits `BoardChange` with `ticketId`, `fromColumnId`, `toColumnId`, `afterTicketId`, and `beforeTicketId` for precise ordering
21
25
 
22
26
  ## Usage Example