@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.
- package/dist/{chunk-5756Q6UN.js → chunk-25YLMIWU.js} +2 -2
- package/dist/{chunk-FLSEJSTN.js → chunk-26HAJBSQ.js} +2 -2
- package/dist/{chunk-JDUPAHJR.cjs → chunk-33FOBAWS.cjs} +11 -11
- package/dist/{chunk-JDUPAHJR.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
- package/dist/{chunk-CRGIXNUE.cjs → chunk-4EGNYOZA.cjs} +20 -20
- package/dist/{chunk-CRGIXNUE.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
- package/dist/{chunk-SXMJLNOF.cjs → chunk-4WREPWRL.cjs} +3 -3
- package/dist/{chunk-SXMJLNOF.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
- package/dist/{chunk-LZD3VSPS.cjs → chunk-5HNZTPAB.cjs} +9 -9
- package/dist/{chunk-LZD3VSPS.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
- package/dist/{chunk-HYABNMBN.cjs → chunk-5JF6H7OJ.cjs} +62 -62
- package/dist/{chunk-HYABNMBN.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
- package/dist/{chunk-BNMKHG2L.cjs → chunk-64HHDGRN.cjs} +4 -4
- package/dist/{chunk-BNMKHG2L.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
- package/dist/{chunk-Z3R3LOC5.cjs → chunk-6YXEZCVG.cjs} +5 -5
- package/dist/{chunk-Z3R3LOC5.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
- package/dist/{chunk-BZKUID4R.cjs → chunk-6ZOVSD4M.cjs} +14 -14
- package/dist/{chunk-BZKUID4R.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
- package/dist/{chunk-Q5SZ2QTZ.cjs → chunk-A6TAVIBX.cjs} +39 -39
- package/dist/{chunk-Q5SZ2QTZ.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
- package/dist/{chunk-ICG7JRYT.cjs → chunk-BE6TRUKY.cjs} +31 -31
- package/dist/{chunk-ICG7JRYT.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
- package/dist/{chunk-JRWOHH7K.js → chunk-DRNSIK3V.js} +2 -2
- package/dist/{chunk-VF4DCOPD.js → chunk-FXVMRZIQ.js} +4 -4
- package/dist/{chunk-XGBVHQP7.js → chunk-G3NBTL4I.js} +3244 -2555
- package/dist/chunk-G3NBTL4I.js.map +1 -0
- package/dist/{chunk-JXJ4FTKN.js → chunk-G7ROZAY5.js} +2 -2
- package/dist/{chunk-S4LL6QI6.cjs → chunk-IPO24G7U.cjs} +1630 -941
- package/dist/chunk-IPO24G7U.cjs.map +1 -0
- package/dist/{chunk-5M4PS3XX.js → chunk-LJWTXODF.js} +2 -2
- package/dist/{chunk-CGUSNV6R.js → chunk-NNGQ6ONX.js} +6 -6
- package/dist/{chunk-HOEOGFQM.cjs → chunk-QCBBX6DO.cjs} +86 -86
- package/dist/{chunk-HOEOGFQM.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
- package/dist/{chunk-VXNMOGRP.js → chunk-RPCY2FAC.js} +4 -4
- package/dist/{chunk-WX5UCLL7.js → chunk-RRWWAH37.js} +2 -2
- package/dist/{chunk-5JWLZJK5.js → chunk-SBKX2VSG.js} +2 -2
- package/dist/{chunk-5EES5HD4.js → chunk-UTEQHK3H.js} +5 -5
- package/dist/{chunk-7Q7GFIFB.cjs → chunk-V5EYGEX7.cjs} +7 -7
- package/dist/{chunk-7Q7GFIFB.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
- package/dist/{chunk-YM2WHYYK.js → chunk-VJL46NHW.js} +2 -2
- package/dist/{chunk-FZT7BYNF.js → chunk-Y6TXTBIQ.js} +2 -2
- package/dist/{chunk-C74PZU6M.cjs → chunk-YHRE4JBF.cjs} +26 -26
- package/dist/{chunk-C74PZU6M.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- 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 +8 -2
- 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 +7 -1
- 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 +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +137 -131
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +28 -28
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/ui/square-avatar.d.ts.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -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/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-S4LL6QI6.cjs.map +0 -1
- package/dist/chunk-XGBVHQP7.js.map +0 -1
- /package/dist/{chunk-5756Q6UN.js.map → chunk-25YLMIWU.js.map} +0 -0
- /package/dist/{chunk-FLSEJSTN.js.map → chunk-26HAJBSQ.js.map} +0 -0
- /package/dist/{chunk-JRWOHH7K.js.map → chunk-DRNSIK3V.js.map} +0 -0
- /package/dist/{chunk-VF4DCOPD.js.map → chunk-FXVMRZIQ.js.map} +0 -0
- /package/dist/{chunk-JXJ4FTKN.js.map → chunk-G7ROZAY5.js.map} +0 -0
- /package/dist/{chunk-5M4PS3XX.js.map → chunk-LJWTXODF.js.map} +0 -0
- /package/dist/{chunk-CGUSNV6R.js.map → chunk-NNGQ6ONX.js.map} +0 -0
- /package/dist/{chunk-VXNMOGRP.js.map → chunk-RPCY2FAC.js.map} +0 -0
- /package/dist/{chunk-WX5UCLL7.js.map → chunk-RRWWAH37.js.map} +0 -0
- /package/dist/{chunk-5JWLZJK5.js.map → chunk-SBKX2VSG.js.map} +0 -0
- /package/dist/{chunk-5EES5HD4.js.map → chunk-UTEQHK3H.js.map} +0 -0
- /package/dist/{chunk-YM2WHYYK.js.map → chunk-VJL46NHW.js.map} +0 -0
- /package/dist/{chunk-FZT7BYNF.js.map → chunk-Y6TXTBIQ.js.map} +0 -0
|
@@ -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" />
|