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