@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,29 +1,59 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
PointerSensor,
|
|
9
|
-
closestCorners,
|
|
10
|
-
pointerWithin,
|
|
11
|
-
rectIntersection,
|
|
12
|
-
useSensor,
|
|
13
|
-
useSensors,
|
|
14
|
-
type CollisionDetection,
|
|
15
|
-
type DragEndEvent,
|
|
16
|
-
type DragOverEvent,
|
|
17
|
-
type DragStartEvent,
|
|
18
|
-
} from '@dnd-kit/core'
|
|
19
|
-
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
|
3
|
+
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
|
4
|
+
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
|
5
|
+
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled'
|
|
6
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
7
|
+
import { createPortal } from 'react-dom'
|
|
20
8
|
import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollbar'
|
|
9
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect'
|
|
10
|
+
import { autoScrollAncestors, autoScrollBothWays } from '../../../utils/auto-scroll-ancestors'
|
|
11
|
+
import { nearestScrollableAncestor } from '../../../utils/scroll-parent'
|
|
21
12
|
import { cn } from '../../../utils/cn'
|
|
22
13
|
import { BoardColumn } from './board-column'
|
|
23
|
-
import {
|
|
14
|
+
import { BoardLiftContext, type DropAim, DropAimContext, isSameAim } from './drop-aim'
|
|
15
|
+
import { aimAnchor, aimFromTarget, aimToChange, type ArrowKey, type BoardAim, initialAim, laneOf, moveAim } from './aim'
|
|
16
|
+
import { cardById, DROP_LINE_ATTRIBUTE } from './lane-geometry'
|
|
17
|
+
import { DRAG_PREVIEW_OPACITY, TicketCardView } from './ticket-card'
|
|
18
|
+
import { applyPendingMove, hasMoveSettled, LandingCardContext, type PendingMove } from './pending-move'
|
|
19
|
+
import { resolveBoardDrop } from './resolve-drop'
|
|
24
20
|
import type { BoardChange, BoardColumnDef, BoardTicket } from './types'
|
|
25
21
|
import { useBoardCollapse } from './use-board-collapse'
|
|
26
22
|
|
|
23
|
+
/** How long the board may keep standing in for `columns` while waiting for the
|
|
24
|
+
* host's answer. Only a host that drops the change on the floor ever reaches
|
|
25
|
+
* this — it exists so a failed move can't be shown as done forever, not as a
|
|
26
|
+
* timing assumption. */
|
|
27
|
+
const PENDING_MOVE_TIMEOUT_MS = 2000
|
|
28
|
+
|
|
29
|
+
/** How long a dropped card takes to travel from the pointer into its slot. */
|
|
30
|
+
const DROP_MS = 160
|
|
31
|
+
const DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)'
|
|
32
|
+
|
|
33
|
+
/** Above the header, modals and drawers, below toasts — a card being carried
|
|
34
|
+
* has to clear everything it is being carried over. */
|
|
35
|
+
const DRAG_PREVIEW_Z = 1400
|
|
36
|
+
|
|
37
|
+
const ARROW_KEYS: ArrowKey[] = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
|
|
38
|
+
|
|
39
|
+
/** What it takes to draw the carried card again, at rest, exactly where the
|
|
40
|
+
* browser's drag image was when it was let go. Measured once at drag start. */
|
|
41
|
+
interface DragGeometry {
|
|
42
|
+
ticket: BoardColumnDef['tickets'][number]
|
|
43
|
+
columnColor?: string
|
|
44
|
+
width: number
|
|
45
|
+
/** Pointer-to-card-corner offset, so the copy lands under the same grip. */
|
|
46
|
+
grabX: number
|
|
47
|
+
grabY: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The short-lived copy that glides into the slot after a drop. */
|
|
51
|
+
interface LandingPreview extends DragGeometry {
|
|
52
|
+
/** Where the pointer was when the card was released. */
|
|
53
|
+
startX: number
|
|
54
|
+
startY: number
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
export interface BoardProps {
|
|
28
58
|
columns: BoardColumnDef[]
|
|
29
59
|
onChange: (change: BoardChange) => void
|
|
@@ -31,7 +61,7 @@ export interface BoardProps {
|
|
|
31
61
|
onAddTicket?: (columnId: string) => void
|
|
32
62
|
onArchiveColumn?: (columnId: string) => void
|
|
33
63
|
getTicketHref?: (ticketId: string) => string
|
|
34
|
-
renderAssignSlot?: (ticket:
|
|
64
|
+
renderAssignSlot?: (ticket: BoardColumnDef['tickets'][number]) => ReactNode
|
|
35
65
|
onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
36
66
|
onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
|
|
37
67
|
collapseStorageKey?: string
|
|
@@ -39,6 +69,17 @@ export interface BoardProps {
|
|
|
39
69
|
className?: string
|
|
40
70
|
}
|
|
41
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The board renders `columns` and nothing else — there is no local mirror of
|
|
74
|
+
* them, and a drag in progress changes no state here at all.
|
|
75
|
+
*
|
|
76
|
+
* That is the whole design. A card that previews its move by actually moving in
|
|
77
|
+
* the data has to re-enter React on every lane crossing, which re-mounts it
|
|
78
|
+
* under a new parent and re-measures the board; guarding that cascade is what
|
|
79
|
+
* the old dnd-kit implementation spent a frame-freeze and a unit-tested rules
|
|
80
|
+
* module on. Here the preview is a line drawn by the card being hovered, the
|
|
81
|
+
* data moves once, on drop, and the cascade has nothing to feed on.
|
|
82
|
+
*/
|
|
42
83
|
export function Board({
|
|
43
84
|
columns,
|
|
44
85
|
onChange,
|
|
@@ -68,269 +109,471 @@ export function Board({
|
|
|
68
109
|
onThumbPointerUp,
|
|
69
110
|
} = useHorizontalScrollbar()
|
|
70
111
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
112
|
+
// The monitor is registered once for the board's lifetime; these keep it
|
|
113
|
+
// reading current values without tearing it down on every render.
|
|
114
|
+
const columnsRef = useRef(columns)
|
|
115
|
+
const onChangeRef = useRef(onChange)
|
|
116
|
+
onChangeRef.current = onChange
|
|
117
|
+
|
|
118
|
+
// `useHorizontalScrollbar` hands back a callback ref, and auto-scroll needs
|
|
119
|
+
// the node itself — so keep our own alongside it on the same element.
|
|
120
|
+
const boardRef = useRef<HTMLDivElement | null>(null)
|
|
121
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
|
122
|
+
const setScroller = useCallback(
|
|
123
|
+
(node: HTMLDivElement | null) => {
|
|
124
|
+
scrollerRef.current = node
|
|
125
|
+
scrollRef(node)
|
|
126
|
+
},
|
|
127
|
+
[scrollRef],
|
|
128
|
+
)
|
|
80
129
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
130
|
+
// Where the drop points. One answer for the whole board, handed to the cards
|
|
131
|
+
// so the preview follows it wherever it came from — see `drop-aim.ts`.
|
|
132
|
+
const [aim, setAim] = useState<DropAim | null>(null)
|
|
133
|
+
|
|
134
|
+
// A card lifted with the keyboard. Jira's model, and the only one that works
|
|
135
|
+
// here: an arrow key cannot commit a move of its own, because each one would
|
|
136
|
+
// be a separate write to the server — so the card is lifted, walked to where
|
|
137
|
+
// it belongs, and put down once.
|
|
138
|
+
const [lifted, setLifted] = useState<{ ticketId: string; aim: BoardAim } | null>(null)
|
|
139
|
+
const [announcement, setAnnouncement] = useState('')
|
|
140
|
+
|
|
141
|
+
// The card being carried is the browser's own drag image (see
|
|
142
|
+
// `ticket-card.tsx`), so nothing here follows the pointer and a drag re-renders
|
|
143
|
+
// nothing. This is measured at drag start and only used at the end, to put a
|
|
144
|
+
// copy back where that image was and glide it into place.
|
|
145
|
+
const dragGeometry = useRef<DragGeometry | null>(null)
|
|
146
|
+
const [landingPreview, setLandingPreview] = useState<LandingPreview | null>(null)
|
|
147
|
+
const previewRef = useRef<HTMLDivElement | null>(null)
|
|
148
|
+
|
|
149
|
+
// A move that has been reported but not answered yet. The board shows it
|
|
150
|
+
// itself for that gap — see `pending-move.ts` for why the card cannot simply
|
|
151
|
+
// wait for the host, and why waiting is what made a drop look like the ticket
|
|
152
|
+
// disappearing and reappearing.
|
|
153
|
+
const [pendingMove, setPendingMove] = useState<PendingMove | null>(null)
|
|
154
|
+
const view = useMemo(() => (pendingMove ? applyPendingMove(columns, pendingMove) : columns), [columns, pendingMove])
|
|
155
|
+
// EVERYTHING downstream reads `view`, never `columns`: it is what is on
|
|
156
|
+
// screen, so it is also what a drag, a keyboard move and a drop are about.
|
|
157
|
+
// `columns` is only ever consulted to ask whether the host has caught up.
|
|
158
|
+
columnsRef.current = view
|
|
159
|
+
|
|
160
|
+
// The card still travelling from the pointer into the slot it landed in. The
|
|
161
|
+
// preview is over it for that moment, so the card itself is hidden.
|
|
162
|
+
const [landing, setLanding] = useState<string | null>(null)
|
|
163
|
+
const landingTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
164
|
+
/** Where the pointer was last seen, in the same terms the preview is placed. */
|
|
165
|
+
const pointRef = useRef({ x: 0, y: 0 })
|
|
166
|
+
|
|
167
|
+
// ---- keyboard move -------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const laneLabel = useCallback(
|
|
170
|
+
(columnId: string) => view.find(c => c.id === columnId)?.label ?? columnId,
|
|
171
|
+
[view],
|
|
84
172
|
)
|
|
85
173
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
174
|
+
/** Lanes the lifted card is allowed into — the same rules a pointer drop obeys. */
|
|
175
|
+
const acceptsLifted = useCallback(
|
|
176
|
+
(fromColumnId: string) => (column: BoardColumnDef) => {
|
|
177
|
+
if (column.dropDisabled || collapsed[column.id]) return false
|
|
178
|
+
if (column.id === fromColumnId) return true
|
|
179
|
+
return !column.allowedFromColumns || column.allowedFromColumns.includes(fromColumnId)
|
|
180
|
+
},
|
|
181
|
+
[collapsed],
|
|
182
|
+
)
|
|
89
183
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
184
|
+
const lift = useCallback(
|
|
185
|
+
(ticketId: string) => {
|
|
186
|
+
const start = initialAim(view, ticketId)
|
|
187
|
+
if (!start) return
|
|
188
|
+
setLifted({ ticketId, aim: start })
|
|
189
|
+
const title = laneOf(view, ticketId)?.tickets.find(t => t.id === ticketId)?.title ?? 'Ticket'
|
|
190
|
+
setAnnouncement(`${title} lifted. Use the arrow keys to move it, space to drop it, escape to put it back.`)
|
|
191
|
+
},
|
|
192
|
+
[view],
|
|
193
|
+
)
|
|
94
194
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return d?.type === 'ticket' && d.columnId === columnId
|
|
103
|
-
})
|
|
104
|
-
if (ticketsInColumn.length > 0) {
|
|
105
|
-
const closest = closestCorners({ ...args, droppableContainers: ticketsInColumn })
|
|
106
|
-
if (closest.length > 0) return closest
|
|
107
|
-
}
|
|
108
|
-
return [columnHit]
|
|
195
|
+
const drop = useCallback(() => {
|
|
196
|
+
if (!lifted) return
|
|
197
|
+
const change = aimToChange(view, lifted.ticketId, lifted.aim)
|
|
198
|
+
setLifted(null)
|
|
199
|
+
if (!change) {
|
|
200
|
+
setAnnouncement('Put back where it was.')
|
|
201
|
+
return
|
|
109
202
|
}
|
|
203
|
+
setAnnouncement(`Dropped into ${laneLabel(change.toColumnId)}.`)
|
|
204
|
+
setPendingMove({
|
|
205
|
+
ticketId: change.ticketId,
|
|
206
|
+
fromColumnId: change.fromColumnId,
|
|
207
|
+
toColumnId: change.toColumnId,
|
|
208
|
+
afterTicketId: change.afterTicketId,
|
|
209
|
+
})
|
|
210
|
+
onChange(change)
|
|
211
|
+
}, [lifted, view, laneLabel, onChange])
|
|
212
|
+
|
|
213
|
+
const walk = useCallback(
|
|
214
|
+
(key: ArrowKey) => {
|
|
215
|
+
if (!lifted) return
|
|
216
|
+
const from = laneOf(view, lifted.ticketId)?.id ?? lifted.aim.columnId
|
|
217
|
+
const next = moveAim(view, lifted.ticketId, lifted.aim, key, acceptsLifted(from))
|
|
218
|
+
if (next === lifted.aim) return
|
|
219
|
+
setLifted({ ...lifted, aim: next })
|
|
220
|
+
const total = (view.find(c => c.id === next.columnId)?.tickets ?? []).filter(
|
|
221
|
+
t => t.id !== lifted.ticketId,
|
|
222
|
+
).length
|
|
223
|
+
setAnnouncement(`${laneLabel(next.columnId)}, position ${next.index + 1} of ${total + 1}.`)
|
|
224
|
+
},
|
|
225
|
+
[lifted, view, acceptsLifted, laneLabel],
|
|
226
|
+
)
|
|
110
227
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const handleDragOver = (e: DragOverEvent) => {
|
|
124
|
-
const { active, over } = e
|
|
125
|
-
if (!over) return
|
|
126
|
-
|
|
127
|
-
const activeId = String(active.id)
|
|
128
|
-
const overId = String(over.id)
|
|
129
|
-
if (activeId === overId) return
|
|
130
|
-
|
|
131
|
-
const overData = over.data.current as { columnId?: string; type?: string } | undefined
|
|
132
|
-
const fromColumnId = locate(items, activeId)?.columnId
|
|
133
|
-
const toColumnId = overData?.columnId
|
|
134
|
-
if (!fromColumnId || !toColumnId || fromColumnId === toColumnId) return
|
|
135
|
-
|
|
136
|
-
const origin = dragOriginRef.current
|
|
137
|
-
const isReturnToOrigin = origin?.fromColumnId === toColumnId
|
|
138
|
-
const targetCol = items.find(c => c.id === toColumnId)
|
|
139
|
-
const blockedBySource =
|
|
140
|
-
!isReturnToOrigin &&
|
|
141
|
-
!!targetCol?.allowedFromColumns &&
|
|
142
|
-
!!origin &&
|
|
143
|
-
!targetCol.allowedFromColumns.includes(origin.fromColumnId)
|
|
144
|
-
if ((targetCol?.dropDisabled && !isReturnToOrigin) || blockedBySource) return
|
|
145
|
-
|
|
146
|
-
setItems(prev => {
|
|
147
|
-
const fromIndex = findIndexInColumn(prev, fromColumnId, activeId)
|
|
148
|
-
const toCol = prev.find(c => c.id === toColumnId)
|
|
149
|
-
if (fromIndex < 0 || !toCol) return prev
|
|
150
|
-
|
|
151
|
-
let toIndex: number
|
|
152
|
-
if (overData?.type === 'column') {
|
|
153
|
-
toIndex = toCol.tickets.length
|
|
228
|
+
// Handled on the board rather than on each card: once a card is lifted the
|
|
229
|
+
// keys belong to the move, not to whatever happens to hold focus.
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!lifted) return
|
|
232
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
233
|
+
if (event.key === 'Escape') {
|
|
234
|
+
setLifted(null)
|
|
235
|
+
setAnnouncement('Move cancelled.')
|
|
236
|
+
} else if (event.key === ' ' || event.key === 'Enter') {
|
|
237
|
+
drop()
|
|
238
|
+
} else if (ARROW_KEYS.includes(event.key as ArrowKey)) {
|
|
239
|
+
walk(event.key as ArrowKey)
|
|
154
240
|
} else {
|
|
155
|
-
|
|
156
|
-
if (overIndex < 0) {
|
|
157
|
-
toIndex = toCol.tickets.length
|
|
158
|
-
} else {
|
|
159
|
-
const activeRect = active.rect.current.translated
|
|
160
|
-
const overRect = over.rect
|
|
161
|
-
const isBelow = !!activeRect && activeRect.top > overRect.top + overRect.height / 2
|
|
162
|
-
toIndex = overIndex + (isBelow ? 1 : 0)
|
|
163
|
-
}
|
|
241
|
+
return
|
|
164
242
|
}
|
|
243
|
+
// Arrows would scroll the lane out from under the card, and space would
|
|
244
|
+
// scroll the page.
|
|
245
|
+
event.preventDefault()
|
|
246
|
+
}
|
|
247
|
+
window.addEventListener('keydown', onKeyDown)
|
|
248
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
249
|
+
}, [lifted, drop, walk])
|
|
250
|
+
|
|
251
|
+
// A card walked with the keyboard has to be visible where it is being walked
|
|
252
|
+
// to. A pointer drag brings its own scrolling — auto-scroll follows it to the
|
|
253
|
+
// edges — but arrow keys move nothing on screen, so a slot below the fold, or
|
|
254
|
+
// in a lane scrolled off the side of the board, was being aimed at completely
|
|
255
|
+
// out of sight. `lifted` is a new object on every step, so this runs per step.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!lifted) return
|
|
258
|
+
boardRef.current?.querySelector(`[${DROP_LINE_ATTRIBUTE}]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
259
|
+
}, [lifted])
|
|
260
|
+
|
|
261
|
+
/** The lifted card feeds the same preview a pointer drag does, so the room
|
|
262
|
+
* opening and the faded copy need to know nothing about keyboards. */
|
|
263
|
+
const keyboardAim = useMemo<DropAim | null>(() => {
|
|
264
|
+
if (!lifted) return null
|
|
265
|
+
const ticket = laneOf(view, lifted.ticketId)?.tickets.find(t => t.id === lifted.ticketId)
|
|
266
|
+
if (!ticket) return null
|
|
267
|
+
const anchor = aimToChange(view, lifted.ticketId, lifted.aim)
|
|
268
|
+
? aimAnchor(view, lifted.ticketId, lifted.aim)
|
|
269
|
+
: { ticketId: null, edge: null }
|
|
270
|
+
return { columnId: lifted.aim.columnId, ...anchor, ticket }
|
|
271
|
+
}, [lifted, view])
|
|
272
|
+
|
|
273
|
+
// Deliberately reads the RAW `columns` — the question is whether the host has
|
|
274
|
+
// caught up, and `view` already has the answer painted into it.
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!pendingMove) return
|
|
277
|
+
// Handing back once the move has landed matters: a host that rolls a failed
|
|
278
|
+
// move back puts the card in its old slot, and a `pendingMove` still sitting
|
|
279
|
+
// here would keep showing the move as though it had worked.
|
|
280
|
+
if (hasMoveSettled(columns, pendingMove)) {
|
|
281
|
+
setPendingMove(null)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const timer = setTimeout(() => setPendingMove(null), PENDING_MOVE_TIMEOUT_MS)
|
|
285
|
+
return () => clearTimeout(timer)
|
|
286
|
+
}, [columns, pendingMove])
|
|
287
|
+
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
const scroller = scrollerRef.current
|
|
290
|
+
|
|
291
|
+
/** What the innermost drop target is currently saying, in the one shape the
|
|
292
|
+
* cards need. A card and a lane answer in the same terms, so this does not
|
|
293
|
+
* care which one replied — and nothing outside a lane replies at all. */
|
|
294
|
+
const readAim = (source: { data: Record<string | symbol, unknown> }, target?: Record<string | symbol, unknown>) => {
|
|
295
|
+
const ticket = source.data.ticket as BoardTicket | undefined
|
|
296
|
+
if (!target || !ticket) return null
|
|
297
|
+
const ticketId = String(source.data.ticketId)
|
|
298
|
+
const slot = aimFromTarget(columnsRef.current, ticketId, target)
|
|
299
|
+
if (!slot) return null
|
|
300
|
+
// No line where the card already is. A drop there changes nothing, and
|
|
301
|
+
// marking a place that is not a move is noise around the card you are
|
|
302
|
+
// holding — the one thing every board gets right by NOT drawing it.
|
|
303
|
+
const anchor = aimToChange(columnsRef.current, ticketId, slot)
|
|
304
|
+
? aimAnchor(columnsRef.current, ticketId, slot)
|
|
305
|
+
: { ticketId: null, edge: null }
|
|
306
|
+
return { columnId: slot.columnId, ...anchor, ticket }
|
|
307
|
+
}
|
|
165
308
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
}
|
|
309
|
+
const aimAt = (
|
|
310
|
+
source: { data: Record<string | symbol, unknown> },
|
|
311
|
+
targets: { data: Record<string | symbol, unknown> }[],
|
|
312
|
+
) => {
|
|
313
|
+
const next = readAim(source, targets[0]?.data)
|
|
314
|
+
// A pointer moving inside one card re-resolves to the same answer every
|
|
315
|
+
// frame; keeping the previous object stops that re-rendering the board.
|
|
316
|
+
setAim(prev => (isSameAim(prev, next) ? prev : next))
|
|
317
|
+
}
|
|
177
318
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
319
|
+
return combine(
|
|
320
|
+
// Dragging towards the edge of the board scrolls the lanes sideways —
|
|
321
|
+
// and keeps scrolling once the pointer is past that edge.
|
|
322
|
+
scroller ? autoScrollBothWays(scroller) : () => {},
|
|
323
|
+
// ...and towards the edge of the SCREEN scrolls whatever the page is
|
|
324
|
+
// actually scrolled by, which is the layout's `<main>`, not the document.
|
|
325
|
+
// Auto-scroll is registered on those; a DROP is not. Nothing outside the
|
|
326
|
+
// lanes is a drop target: a card carried over the filters, the header or
|
|
327
|
+
// the sidebar is aimed at nothing, draws nothing, and cancels when
|
|
328
|
+
// released. Resolving a lane from a pointer that has left the board — by
|
|
329
|
+
// matching it horizontally to the column above or below it — was tried and
|
|
330
|
+
// is not what a board does; the answer has to be somewhere the user is
|
|
331
|
+
// actually pointing.
|
|
332
|
+
monitorForElements({
|
|
333
|
+
canMonitor: ({ source }) => source.data.type === 'ticket',
|
|
334
|
+
|
|
335
|
+
onDragStart: ({ source, location }) => {
|
|
336
|
+
const ticket = source.data.ticket as BoardColumnDef['tickets'][number] | undefined
|
|
337
|
+
if (!ticket) return
|
|
338
|
+
const rect = source.element.getBoundingClientRect()
|
|
339
|
+
const { clientX, clientY } = location.current.input
|
|
340
|
+
pointRef.current = { x: clientX, y: clientY }
|
|
341
|
+
// Nothing outside the lanes is a drop target, and a native drag with
|
|
342
|
+
// no target under it falls back to the browser's own drop effect —
|
|
343
|
+
// which is `copy`, i.e. the green plus cursor, over the filters, the
|
|
344
|
+
// header and the sidebar. This is Pragmatic's own answer: it holds the
|
|
345
|
+
// effect at `move` everywhere, so the cursor stays a plain drag cursor
|
|
346
|
+
// wherever the card is carried. It changes nothing about WHERE the
|
|
347
|
+
// card can land — the board still resolves that from its own targets,
|
|
348
|
+
// and releasing outside them still cancels.
|
|
349
|
+
preventUnhandled.start()
|
|
350
|
+
// A previous card may still be gliding into place; this drag owns the
|
|
351
|
+
// preview from here, so that one arrives instantly instead.
|
|
352
|
+
setLanding(null)
|
|
353
|
+
dragGeometry.current = {
|
|
354
|
+
ticket,
|
|
355
|
+
columnColor: columnsRef.current.find(c => c.id === source.data.columnId)?.color,
|
|
356
|
+
width: rect.width,
|
|
357
|
+
grabX: clientX - rect.left,
|
|
358
|
+
grabY: clientY - rect.top,
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
onDropTargetChange: ({ source, location }) => aimAt(source, location.current.dropTargets),
|
|
363
|
+
|
|
364
|
+
onDrag: ({ source, location }) => {
|
|
365
|
+
// The edge can flip without the set of targets changing, so the aim is
|
|
366
|
+
// re-read here too — `onDrag` is throttled to an animation frame.
|
|
367
|
+
aimAt(source, location.current.dropTargets)
|
|
368
|
+
const { clientX, clientY } = location.current.input
|
|
369
|
+
pointRef.current = { x: clientX, y: clientY }
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
onDrop: ({ source, location }) => {
|
|
373
|
+
preventUnhandled.stop()
|
|
374
|
+
setAim(null)
|
|
375
|
+
// Innermost first: a card if the pointer was over one, otherwise the
|
|
376
|
+
// lane it was dropped into. Empty means dropped outside the board.
|
|
377
|
+
const change = resolveBoardDrop(columnsRef.current, {
|
|
378
|
+
source: source.data,
|
|
379
|
+
target: location.current.dropTargets[0]?.data,
|
|
380
|
+
})
|
|
381
|
+
// Released where nothing would change, or outside the board entirely.
|
|
382
|
+
// The browser takes its drag image away by itself and the card un-fades
|
|
383
|
+
// on its own `onDrop`, in this same event, so there is nothing to undo.
|
|
384
|
+
if (!change) return
|
|
385
|
+
|
|
386
|
+
// The move goes on screen NOW, from here, and the host's answer
|
|
387
|
+
// replaces an identical picture whenever it arrives.
|
|
388
|
+
setPendingMove({
|
|
389
|
+
ticketId: change.ticketId,
|
|
390
|
+
fromColumnId: change.fromColumnId,
|
|
391
|
+
toColumnId: change.toColumnId,
|
|
392
|
+
afterTicketId: change.afterTicketId,
|
|
393
|
+
})
|
|
394
|
+
// ...and a copy of the card takes over from the drag image for one
|
|
395
|
+
// short glide into the slot that move just opened. Both land in the
|
|
396
|
+
// same commit, so the slot is already there to be measured — see the
|
|
397
|
+
// effect below.
|
|
398
|
+
const geometry = dragGeometry.current
|
|
399
|
+
if (geometry) {
|
|
400
|
+
setLandingPreview({ ...geometry, startX: pointRef.current.x, startY: pointRef.current.y })
|
|
401
|
+
setLanding(change.ticketId)
|
|
402
|
+
}
|
|
403
|
+
onChangeRef.current(change)
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
}, [])
|
|
183
408
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
409
|
+
/**
|
|
410
|
+
* The dropped card's glide from where it was released into its slot.
|
|
411
|
+
*
|
|
412
|
+
* Runs in the commit that carries the move itself, so the slot already exists
|
|
413
|
+
* and can be measured; the card in it is hidden (see `LandingCardContext`) and
|
|
414
|
+
* this copy is what the eye follows the rest of the way. Without it the
|
|
415
|
+
* browser's drag image simply vanishes at the pointer and the card is suddenly
|
|
416
|
+
* elsewhere — the discontinuity the whole drop sequence exists to remove.
|
|
417
|
+
*
|
|
418
|
+
* A plain FLIP, written out rather than split between the render and here: the
|
|
419
|
+
* copy is placed where the drag image was, that position is forced to resolve,
|
|
420
|
+
* and only then is it given a destination. Skipping the forced resolve means
|
|
421
|
+
* the browser only ever sees the final value and animates from nothing — an
|
|
422
|
+
* element's very first style resolution never starts a transition.
|
|
423
|
+
*/
|
|
424
|
+
useIsomorphicLayoutEffect(() => {
|
|
425
|
+
if (!landing) return
|
|
426
|
+
|
|
427
|
+
const finish = () => {
|
|
428
|
+
landingTimer.current = null
|
|
429
|
+
setLanding(null)
|
|
430
|
+
setLandingPreview(null)
|
|
188
431
|
}
|
|
189
432
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
433
|
+
const preview = previewRef.current
|
|
434
|
+
const card = boardRef.current && cardById(boardRef.current, landing)
|
|
435
|
+
if (!preview || !landingPreview || !card) {
|
|
436
|
+
finish()
|
|
193
437
|
return
|
|
194
438
|
}
|
|
195
439
|
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
setItems(columns)
|
|
440
|
+
const to = card.getBoundingClientRect()
|
|
441
|
+
// The slot has to be somewhere the eye can actually follow the card to. A
|
|
442
|
+
// lane clips its own overflow, so a slot hanging over its edge would take a
|
|
443
|
+
// card that is whole one frame and cut in half the next; the card simply
|
|
444
|
+
// appears in place instead, which is never wrong — only less pretty.
|
|
445
|
+
const lane = nearestScrollableAncestor(card)?.getBoundingClientRect()
|
|
446
|
+
if (lane && (to.top < lane.top || to.bottom > lane.bottom)) {
|
|
447
|
+
finish()
|
|
205
448
|
return
|
|
206
449
|
}
|
|
207
450
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
451
|
+
const { startX, startY } = landingPreview
|
|
452
|
+
preview.style.transition = 'none'
|
|
453
|
+
preview.style.opacity = String(DRAG_PREVIEW_OPACITY)
|
|
454
|
+
preview.style.transform = `translate(${startX}px, ${startY}px)`
|
|
455
|
+
const from = preview.getBoundingClientRect()
|
|
456
|
+
void preview.offsetHeight
|
|
457
|
+
|
|
458
|
+
// Settles solid as it lands, so handing over to the real card underneath is
|
|
459
|
+
// not also a jump in brightness.
|
|
460
|
+
preview.style.transition = `transform ${DROP_MS}ms ${DROP_EASING}, opacity ${DROP_MS}ms ${DROP_EASING}`
|
|
461
|
+
preview.style.opacity = '1'
|
|
462
|
+
preview.style.transform = `translate(${startX + to.left - from.left}px, ${startY + to.top - from.top}px)`
|
|
463
|
+
landingTimer.current = setTimeout(finish, DROP_MS)
|
|
464
|
+
return () => {
|
|
465
|
+
if (landingTimer.current) clearTimeout(landingTimer.current)
|
|
466
|
+
landingTimer.current = null
|
|
223
467
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
468
|
+
}, [landing, landingPreview])
|
|
469
|
+
|
|
470
|
+
// The released card, on its way into its slot. Alive for one short animation
|
|
471
|
+
// and no longer: while the card is actually being carried there is nothing
|
|
472
|
+
// here at all — that is the browser's drag image (see `ticket-card.tsx`).
|
|
473
|
+
//
|
|
474
|
+
// A portal, because a lane clips its own overflow and this travels across the
|
|
475
|
+
// whole board. Position, opacity and transition are all written by the effect
|
|
476
|
+
// above and deliberately not by React: a re-render for an unrelated reason
|
|
477
|
+
// must not put the card back at the pointer half way down.
|
|
478
|
+
const preview =
|
|
479
|
+
landingPreview &&
|
|
480
|
+
createPortal(
|
|
481
|
+
<div
|
|
482
|
+
ref={previewRef}
|
|
483
|
+
aria-hidden
|
|
484
|
+
// `[&_*]:!pointer-events-none` and not just the root: the card body
|
|
485
|
+
// re-enables pointer events on its own parts (the assignee slot, the
|
|
486
|
+
// timestamp tooltip), and this sits over the board while it settles.
|
|
487
|
+
className="pointer-events-none fixed left-0 top-0 [&_*]:!pointer-events-none"
|
|
488
|
+
style={{
|
|
489
|
+
zIndex: DRAG_PREVIEW_Z,
|
|
490
|
+
width: landingPreview.width,
|
|
491
|
+
// The grip offset lives here, so the animated property is a plain
|
|
492
|
+
// translate in pointer coordinates and nothing else.
|
|
493
|
+
margin: `${-landingPreview.grabY}px 0 0 ${-landingPreview.grabX}px`,
|
|
494
|
+
}}
|
|
495
|
+
>
|
|
496
|
+
{/* Rendered with the host's assignee slot, like the drag image it takes
|
|
497
|
+
over from: without it the card visibly changes at the handover. It is
|
|
498
|
+
a live control, but an inert copy of one — nothing in this subtree
|
|
499
|
+
can be pointed at (see above) — and it shares its queries with the
|
|
500
|
+
card it was copied from. */}
|
|
501
|
+
<TicketCardView
|
|
502
|
+
ticket={landingPreview.ticket}
|
|
503
|
+
columnColor={landingPreview.columnColor}
|
|
504
|
+
renderAssignSlot={renderAssignSlot}
|
|
505
|
+
// Still held, to match the drag image at the moment it hands over.
|
|
506
|
+
className="shadow-card-hover"
|
|
507
|
+
/>
|
|
508
|
+
</div>,
|
|
509
|
+
document.body,
|
|
510
|
+
)
|
|
245
511
|
|
|
246
512
|
return (
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
onDragEnd={handleDragEnd}
|
|
253
|
-
onDragCancel={handleDragCancel}
|
|
254
|
-
>
|
|
255
|
-
<div className={cn('flex flex-col h-full', className)}>
|
|
256
|
-
<div
|
|
257
|
-
ref={scrollRef}
|
|
258
|
-
onScroll={onScroll}
|
|
259
|
-
className="flex flex-1 min-h-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
260
|
-
>
|
|
261
|
-
{items.map((column, i) => {
|
|
262
|
-
const prev = items[i - 1]
|
|
263
|
-
const next = items[i + 1]
|
|
264
|
-
const joinLeft = !!(column.system && prev?.system)
|
|
265
|
-
const joinRight = !!(column.system && next?.system)
|
|
266
|
-
const showGap = i > 0 && !joinLeft
|
|
267
|
-
return (
|
|
268
|
-
<React.Fragment key={column.id}>
|
|
269
|
-
{showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
|
|
270
|
-
<BoardColumn
|
|
271
|
-
column={column}
|
|
272
|
-
collapsed={!!collapsed[column.id]}
|
|
273
|
-
onToggleCollapse={() => toggle(column.id)}
|
|
274
|
-
onAddTicket={onAddTicket}
|
|
275
|
-
onArchive={onArchiveColumn}
|
|
276
|
-
getTicketHref={getTicketHref}
|
|
277
|
-
renderAssignSlot={renderAssignSlot}
|
|
278
|
-
onApprove={onApprove}
|
|
279
|
-
onReject={onReject}
|
|
280
|
-
onLoadMore={onLoadMore}
|
|
281
|
-
loadMoreRootMargin={loadMoreRootMargin}
|
|
282
|
-
joinLeft={joinLeft}
|
|
283
|
-
joinRight={joinRight}
|
|
284
|
-
/>
|
|
285
|
-
</React.Fragment>
|
|
286
|
-
)
|
|
287
|
-
})}
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
{thumbRatio > 0 && (
|
|
291
|
-
<div
|
|
292
|
-
ref={trackRef}
|
|
293
|
-
onClick={onTrackClick}
|
|
294
|
-
onWheel={onTrackWheel}
|
|
295
|
-
className="relative h-2 mt-[var(--spacing-system-mf)] rounded-full bg-ods-border cursor-pointer shrink-0"
|
|
296
|
-
>
|
|
513
|
+
<LandingCardContext.Provider value={landing}>
|
|
514
|
+
<DropAimContext.Provider value={aim ?? keyboardAim}>
|
|
515
|
+
<BoardLiftContext.Provider value={lift}>
|
|
516
|
+
{preview}
|
|
517
|
+
<div ref={boardRef} className={cn('flex flex-col h-full', className)}>
|
|
297
518
|
<div
|
|
298
|
-
ref={
|
|
299
|
-
|
|
300
|
-
className="
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
519
|
+
ref={setScroller}
|
|
520
|
+
onScroll={onScroll}
|
|
521
|
+
className="flex flex-1 min-h-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
522
|
+
>
|
|
523
|
+
{view.map((column, i) => {
|
|
524
|
+
const prev = view[i - 1]
|
|
525
|
+
const next = view[i + 1]
|
|
526
|
+
const joinLeft = !!(column.system && prev?.system)
|
|
527
|
+
const joinRight = !!(column.system && next?.system)
|
|
528
|
+
const showGap = i > 0 && !joinLeft
|
|
529
|
+
return (
|
|
530
|
+
<Fragment key={column.id}>
|
|
531
|
+
{showGap && <div aria-hidden className="w-[var(--spacing-system-mf)] shrink-0" />}
|
|
532
|
+
<BoardColumn
|
|
533
|
+
column={column}
|
|
534
|
+
collapsed={!!collapsed[column.id]}
|
|
535
|
+
onToggleCollapse={toggle}
|
|
536
|
+
onAddTicket={onAddTicket}
|
|
537
|
+
onArchive={onArchiveColumn}
|
|
538
|
+
getTicketHref={getTicketHref}
|
|
539
|
+
renderAssignSlot={renderAssignSlot}
|
|
540
|
+
onApprove={onApprove}
|
|
541
|
+
onReject={onReject}
|
|
542
|
+
onLoadMore={onLoadMore}
|
|
543
|
+
loadMoreRootMargin={loadMoreRootMargin}
|
|
544
|
+
joinLeft={joinLeft}
|
|
545
|
+
joinRight={joinRight}
|
|
546
|
+
/>
|
|
547
|
+
</Fragment>
|
|
548
|
+
)
|
|
549
|
+
})}
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<p aria-live="polite" className="sr-only">
|
|
553
|
+
{announcement}
|
|
554
|
+
</p>
|
|
555
|
+
|
|
556
|
+
{thumbRatio > 0 && (
|
|
557
|
+
<div
|
|
558
|
+
ref={trackRef}
|
|
559
|
+
onClick={onTrackClick}
|
|
560
|
+
onWheel={onTrackWheel}
|
|
561
|
+
className="relative h-2 mt-[var(--spacing-system-mf)] rounded-full bg-ods-border cursor-pointer shrink-0"
|
|
562
|
+
>
|
|
563
|
+
<div
|
|
564
|
+
ref={thumbRef}
|
|
565
|
+
data-scrollbar-thumb
|
|
566
|
+
className="absolute top-0 h-full rounded-full bg-ods-text-secondary transition-colors"
|
|
567
|
+
style={{ width: `${thumbRatio * 100}%`, cursor: 'grab' }}
|
|
568
|
+
onPointerDown={onThumbPointerDown}
|
|
569
|
+
onPointerMove={onThumbPointerMove}
|
|
570
|
+
onPointerUp={onThumbPointerUp}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
306
574
|
</div>
|
|
307
|
-
|
|
308
|
-
</
|
|
309
|
-
|
|
310
|
-
{activeTicket ? (
|
|
311
|
-
<TicketCard
|
|
312
|
-
ticket={activeTicket.ticket}
|
|
313
|
-
columnId={activeTicket.columnId}
|
|
314
|
-
columnColor={items.find(c => c.id === activeTicket.columnId)?.color}
|
|
315
|
-
isOverlay
|
|
316
|
-
/>
|
|
317
|
-
) : null}
|
|
318
|
-
</DragOverlay>
|
|
319
|
-
</DndContext>
|
|
575
|
+
</BoardLiftContext.Provider>
|
|
576
|
+
</DropAimContext.Provider>
|
|
577
|
+
</LandingCardContext.Provider>
|
|
320
578
|
)
|
|
321
579
|
}
|
|
322
|
-
|
|
323
|
-
function locate(
|
|
324
|
-
cols: BoardColumnDef[],
|
|
325
|
-
ticketId: string,
|
|
326
|
-
): { ticket: BoardTicket; columnId: string; index: number } | null {
|
|
327
|
-
for (const c of cols) {
|
|
328
|
-
const idx = c.tickets.findIndex(t => t.id === ticketId)
|
|
329
|
-
if (idx >= 0) return { ticket: c.tickets[idx], columnId: c.id, index: idx }
|
|
330
|
-
}
|
|
331
|
-
return null
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function findIndexInColumn(cols: BoardColumnDef[], columnId: string, ticketId: string): number {
|
|
335
|
-
return cols.find(c => c.id === columnId)?.tickets.findIndex(t => t.id === ticketId) ?? -1
|
|
336
|
-
}
|