@flamingo-stack/openframe-frontend-core 0.0.555 → 0.0.556-snapshot.20260818165639
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-6ER3AYJM.js → chunk-25YLMIWU.js} +3 -3
- package/dist/{chunk-HO3Q7NXJ.js → chunk-26HAJBSQ.js} +2 -2
- package/dist/{chunk-P22HM2MO.cjs → chunk-33FOBAWS.cjs} +18 -18
- package/dist/{chunk-P22HM2MO.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
- package/dist/{chunk-N5A4AC2J.cjs → chunk-4EGNYOZA.cjs} +23 -23
- package/dist/{chunk-N5A4AC2J.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
- package/dist/{chunk-A7ES3NZK.cjs → chunk-4WREPWRL.cjs} +3 -3
- package/dist/{chunk-A7ES3NZK.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
- package/dist/{chunk-CU6Q2KQT.cjs → chunk-5HNZTPAB.cjs} +12 -12
- package/dist/{chunk-CU6Q2KQT.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
- package/dist/{chunk-MLVF3OXU.cjs → chunk-5JF6H7OJ.cjs} +76 -76
- package/dist/{chunk-MLVF3OXU.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
- package/dist/{chunk-UXW63WCV.cjs → chunk-64HHDGRN.cjs} +4 -4
- package/dist/{chunk-UXW63WCV.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
- package/dist/{chunk-KXLQITOX.cjs → chunk-6YXEZCVG.cjs} +5 -5
- package/dist/{chunk-KXLQITOX.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
- package/dist/{chunk-SST2YI7S.cjs → chunk-6ZOVSD4M.cjs} +14 -14
- package/dist/{chunk-SST2YI7S.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
- package/dist/{chunk-M5TWHFIN.cjs → chunk-A6TAVIBX.cjs} +42 -42
- package/dist/{chunk-M5TWHFIN.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
- package/dist/{chunk-J76LCSR4.cjs → chunk-BE6TRUKY.cjs} +40 -40
- package/dist/{chunk-J76LCSR4.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
- package/dist/{chunk-Q6RC3YGW.cjs → chunk-DFJ7TWH6.cjs} +3 -3
- package/dist/{chunk-Q6RC3YGW.cjs.map → chunk-DFJ7TWH6.cjs.map} +1 -1
- package/dist/{chunk-B2GEKAAA.js → chunk-DRNSIK3V.js} +2 -2
- package/dist/{chunk-CTTY5EKH.js → chunk-FXVMRZIQ.js} +5 -5
- package/dist/{chunk-QZ64JOE2.js → chunk-G3NBTL4I.js} +3246 -2557
- package/dist/chunk-G3NBTL4I.js.map +1 -0
- package/dist/{chunk-TXV444YV.js → chunk-G7ROZAY5.js} +3 -3
- package/dist/{chunk-E56VNTBV.cjs → chunk-IPO24G7U.cjs} +1775 -1086
- package/dist/chunk-IPO24G7U.cjs.map +1 -0
- package/dist/{chunk-CVCQW7WG.js → chunk-LJWTXODF.js} +3 -3
- package/dist/{chunk-P7MSXPGL.js → chunk-NNGQ6ONX.js} +8 -8
- package/dist/{chunk-XOKRFZBD.cjs → chunk-QCBBX6DO.cjs} +97 -97
- package/dist/{chunk-XOKRFZBD.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
- package/dist/{chunk-GSONSIWG.js → chunk-R7A7WHXP.js} +60 -35
- package/dist/chunk-R7A7WHXP.js.map +1 -0
- package/dist/{chunk-L36R3G7Z.js → chunk-RPCY2FAC.js} +6 -6
- package/dist/{chunk-QMQQ37RW.js → chunk-RRWWAH37.js} +2 -2
- package/dist/{chunk-ODRMPI3Y.js → chunk-SBKX2VSG.js} +2 -2
- package/dist/{chunk-KQKU4VXB.cjs → chunk-STHVHAV4.cjs} +82 -57
- package/dist/chunk-STHVHAV4.cjs.map +1 -0
- package/dist/{chunk-E2IPBWGT.js → chunk-UTEQHK3H.js} +6 -6
- package/dist/{chunk-6QRKCVYR.cjs → chunk-V5EYGEX7.cjs} +7 -7
- package/dist/{chunk-6QRKCVYR.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
- package/dist/{chunk-K432ES7N.js → chunk-VJL46NHW.js} +4 -4
- package/dist/{chunk-QTO3WYB2.cjs → chunk-VJTD2DGF.cjs} +3 -3
- package/dist/{chunk-QTO3WYB2.cjs.map → chunk-VJTD2DGF.cjs.map} +1 -1
- package/dist/{chunk-VGLDA77S.js → chunk-VRX6Y6A7.js} +2 -2
- package/dist/{chunk-OR67WC32.js → chunk-WBQ3NOH7.js} +2 -2
- package/dist/{chunk-UL3WQWTF.js → chunk-Y6TXTBIQ.js} +2 -2
- package/dist/{chunk-YAOBDOI5.cjs → chunk-YHRE4JBF.cjs} +32 -32
- package/dist/{chunk-YAOBDOI5.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/board/aim.d.ts +57 -0
- package/dist/components/features/board/aim.d.ts.map +1 -0
- package/dist/components/features/board/board-column-header.d.ts.map +1 -1
- package/dist/components/features/board/board-column.d.ts +8 -4
- package/dist/components/features/board/board-column.d.ts.map +1 -1
- package/dist/components/features/board/board.d.ts +15 -4
- package/dist/components/features/board/board.d.ts.map +1 -1
- package/dist/components/features/board/drop-aim.d.ts +34 -0
- package/dist/components/features/board/drop-aim.d.ts.map +1 -0
- package/dist/components/features/board/lane-geometry.d.ts +22 -0
- package/dist/components/features/board/lane-geometry.d.ts.map +1 -0
- package/dist/components/features/board/pending-move.d.ts +77 -0
- package/dist/components/features/board/pending-move.d.ts.map +1 -0
- package/dist/components/features/board/resolve-drop.d.ts +19 -0
- package/dist/components/features/board/resolve-drop.d.ts.map +1 -0
- package/dist/components/features/board/ticket-card.d.ts +25 -9
- package/dist/components/features/board/ticket-card.d.ts.map +1 -1
- package/dist/components/features/board/use-board-collapse.d.ts.map +1 -1
- package/dist/components/features/board/use-lane-scroll-anchor.d.ts +27 -0
- package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
- package/dist/components/features/index.cjs +10 -4
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +9 -3
- package/dist/components/features/sortable-list/index.d.ts +4 -0
- package/dist/components/features/sortable-list/index.d.ts.map +1 -0
- package/dist/components/features/sortable-list/slot-geometry.d.ts +57 -0
- package/dist/components/features/sortable-list/slot-geometry.d.ts.map +1 -0
- package/dist/components/features/sortable-list/sortable-list.d.ts +50 -0
- package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -0
- package/dist/components/features/sortable-list/use-sortable-item.d.ts +29 -0
- package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +25 -25
- package/dist/components/help-center-pages/index.js +16 -16
- package/dist/components/index.cjs +144 -138
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +20 -14
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +35 -35
- package/dist/components/meeting-scheduler/index.js +5 -5
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/components/ui/square-avatar.d.ts.map +1 -1
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +10 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -3
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/index.js +1 -1
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/nats/shared-connection.d.ts +13 -6
- package/dist/nats/shared-connection.d.ts.map +1 -1
- package/dist/utils/auto-scroll-ancestors.d.ts +43 -0
- package/dist/utils/auto-scroll-ancestors.d.ts.map +1 -0
- package/dist/utils/drag-effect.d.ts +21 -0
- package/dist/utils/drag-effect.d.ts.map +1 -0
- package/dist/utils/scroll-parent.d.ts +11 -0
- package/dist/utils/scroll-parent.d.ts.map +1 -0
- package/package.json +6 -3
- package/src/__tests__/board-aim.test.ts +152 -0
- package/src/__tests__/board-pending-move.test.ts +113 -0
- package/src/__tests__/board-resolve-drop.test.ts +68 -0
- package/src/__tests__/sortable-slot-geometry.test.ts +105 -0
- package/src/components/features/board/.board-column.md +17 -4
- package/src/components/features/board/.board.md +11 -7
- package/src/components/features/board/.ticket-card.md +27 -5
- package/src/components/features/board/aim.ts +131 -0
- package/src/components/features/board/board-column-header.tsx +2 -9
- package/src/components/features/board/board-column.tsx +137 -66
- package/src/components/features/board/board.tsx +501 -258
- package/src/components/features/board/drop-aim.ts +50 -0
- package/src/components/features/board/lane-geometry.ts +44 -0
- package/src/components/features/board/pending-move.ts +116 -0
- package/src/components/features/board/resolve-drop.ts +28 -0
- package/src/components/features/board/ticket-card.tsx +256 -47
- package/src/components/features/board/use-board-collapse.ts +16 -14
- package/src/components/features/board/use-lane-scroll-anchor.ts +79 -0
- package/src/components/features/index.ts +1 -0
- package/src/components/features/sortable-list/.sortable-list.md +82 -0
- package/src/components/features/sortable-list/index.ts +3 -0
- package/src/components/features/sortable-list/slot-geometry.ts +100 -0
- package/src/components/features/sortable-list/sortable-list.tsx +270 -0
- package/src/components/features/sortable-list/use-sortable-item.ts +92 -0
- package/src/components/ui/square-avatar.tsx +5 -0
- package/src/nats/__tests__/reconnect-lifecycle.test.tsx +814 -0
- package/src/nats/nats.ts +56 -13
- package/src/nats/shared-connection.ts +132 -36
- package/src/stories/.TicketCard.stories.md +6 -12
- package/src/stories/Board.stories.tsx +16 -7
- package/src/stories/TicketCard.stories.tsx +38 -76
- package/src/utils/auto-scroll-ancestors.ts +90 -0
- package/src/utils/drag-effect.ts +26 -0
- package/src/utils/scroll-parent.ts +23 -0
- package/dist/chunk-E56VNTBV.cjs.map +0 -1
- package/dist/chunk-GSONSIWG.js.map +0 -1
- package/dist/chunk-KQKU4VXB.cjs.map +0 -1
- package/dist/chunk-QZ64JOE2.js.map +0 -1
- /package/dist/{chunk-6ER3AYJM.js.map → chunk-25YLMIWU.js.map} +0 -0
- /package/dist/{chunk-HO3Q7NXJ.js.map → chunk-26HAJBSQ.js.map} +0 -0
- /package/dist/{chunk-B2GEKAAA.js.map → chunk-DRNSIK3V.js.map} +0 -0
- /package/dist/{chunk-CTTY5EKH.js.map → chunk-FXVMRZIQ.js.map} +0 -0
- /package/dist/{chunk-TXV444YV.js.map → chunk-G7ROZAY5.js.map} +0 -0
- /package/dist/{chunk-CVCQW7WG.js.map → chunk-LJWTXODF.js.map} +0 -0
- /package/dist/{chunk-P7MSXPGL.js.map → chunk-NNGQ6ONX.js.map} +0 -0
- /package/dist/{chunk-L36R3G7Z.js.map → chunk-RPCY2FAC.js.map} +0 -0
- /package/dist/{chunk-QMQQ37RW.js.map → chunk-RRWWAH37.js.map} +0 -0
- /package/dist/{chunk-ODRMPI3Y.js.map → chunk-SBKX2VSG.js.map} +0 -0
- /package/dist/{chunk-E2IPBWGT.js.map → chunk-UTEQHK3H.js.map} +0 -0
- /package/dist/{chunk-K432ES7N.js.map → chunk-VJL46NHW.js.map} +0 -0
- /package/dist/{chunk-VGLDA77S.js.map → chunk-VRX6Y6A7.js.map} +0 -0
- /package/dist/{chunk-OR67WC32.js.map → chunk-WBQ3NOH7.js.map} +0 -0
- /package/dist/{chunk-UL3WQWTF.js.map → chunk-Y6TXTBIQ.js.map} +0 -0
|
@@ -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
|
|
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={
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- source-hash: db6e303b7af152f7914246835dde7146 -->
|
|
2
|
-
Draggable and static board ticket card components for a kanban-style board view, providing both a
|
|
2
|
+
Draggable and static board ticket card components for a kanban-style board view, providing both a card that registers its own drag and drop behaviour and a standalone presentational variant.
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
5
5
|
|
|
@@ -7,16 +7,38 @@ Draggable and static board ticket card components for a kanban-style board view,
|
|
|
7
7
|
|---|---|---|
|
|
8
8
|
| `TicketCardBody` | Component | Pure presentational inner content — title, device/org row, priority flag, assignees, tags, timestamp, new message badge, and approval row |
|
|
9
9
|
| `TicketCardView` | Component | Static, non-draggable card using the same shell and body as the board card; suitable for embeds, previews, and marketing |
|
|
10
|
-
| `TicketCard` | Component | Full
|
|
10
|
+
| `TicketCard` | Component | Full board card: registers itself as both draggable and drop target, fades in place while carried, draws the insertion line for the shared aim, and supports an optional `href` link plus drag/drop-disabled state |
|
|
11
11
|
| `TicketCardBodyProps` | Interface | Props for the shared body: `ticket`, `columnColor`, `renderAssignSlot`, `onApprove`, `onReject` |
|
|
12
|
-
| `TicketCardProps` | Interface | Extends body props with `columnId`, `href`, `
|
|
12
|
+
| `TicketCardProps` | Interface | Extends body props with `columnId`, `href`, `dragDisabled`, `dropDisabled`, `allowedFromColumns`, and board-aware approve/reject signatures |
|
|
13
|
+
|
|
14
|
+
**Nothing about the lane moves during a drag.** The card being carried stays exactly where it is,
|
|
15
|
+
faded (0.4), and where it would land is drawn as a **line** on the nearest edge of the card it would
|
|
16
|
+
displace — matched on the lane as well as the card id, because a ticket mid-status-change is briefly
|
|
17
|
+
listed by two lanes at once. No room opens, no slot closes, so there is no layout to keep balanced
|
|
18
|
+
and no lane height to wander. A no-op slot (the card's own place) draws nothing.
|
|
19
|
+
|
|
20
|
+
That is a performance decision as much as a visual one. Opening a card's worth of room and drawing a
|
|
21
|
+
copy of the card in it costs a lane relayout plus a whole card render — avatars, tags, tooltips, the
|
|
22
|
+
host's assignee slot — on every edge flip, and the flips come as fast as the pointer moves. The line
|
|
23
|
+
is one absolutely positioned span and says the same thing.
|
|
24
|
+
|
|
25
|
+
The card under the pointer is the browser's **native drag image** — a DOM clone of the card, taken
|
|
26
|
+
in `onGenerateDragPreview` (`setCustomNativeDragPreview` + `preserveOffsetOnSource`), at 0.9 opacity
|
|
27
|
+
with a held shadow. A clone, not a second React tree: it is a photograph of exactly what is on
|
|
28
|
+
screen, assignee slot included, with no providers to re-supply. Native, not a positioned element:
|
|
29
|
+
an element in the page cannot leave the page, so a hand-drawn preview froze whenever the pointer
|
|
30
|
+
crossed into the browser's own chrome.
|
|
31
|
+
|
|
32
|
+
After the drop the board shows the move immediately (see `pending-move.ts`); the landed card is
|
|
33
|
+
hidden under the portal copy gliding into its slot (`useIsLanding`) for the 160ms of that flight,
|
|
34
|
+
and no longer than that.
|
|
13
35
|
|
|
14
36
|
**Constants:** `MAX_VISIBLE_TAGS = 2`, `MAX_VISIBLE_ASSIGNEES = 3`, `PRIORITY_COLOR_CLASS` maps `low | medium | high | urgent` to ODS color tokens.
|
|
15
37
|
|
|
16
38
|
## Usage Example
|
|
17
39
|
|
|
18
40
|
```typescript
|
|
19
|
-
// Draggable board card (inside a <Board>
|
|
41
|
+
// Draggable board card (inside a <Board>)
|
|
20
42
|
<TicketCard
|
|
21
43
|
ticket={ticket}
|
|
22
44
|
columnId="in-progress"
|
|
@@ -26,7 +48,7 @@ Draggable and static board ticket card components for a kanban-style board view,
|
|
|
26
48
|
onReject={(ticketId, requestId) => handleReject(ticketId, requestId)}
|
|
27
49
|
/>
|
|
28
50
|
|
|
29
|
-
// Static preview card (
|
|
51
|
+
// Static preview card (renders anywhere, registers nothing)
|
|
30
52
|
<TicketCardView
|
|
31
53
|
ticket={ticket}
|
|
32
54
|
columnColor="#6366f1"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
|
|
2
|
+
import type { BoardChange, BoardColumnDef } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a card being moved currently points: a lane, and a slot among the cards
|
|
6
|
+
* ALREADY in it — the card being moved never counts as one of them.
|
|
7
|
+
*
|
|
8
|
+
* This is the board's ONE description of a landing place, and everything is
|
|
9
|
+
* expressed in it: the pointer, the keyboard, the line that gets drawn and the
|
|
10
|
+
* change that gets reported. An index cannot say the same thing two ways, which
|
|
11
|
+
* matters more than it sounds — "below card X" and "above card Y" are the same
|
|
12
|
+
* slot, and drawing a line for whichever one a hit test happened to return makes
|
|
13
|
+
* it jump between two positions as the pointer crosses the boundary.
|
|
14
|
+
*/
|
|
15
|
+
export interface BoardAim {
|
|
16
|
+
columnId: string
|
|
17
|
+
/** Insertion index among the other cards, so `0..others.length`. */
|
|
18
|
+
index: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
|
|
22
|
+
|
|
23
|
+
/** Whether a lane will take this card — the board's own rules, passed in so this
|
|
24
|
+
* module stays arithmetic. */
|
|
25
|
+
type LaneFilter = (column: BoardColumnDef) => boolean
|
|
26
|
+
|
|
27
|
+
const othersIn = (columns: readonly BoardColumnDef[], columnId: string, ticketId: string) =>
|
|
28
|
+
(columns.find(c => c.id === columnId)?.tickets ?? []).filter(t => t.id !== ticketId)
|
|
29
|
+
|
|
30
|
+
/** The lane a card is in right now, or `null` if it is in none of them. */
|
|
31
|
+
export function laneOf(columns: readonly BoardColumnDef[], ticketId: string): BoardColumnDef | null {
|
|
32
|
+
return columns.find(c => c.tickets.some(t => t.id === ticketId)) ?? null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where a card starts: exactly where it already sits. */
|
|
36
|
+
export function initialAim(columns: readonly BoardColumnDef[], ticketId: string): BoardAim | null {
|
|
37
|
+
const lane = laneOf(columns, ticketId)
|
|
38
|
+
if (!lane) return null
|
|
39
|
+
// Its index among the others is its own index: dropping it back at that slot
|
|
40
|
+
// puts it exactly where it came from.
|
|
41
|
+
return { columnId: lane.id, index: lane.tickets.findIndex(t => t.id === ticketId) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The aim after one arrow key, or the aim unchanged when the move would leave
|
|
46
|
+
* the board or land in a lane that will not take the card.
|
|
47
|
+
*
|
|
48
|
+
* Up and down step through slots; left and right step through lanes, skipping
|
|
49
|
+
* any the card is not allowed into rather than stopping dead at it — a lane the
|
|
50
|
+
* user cannot use should cost a keypress, not end the journey.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The slot a resolved drop target points at.
|
|
54
|
+
*
|
|
55
|
+
* Card targets carry the edge the pointer is nearest; a lane target means "past
|
|
56
|
+
* the last card". Either way the answer comes out as an index, so the caller
|
|
57
|
+
* never has to care which kind of target replied.
|
|
58
|
+
*/
|
|
59
|
+
export function aimFromTarget(
|
|
60
|
+
columns: readonly BoardColumnDef[],
|
|
61
|
+
ticketId: string,
|
|
62
|
+
target: Record<string | symbol, unknown>,
|
|
63
|
+
): BoardAim | null {
|
|
64
|
+
const columnId = String(target.columnId)
|
|
65
|
+
if (!columns.some(c => c.id === columnId)) return null
|
|
66
|
+
const others = othersIn(columns, columnId, ticketId)
|
|
67
|
+
if (target.type !== 'ticket') return { columnId, index: others.length }
|
|
68
|
+
const over = others.findIndex(t => t.id === String(target.ticketId))
|
|
69
|
+
if (over < 0) return { columnId, index: others.length }
|
|
70
|
+
return { columnId, index: over + (extractClosestEdge(target) === 'bottom' ? 1 : 0) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function moveAim(
|
|
74
|
+
columns: readonly BoardColumnDef[],
|
|
75
|
+
ticketId: string,
|
|
76
|
+
aim: BoardAim,
|
|
77
|
+
key: ArrowKey,
|
|
78
|
+
accepts: LaneFilter,
|
|
79
|
+
): BoardAim {
|
|
80
|
+
if (key === 'ArrowUp' || key === 'ArrowDown') {
|
|
81
|
+
const limit = othersIn(columns, aim.columnId, ticketId).length
|
|
82
|
+
const index = Math.min(Math.max(aim.index + (key === 'ArrowUp' ? -1 : 1), 0), limit)
|
|
83
|
+
return index === aim.index ? aim : { ...aim, index }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const step = key === 'ArrowLeft' ? -1 : 1
|
|
87
|
+
const from = columns.findIndex(c => c.id === aim.columnId)
|
|
88
|
+
for (let i = from + step; i >= 0 && i < columns.length; i += step) {
|
|
89
|
+
const column = columns[i]
|
|
90
|
+
if (!accepts(column)) continue
|
|
91
|
+
// Keep the slot the card had, as far as the new lane allows.
|
|
92
|
+
const limit = othersIn(columns, column.id, ticketId).length
|
|
93
|
+
return { columnId: column.id, index: Math.min(aim.index, limit) }
|
|
94
|
+
}
|
|
95
|
+
return aim
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The card the preview hangs off, and which side of it — `null` for a lane with
|
|
99
|
+
* nothing else in it. */
|
|
100
|
+
export function aimAnchor(
|
|
101
|
+
columns: readonly BoardColumnDef[],
|
|
102
|
+
ticketId: string,
|
|
103
|
+
aim: BoardAim,
|
|
104
|
+
): { ticketId: string | null; edge: 'top' | 'bottom' | null } {
|
|
105
|
+
const others = othersIn(columns, aim.columnId, ticketId)
|
|
106
|
+
if (others.length === 0) return { ticketId: null, edge: null }
|
|
107
|
+
if (aim.index < others.length) return { ticketId: others[aim.index].id, edge: 'top' }
|
|
108
|
+
return { ticketId: others[others.length - 1].id, edge: 'bottom' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The move to report, or `null` when the card would land back where it started.
|
|
113
|
+
*
|
|
114
|
+
* Same rule as a pointer drop, and deliberately so: a keyboard move that ends
|
|
115
|
+
* where it began must be as silent as a card picked up and put down again.
|
|
116
|
+
*/
|
|
117
|
+
export function aimToChange(columns: readonly BoardColumnDef[], ticketId: string, aim: BoardAim): BoardChange | null {
|
|
118
|
+
const lane = laneOf(columns, ticketId)
|
|
119
|
+
if (!lane) return null
|
|
120
|
+
|
|
121
|
+
const others = othersIn(columns, aim.columnId, ticketId)
|
|
122
|
+
const afterTicketId = others[aim.index - 1]?.id ?? null
|
|
123
|
+
const beforeTicketId = others[aim.index]?.id ?? null
|
|
124
|
+
|
|
125
|
+
if (lane.id === aim.columnId) {
|
|
126
|
+
const currentIndex = lane.tickets.findIndex(t => t.id === ticketId)
|
|
127
|
+
if ((lane.tickets[currentIndex - 1]?.id ?? null) === afterTicketId) return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ticketId, fromColumnId: lane.id, toColumnId: aim.columnId, afterTicketId, beforeTicketId }
|
|
131
|
+
}
|
|
@@ -54,15 +54,8 @@ export function BoardColumnHeader({
|
|
|
54
54
|
return (
|
|
55
55
|
<div className="flex items-center gap-[var(--spacing-system-xsf)]">
|
|
56
56
|
<div className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xsf)]">
|
|
57
|
-
<TicketStatusTag
|
|
58
|
-
|
|
59
|
-
label={column.label}
|
|
60
|
-
color={useStatusVariant ? undefined : column.color}
|
|
61
|
-
/>
|
|
62
|
-
<span
|
|
63
|
-
className="text-h5"
|
|
64
|
-
style={useStatusVariant ? undefined : { color: column.color }}
|
|
65
|
-
>
|
|
57
|
+
<TicketStatusTag status={tagStatus} label={column.label} color={useStatusVariant ? undefined : column.color} />
|
|
58
|
+
<span className="text-h5" style={useStatusVariant ? undefined : { color: column.color }}>
|
|
66
59
|
{count}
|
|
67
60
|
</span>
|
|
68
61
|
</div>
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import * as React from 'react'
|
|
3
|
+
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
|
4
|
+
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
6
5
|
import { TagIcon } from '../../icons-v2-generated/shopping/tag-icon'
|
|
7
6
|
import { cn } from '../../../utils/cn'
|
|
7
|
+
import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
|
|
8
|
+
import { autoScrollColumn } from '../../../utils/auto-scroll-ancestors'
|
|
8
9
|
import { BoardColumnHeader } from './board-column-header'
|
|
10
|
+
import { useDropAim } from './drop-aim'
|
|
11
|
+
import { DROP_LINE_ATTRIBUTE, nearestCardIn } from './lane-geometry'
|
|
9
12
|
import { tintOnDark } from './color-utils'
|
|
10
13
|
import { TicketCard } from './ticket-card'
|
|
11
14
|
import { TicketCardSkeleton } from './ticket-card-skeleton'
|
|
15
|
+
import { TICKET_ID_ATTRIBUTE, useLaneScrollAnchor } from './use-lane-scroll-anchor'
|
|
12
16
|
import type { BoardColumnDef, BoardTicket } from './types'
|
|
13
17
|
|
|
14
18
|
export interface BoardColumnProps {
|
|
15
19
|
column: BoardColumnDef
|
|
16
20
|
collapsed?: boolean
|
|
17
|
-
|
|
21
|
+
/** Takes the id so the board can pass one stable handler to every lane. */
|
|
22
|
+
onToggleCollapse: (columnId: string) => void
|
|
18
23
|
onAddTicket?: (columnId: string) => void
|
|
19
24
|
onArchive?: (columnId: string) => void
|
|
20
25
|
getTicketHref?: (ticketId: string) => string
|
|
21
|
-
renderAssignSlot?: (ticket: BoardTicket) =>
|
|
26
|
+
renderAssignSlot?: (ticket: BoardTicket) => ReactNode
|
|
22
27
|
onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
23
28
|
onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
24
29
|
onLoadMore?: (columnId: string) => void
|
|
@@ -27,7 +32,10 @@ export interface BoardColumnProps {
|
|
|
27
32
|
joinRight?: boolean
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
/** Memoized: `Board` rebuilds only the two columns a drag touches, so the
|
|
36
|
+
* untouched ones keep their object identity and can skip the render entirely.
|
|
37
|
+
* Without this, every pointer move that shifts a card re-renders all lanes. */
|
|
38
|
+
export const BoardColumn = memo(function BoardColumn({
|
|
31
39
|
column,
|
|
32
40
|
collapsed = false,
|
|
33
41
|
onToggleCollapse,
|
|
@@ -42,8 +50,59 @@ export function BoardColumn({
|
|
|
42
50
|
joinLeft = false,
|
|
43
51
|
joinRight = false,
|
|
44
52
|
}: BoardColumnProps) {
|
|
53
|
+
// The drop zone is the WHOLE lane, header included — not just the list. A
|
|
54
|
+
// target that stops at the list leaves dead strips (the header, the padding)
|
|
55
|
+
// where the pointer is over a column yet hits nothing. Collapsed lanes stay
|
|
56
|
+
// out: they render no list to drop into.
|
|
57
|
+
//
|
|
58
|
+
// A card sitting inside this lane is a drop target too, and Pragmatic reports
|
|
59
|
+
// the innermost one first — so the lane only ever answers for the gaps, and
|
|
60
|
+
// "which card" never has to be guessed from rect overlap.
|
|
61
|
+
const laneRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
const [isOver, setIsOver] = useState(false)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const element = laneRef.current
|
|
66
|
+
if (!element || collapsed || column.dropDisabled) return
|
|
67
|
+
|
|
68
|
+
return dropTargetForElements({
|
|
69
|
+
element,
|
|
70
|
+
canDrop: ({ source }) => {
|
|
71
|
+
if (source.data.type !== 'ticket') return false
|
|
72
|
+
const from = source.data.columnId
|
|
73
|
+
if (from === column.id) return true
|
|
74
|
+
return !column.allowedFromColumns || column.allowedFromColumns.includes(String(from))
|
|
75
|
+
},
|
|
76
|
+
// A drop that lands between cards — most of the lane, once a hovered card
|
|
77
|
+
// has opened the room the preview is drawn in — answers with the nearest
|
|
78
|
+
// card and the edge of it the pointer is on, exactly as that card would
|
|
79
|
+
// have. Reporting a bare lane instead sent the ticket to the BOTTOM of the
|
|
80
|
+
// column, which is what made small moves look like they had not worked and
|
|
81
|
+
// large ones like the only thing that did.
|
|
82
|
+
getData: ({ input, element: lane, source }) => {
|
|
83
|
+
const nearest = nearestCardIn(lane, input.clientY, String(source.data.ticketId))
|
|
84
|
+
const ticketId = nearest?.dataset.ticketId
|
|
85
|
+
// An empty lane, or one whose only card is the one being dragged: the
|
|
86
|
+
// drop appends, which is the only place it could go.
|
|
87
|
+
if (!nearest || !ticketId) return { type: 'column', columnId: column.id }
|
|
88
|
+
return attachClosestEdge(
|
|
89
|
+
{ type: 'ticket', ticketId, columnId: column.id },
|
|
90
|
+
{ input, element: nearest, allowedEdges: ['top', 'bottom'] },
|
|
91
|
+
)
|
|
92
|
+
},
|
|
93
|
+
onDragEnter: () => setIsOver(true),
|
|
94
|
+
onDragLeave: () => setIsOver(false),
|
|
95
|
+
onDrop: () => setIsOver(false),
|
|
96
|
+
})
|
|
97
|
+
}, [column.id, column.dropDisabled, column.allowedFromColumns, collapsed])
|
|
98
|
+
|
|
99
|
+
// The header's own prop stays zero-arg; the id is bound here, where it is
|
|
100
|
+
// already known, rather than by the board building one closure per lane.
|
|
101
|
+
const handleToggleCollapse = useCallback(() => onToggleCollapse(column.id), [onToggleCollapse, column.id])
|
|
102
|
+
|
|
45
103
|
return (
|
|
46
104
|
<div
|
|
105
|
+
ref={laneRef}
|
|
47
106
|
className={cn(
|
|
48
107
|
'flex h-full shrink-0 flex-col gap-[var(--spacing-system-sf)] overflow-hidden rounded-md border border-ods-border p-[var(--spacing-system-sf)]',
|
|
49
108
|
'transition-[width] duration-300 ease-out',
|
|
@@ -51,13 +110,14 @@ export function BoardColumn({
|
|
|
51
110
|
column.system && 'bg-ods-card',
|
|
52
111
|
joinLeft && 'rounded-l-none border-l-0',
|
|
53
112
|
joinRight && 'rounded-r-none',
|
|
113
|
+
isOver && 'outline outline-2 -outline-offset-2 outline-ods-focus',
|
|
54
114
|
)}
|
|
55
115
|
style={column.system ? undefined : { backgroundColor: tintOnDark(column.color) }}
|
|
56
116
|
>
|
|
57
117
|
<BoardColumnHeader
|
|
58
118
|
column={column}
|
|
59
119
|
collapsed={collapsed}
|
|
60
|
-
onToggleCollapse={
|
|
120
|
+
onToggleCollapse={handleToggleCollapse}
|
|
61
121
|
onAddTicket={!collapsed && onAddTicket ? () => onAddTicket(column.id) : undefined}
|
|
62
122
|
onArchive={!collapsed && column.archivable && onArchive ? () => onArchive(column.id) : undefined}
|
|
63
123
|
/>
|
|
@@ -77,40 +137,49 @@ export function BoardColumn({
|
|
|
77
137
|
)}
|
|
78
138
|
</div>
|
|
79
139
|
)
|
|
80
|
-
}
|
|
140
|
+
})
|
|
81
141
|
|
|
82
142
|
interface ColumnBodyProps {
|
|
83
143
|
column: BoardColumnDef
|
|
84
144
|
getTicketHref?: (ticketId: string) => string
|
|
85
|
-
renderAssignSlot?: (ticket: BoardTicket) =>
|
|
145
|
+
renderAssignSlot?: (ticket: BoardTicket) => ReactNode
|
|
86
146
|
onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
87
147
|
onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
88
148
|
onLoadMore?: (columnId: string) => void
|
|
89
149
|
loadMoreRootMargin: string
|
|
90
150
|
}
|
|
91
151
|
|
|
92
|
-
function ColumnBody({
|
|
93
|
-
|
|
152
|
+
function ColumnBody({
|
|
153
|
+
column,
|
|
154
|
+
getTicketHref,
|
|
155
|
+
renderAssignSlot,
|
|
156
|
+
onApprove,
|
|
157
|
+
onReject,
|
|
158
|
+
onLoadMore,
|
|
159
|
+
loadMoreRootMargin,
|
|
160
|
+
}: ColumnBodyProps) {
|
|
161
|
+
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
162
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
94
163
|
|
|
95
|
-
|
|
96
|
-
() => ({ columnId: column.id, type: 'column' as const }),
|
|
97
|
-
[column.id],
|
|
98
|
-
)
|
|
99
|
-
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
|
100
|
-
id: `column:${column.id}`,
|
|
101
|
-
data: droppableData,
|
|
102
|
-
disabled: column.dropDisabled,
|
|
103
|
-
})
|
|
164
|
+
useLaneScrollAnchor(scrollRef, column.tickets)
|
|
104
165
|
|
|
105
|
-
|
|
106
|
-
|
|
166
|
+
// Dragging near the top or bottom of a long lane scrolls THIS lane, and only
|
|
167
|
+
// this one — the reach past the lane's edges is vertical, so a pointer below
|
|
168
|
+
// the board is below exactly one column. The page's own scrollers are
|
|
169
|
+
// registered once by the board, not once per lane: registering them here gave
|
|
170
|
+
// `<main>` one auto-scroller per column, all pulling at the same time.
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const element = scrollRef.current
|
|
173
|
+
if (!element) return
|
|
174
|
+
return autoScrollColumn(element)
|
|
175
|
+
}, [])
|
|
107
176
|
|
|
108
|
-
const loadMoreRef =
|
|
177
|
+
const loadMoreRef = useRef(onLoadMore)
|
|
109
178
|
loadMoreRef.current = onLoadMore
|
|
110
|
-
const columnIdRef =
|
|
179
|
+
const columnIdRef = useRef(column.id)
|
|
111
180
|
columnIdRef.current = column.id
|
|
112
181
|
|
|
113
|
-
|
|
182
|
+
useEffect(() => {
|
|
114
183
|
if (!column.hasMore || column.isLoadingMore) return
|
|
115
184
|
const sentinel = sentinelRef.current
|
|
116
185
|
const root = scrollRef.current
|
|
@@ -128,43 +197,40 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
|
|
|
128
197
|
return () => observer.disconnect()
|
|
129
198
|
}, [column.hasMore, column.isLoadingMore, loadMoreRootMargin])
|
|
130
199
|
|
|
131
|
-
const setBodyRef = React.useCallback(
|
|
132
|
-
(el: HTMLDivElement | null) => {
|
|
133
|
-
scrollRef.current = el
|
|
134
|
-
setDroppableRef(el)
|
|
135
|
-
},
|
|
136
|
-
[setDroppableRef],
|
|
137
|
-
)
|
|
138
|
-
|
|
139
200
|
return (
|
|
201
|
+
// `overflow-anchor:none` hands scroll anchoring to `useLaneScrollAnchor`
|
|
202
|
+
// wholesale. The browser's own anchoring is unusable here — it silently
|
|
203
|
+
// skips transformed elements, so it works between drags and does nothing
|
|
204
|
+
// during one — and leaving both on double-corrects every insertion.
|
|
140
205
|
<div
|
|
141
|
-
ref={
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
206
|
+
ref={scrollRef}
|
|
207
|
+
// `py-[5px]`: the insertion line sits in the middle of the gap between two
|
|
208
|
+
// cards, and the first and last card have no gap on their outer side. This
|
|
209
|
+
// is the sliver it lives in there — without it the line is clipped away by
|
|
210
|
+
// this element's own overflow exactly when a card is aimed at either end.
|
|
211
|
+
className="flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-xs)] overflow-y-auto py-[5px] [overflow-anchor:none]"
|
|
146
212
|
>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
213
|
+
{column.isLoading ? (
|
|
214
|
+
<SkeletonStack />
|
|
215
|
+
) : column.tickets.length === 0 ? (
|
|
216
|
+
<EmptyState columnId={column.id} />
|
|
217
|
+
) : (
|
|
218
|
+
column.tickets.map(t => (
|
|
219
|
+
<TicketCard
|
|
220
|
+
key={t.id}
|
|
221
|
+
ticket={t}
|
|
222
|
+
columnId={column.id}
|
|
223
|
+
columnColor={column.color}
|
|
224
|
+
href={getTicketHref?.(t.id)}
|
|
225
|
+
renderAssignSlot={renderAssignSlot}
|
|
226
|
+
onApprove={onApprove}
|
|
227
|
+
onReject={onReject}
|
|
228
|
+
dragDisabled={column.dragDisabled}
|
|
229
|
+
dropDisabled={column.dropDisabled}
|
|
230
|
+
allowedFromColumns={column.allowedFromColumns}
|
|
231
|
+
/>
|
|
232
|
+
))
|
|
233
|
+
)}
|
|
168
234
|
{column.isLoadingMore && !column.isLoading && <TicketCardSkeleton />}
|
|
169
235
|
{column.hasMore && <div ref={sentinelRef} aria-hidden className="h-1 shrink-0" />}
|
|
170
236
|
</div>
|
|
@@ -172,20 +238,25 @@ function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReje
|
|
|
172
238
|
}
|
|
173
239
|
|
|
174
240
|
function SkeletonStack({ count = 4 }: { count?: number }) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
241
|
+
// Position IS the identity here: the rows are interchangeable placeholders in
|
|
242
|
+
// a fixed-length list, so the index is the correct key. Random keys remounted
|
|
243
|
+
// every row whenever this re-rendered with a different `count`.
|
|
179
244
|
return (
|
|
180
245
|
<>
|
|
181
|
-
{
|
|
182
|
-
<TicketCardSkeleton key={
|
|
246
|
+
{Array.from({ length: count }, (_, i) => (
|
|
247
|
+
<TicketCardSkeleton key={`skeleton-${i}`} />
|
|
183
248
|
))}
|
|
184
249
|
</>
|
|
185
250
|
)
|
|
186
251
|
}
|
|
187
252
|
|
|
188
|
-
|
|
253
|
+
/** Also carries the insertion line when a drop is aimed here, since the lane has
|
|
254
|
+
* no card to hang it off. */
|
|
255
|
+
function EmptyState({ columnId }: { columnId: string }) {
|
|
256
|
+
const aim = useDropAim()
|
|
257
|
+
if (aim && aim.columnId === columnId) {
|
|
258
|
+
return <div aria-hidden {...{ [DROP_LINE_ATTRIBUTE]: '' }} className="h-0.5 shrink-0 rounded-full bg-ods-accent" />
|
|
259
|
+
}
|
|
189
260
|
return (
|
|
190
261
|
<div className="flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-lf)] p-[var(--spacing-system-lf)] text-center text-ods-text-secondary">
|
|
191
262
|
<TagIcon className="h-6 w-6 shrink-0" />
|