@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,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useState } from 'react'
|
|
3
|
+
import { useCallback, useRef, useState } from 'react'
|
|
4
4
|
import { useLocalStorage } from '../../../hooks/ui/use-local-storage'
|
|
5
5
|
|
|
6
6
|
export type BoardCollapseMap = Record<string, boolean>
|
|
@@ -21,19 +21,21 @@ export function useBoardCollapse(storageKey?: string): UseBoardCollapseReturn {
|
|
|
21
21
|
const collapsed = storageKey ? persisted : memory
|
|
22
22
|
const setMap = storageKey ? setPersisted : setMemory
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
24
|
+
// Both callbacks keep one identity for the hook's lifetime. `useLocalStorage`
|
|
25
|
+
// returns an unmemoized setter, so depending on it directly hands the board a
|
|
26
|
+
// new `toggle` on every render — and a board that memoizes its columns then
|
|
27
|
+
// re-renders all of them on every drag frame, which is the one thing the memo
|
|
28
|
+
// is there to prevent. The ref keeps the latest setter without the identity.
|
|
29
|
+
const setMapRef = useRef(setMap)
|
|
30
|
+
setMapRef.current = setMap
|
|
31
|
+
|
|
32
|
+
const toggle = useCallback((columnId: string) => {
|
|
33
|
+
setMapRef.current(prev => ({ ...prev, [columnId]: !prev[columnId] }))
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
const setCollapsed = useCallback((columnId: string, value: boolean) => {
|
|
37
|
+
setMapRef.current(prev => ({ ...prev, [columnId]: value }))
|
|
38
|
+
}, [])
|
|
37
39
|
|
|
38
40
|
return { collapsed, toggle, setCollapsed }
|
|
39
41
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef, type RefObject } from 'react'
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect'
|
|
5
|
+
import type { BoardTicket } from './types'
|
|
6
|
+
|
|
7
|
+
/** Attribute the lane's cards carry so the anchor can find them again. */
|
|
8
|
+
export const TICKET_ID_ATTRIBUTE = 'data-ticket-id'
|
|
9
|
+
|
|
10
|
+
interface CardPosition {
|
|
11
|
+
id: string
|
|
12
|
+
/**
|
|
13
|
+
* Position in LAYOUT space, absolute within the lane — not the painted one.
|
|
14
|
+
* `offsetTop` ignores transforms, so a card mid-animation reads at its resting
|
|
15
|
+
* position and the correction below stays honest. Absolute rather than
|
|
16
|
+
* viewport-relative so that scrolling between two list changes — the drag's own
|
|
17
|
+
* auto-scroll, most of all — cannot stale the reading.
|
|
18
|
+
*/
|
|
19
|
+
offsetTop: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cardPositions(root: HTMLElement): CardPosition[] {
|
|
23
|
+
return Array.from(root.querySelectorAll<HTMLElement>(`[${TICKET_ID_ATTRIBUTE}]`)).map(element => ({
|
|
24
|
+
id: element.getAttribute(TICKET_ID_ATTRIBUTE) ?? '',
|
|
25
|
+
offsetTop: element.offsetTop,
|
|
26
|
+
}))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Keeps a scrolled lane visually still when its list changes above the viewport.
|
|
31
|
+
*
|
|
32
|
+
* Browsers do this themselves — it is called scroll anchoring — but the lane turns
|
|
33
|
+
* theirs off (`overflow-anchor:none`), because leaving both on double-corrects
|
|
34
|
+
* every insertion. So a ticket arriving from a live update is inserted above the
|
|
35
|
+
* viewport with nothing to compensate, and the whole list slides down by a card.
|
|
36
|
+
*
|
|
37
|
+
* NOTE: the original reason for taking this over by hand was that Chrome skips
|
|
38
|
+
* anchor candidates carrying a `transform`, and dnd-kit transformed every card in
|
|
39
|
+
* the lane during a drag. Nothing transforms cards any more, so the browser's own
|
|
40
|
+
* anchoring may well be enough now — worth testing against live updates before
|
|
41
|
+
* deleting this and the `overflow-anchor:none` that goes with it.
|
|
42
|
+
*
|
|
43
|
+
* The fix is one measurement: whichever card sits at the top of the viewport,
|
|
44
|
+
* note where it is, and after the list changes put the viewport back the same
|
|
45
|
+
* distance from it.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately inert at the very top of a lane (`scrollTop === 0`), matching what
|
|
48
|
+
* browsers do — there a new ticket is meant to be seen, not scrolled away.
|
|
49
|
+
*/
|
|
50
|
+
export function useLaneScrollAnchor(
|
|
51
|
+
scrollRef: RefObject<HTMLDivElement | null>,
|
|
52
|
+
tickets: readonly BoardTicket[],
|
|
53
|
+
): void {
|
|
54
|
+
const positionsRef = useRef<CardPosition[]>([])
|
|
55
|
+
|
|
56
|
+
useIsomorphicLayoutEffect(() => {
|
|
57
|
+
const root = scrollRef.current
|
|
58
|
+
if (!root) return
|
|
59
|
+
|
|
60
|
+
const previous = positionsRef.current
|
|
61
|
+
const scrollTop = root.scrollTop
|
|
62
|
+
// Measured once and reused: this runs for every lane on every list change —
|
|
63
|
+
// live updates, the host's refetch, every committed move — and reading
|
|
64
|
+
// `offsetTop` after the DOM changed forces a synchronous layout.
|
|
65
|
+
const current = cardPositions(root)
|
|
66
|
+
|
|
67
|
+
if (previous.length > 0 && scrollTop > 0) {
|
|
68
|
+
// The card at the top of the viewport right now, by its previous position.
|
|
69
|
+
const anchor = previous.find(card => card.offsetTop >= scrollTop) ?? previous[previous.length - 1]
|
|
70
|
+
const moved = current.find(card => card.id === anchor.id)
|
|
71
|
+
if (moved) {
|
|
72
|
+
const drift = moved.offsetTop - anchor.offsetTop
|
|
73
|
+
if (Math.abs(drift) >= 1) root.scrollTop = scrollTop + drift
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
positionsRef.current = current
|
|
78
|
+
}, [tickets])
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!-- source-hash: manual -->
|
|
2
|
+
A vertical list whose items are reordered by dragging a handle, built on Pragmatic drag and drop.
|
|
3
|
+
|
|
4
|
+
> **Currently unused.** It was written for the ordered lists — schedule scripts, ticket statuses,
|
|
5
|
+
> AI quick actions — and those have since gone back to `@dnd-kit` (`TicketStatusConfigList`), whose
|
|
6
|
+
> pointer sensors work under touch where the HTML5 drag-and-drop API this is built on does not.
|
|
7
|
+
> Kept, not deleted: it is a complete and general implementation, and the ticket board next door
|
|
8
|
+
> still runs on the same library. Reach for `TicketStatusConfigList` for a new ordered list; reach
|
|
9
|
+
> for this one only if you specifically need what it does differently — slot geometry with
|
|
10
|
+
> hysteresis, a FLIP drop animation, and auto-scroll through every scrollable ancestor.
|
|
11
|
+
|
|
12
|
+
Deliberately **not** the ticket board, which moves cards between columns and needs a different
|
|
13
|
+
model.
|
|
14
|
+
|
|
15
|
+
## Key Components
|
|
16
|
+
|
|
17
|
+
| Export | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `SortableList` | The container. Owns the geometry, the drag monitor and the live region |
|
|
20
|
+
| `useSortableItem` | Registers one item: gives back `itemRef`, `dragHandleProps` and `isDragging` |
|
|
21
|
+
| `SLOT_STICKINESS` | The dead band around a slot boundary, as a share of one slot's travel |
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<SortableList onReorder={move} getItemLabel={i => rows[i].name} className="flex flex-col gap-6">
|
|
27
|
+
{rows.map(row => <Row key={row.id} row={row} />)}
|
|
28
|
+
</SortableList>
|
|
29
|
+
|
|
30
|
+
function Row({ row }) {
|
|
31
|
+
const { itemRef, dragHandleProps, isDragging } = useSortableItem()
|
|
32
|
+
return (
|
|
33
|
+
<div ref={itemRef} className={isDragging ? 'border-ods-accent shadow-lg' : 'border-ods-border'}>
|
|
34
|
+
<button type="button" {...dragHandleProps} aria-label="Reorder…"><DraggerIcon /></button>
|
|
35
|
+
…
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`onReorder(from, to)` fires **once**, on drop or on an arrow key — never mid-drag. Do not put a
|
|
42
|
+
`style` prop on the item's root: the list writes `transform` straight onto that node.
|
|
43
|
+
|
|
44
|
+
## Why it is built this way
|
|
45
|
+
|
|
46
|
+
- **The drag moves nothing but transforms.** The dragged item keeps its slot and is translated to
|
|
47
|
+
follow the pointer; the items it passes are translated by exactly its height plus the row gap.
|
|
48
|
+
Layout is untouched, so the height of the page around the list cannot move, and the slide is safe
|
|
49
|
+
to animate. Opening and closing real space (margins, `display: none`) is not: interrupt one of
|
|
50
|
+
those transitions by moving the pointer, or reorder the DOM on drop, and the two ends run at
|
|
51
|
+
different rates and the page jumps by an item's height. Both failure modes were measured before
|
|
52
|
+
this shape was settled on.
|
|
53
|
+
- **The native drag image is off.** The browser draws it washed out and offers no way to style it,
|
|
54
|
+
so the real item follows the pointer instead, at full opacity.
|
|
55
|
+
- **Nearest resting place, with a handicap.** Where a drop would land is whichever slot the item is
|
|
56
|
+
nearest to coming to *rest* in — dnd-kit's `closestCenter`, which flips at the midpoint between
|
|
57
|
+
two slots, so half an item of travel is enough. The slot already held keeps a `SLOT_STICKINESS`
|
|
58
|
+
handicap; without it the item sits exactly ON the boundary it just crossed, so leaving costs half
|
|
59
|
+
an item and returning costs a pixel. Rules and arithmetic live in `slot-geometry.ts`, pure and
|
|
60
|
+
unit-tested (`src/__tests__/sortable-slot-geometry.test.ts`).
|
|
61
|
+
- **Everything is measured once, at drag start,** so an item moving out of the way can never change
|
|
62
|
+
the answer that moved it and start an oscillation. No React state changes during a drag: the
|
|
63
|
+
transforms are written straight to the DOM, so dragging a list of form cards re-renders nothing.
|
|
64
|
+
- **The landing is a FLIP, so the data never waits for it.** `onReorder` fires immediately; the
|
|
65
|
+
animation runs in a layout effect after the commit that carries the reorder, reading where each
|
|
66
|
+
item *looked* a moment earlier and playing it back from there. Items that stood aside during the
|
|
67
|
+
drag come out with a delta of zero — they are already in place — so this animates the dropped item
|
|
68
|
+
and nothing else. Worth animating because the item follows the pointer while the slot flips at a
|
|
69
|
+
midpoint, so it can be released up to half an item away from where it lands; teleporting that far
|
|
70
|
+
reads as a glitch rather than as "done".
|
|
71
|
+
- **Auto-scroll is registered on every scrollable ancestor, and the window.** dnd-kit walked that
|
|
72
|
+
chain for you; Pragmatic makes each scroller opt in by name, so after the migration a drag towards
|
|
73
|
+
the edge of the screen scrolled nothing. The window alone is not enough either — the app's real
|
|
74
|
+
scroller is the layout's `<main>`. Because an ancestor can now scroll mid-drag, every position is
|
|
75
|
+
measured relative to the list's own top rather than to the viewport or the document, so a scroll
|
|
76
|
+
moves the list and the pointer together and nothing goes stale.
|
|
77
|
+
- **Keyboard.** Pragmatic drag and drop ships none, so `dragHandleProps` carries Arrow Up / Arrow
|
|
78
|
+
Down — one press, one move. The list announces every move through its own live region.
|
|
79
|
+
|
|
80
|
+
## Source
|
|
81
|
+
|
|
82
|
+
[`sortable-list.tsx`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/sortable-list.tsx)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind a vertical sortable list: where a dragged item would
|
|
3
|
+
* come to rest at each index, which of those places it is currently nearest to,
|
|
4
|
+
* and how far that pushes everything else.
|
|
5
|
+
*
|
|
6
|
+
* Pure on purpose. The DOM is read exactly once, when the drag starts, and
|
|
7
|
+
* everything after that is numbers — so the rules can be unit-tested instead of
|
|
8
|
+
* discovered in a browser, and a card moving out of the way can never change the
|
|
9
|
+
* answer that moved it and start an oscillation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Share of one slot's travel that the slot an item already holds keeps as a
|
|
14
|
+
* handicap.
|
|
15
|
+
*
|
|
16
|
+
* Without it an item ends up sitting exactly ON a boundary the instant it
|
|
17
|
+
* crosses one: leaving a slot costs half an item of dragging and coming back
|
|
18
|
+
* costs a pixel, which reads as the list changing its mind about the rules. The
|
|
19
|
+
* handicap turns that into a dead band, so both cost the same movement — and a
|
|
20
|
+
* boundary just crossed cannot be re-crossed by jitter, or by the hand simply
|
|
21
|
+
* stopping.
|
|
22
|
+
*/
|
|
23
|
+
export const SLOT_STICKINESS = 0.25
|
|
24
|
+
|
|
25
|
+
export interface DragSlots {
|
|
26
|
+
/** Every item's top edge, before anything moved, measured from the list's own
|
|
27
|
+
* top rather than the viewport or the document — so a scroll during the drag
|
|
28
|
+
* (the page, the layout's `<main>`, any ancestor) leaves them all valid. */
|
|
29
|
+
tops: number[]
|
|
30
|
+
/** Every item's bottom edge, same frame of reference. */
|
|
31
|
+
bottoms: number[]
|
|
32
|
+
/** The dragged item's own height. */
|
|
33
|
+
height: number
|
|
34
|
+
/** The index the drag started from. */
|
|
35
|
+
from: number
|
|
36
|
+
/** How far a displaced item travels: the dragged item's height + the row gap. */
|
|
37
|
+
step: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads the list once, at drag start. The only DOM access in this module.
|
|
42
|
+
*
|
|
43
|
+
* `origin` is the list's own top edge in viewport coordinates; everything is
|
|
44
|
+
* stored relative to it, so auto-scrolling an ancestor mid-drag moves the list
|
|
45
|
+
* and the pointer together and none of this goes stale.
|
|
46
|
+
*/
|
|
47
|
+
export function measureSlots(items: readonly HTMLElement[], from: number, gap: number, origin: number): DragSlots {
|
|
48
|
+
const rects = items.map(item => item.getBoundingClientRect())
|
|
49
|
+
return {
|
|
50
|
+
tops: rects.map(rect => rect.top - origin),
|
|
51
|
+
bottoms: rects.map(rect => rect.bottom - origin),
|
|
52
|
+
height: rects[from].height,
|
|
53
|
+
from,
|
|
54
|
+
step: rects[from].height + gap,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where the dragged item's centre ends up if it is dropped at `index`.
|
|
60
|
+
*
|
|
61
|
+
* At or above its own index it takes over that item's top edge; below it, it
|
|
62
|
+
* backs up against that item's bottom edge. With items of one height those are
|
|
63
|
+
* the same point; with mixed heights they are not, and it is the resting place
|
|
64
|
+
* the user is aiming at — not the neighbour's centre.
|
|
65
|
+
*/
|
|
66
|
+
function restingCentre(slots: DragSlots, index: number): number {
|
|
67
|
+
return index <= slots.from ? slots.tops[index] + slots.height / 2 : slots.bottoms[index] - slots.height / 2
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The slot the dragged item is now nearest to landing in, given the one it
|
|
72
|
+
* currently holds and how far it has been dragged.
|
|
73
|
+
*
|
|
74
|
+
* Nearest — not "has crossed" — is what dnd-kit sorted by, and it is why the
|
|
75
|
+
* list opens up when it does: nearest flips at the midpoint between two slots,
|
|
76
|
+
* so half an item of travel is enough, where waiting for the item to cross a
|
|
77
|
+
* neighbour outright takes twice as long and reads as late.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveSlot(slots: DragSlots, held: number, offset: number): number {
|
|
80
|
+
const centre = slots.tops[slots.from] + slots.height / 2 + offset
|
|
81
|
+
let best = Math.abs(centre - restingCentre(slots, held)) - slots.step * SLOT_STICKINESS
|
|
82
|
+
let next = held
|
|
83
|
+
for (let index = 0; index < slots.tops.length; index++) {
|
|
84
|
+
if (index === held) continue
|
|
85
|
+
const distance = Math.abs(centre - restingCentre(slots, index))
|
|
86
|
+
if (distance < best) {
|
|
87
|
+
best = distance
|
|
88
|
+
next = index
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return next
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How far the item at `index` has to stand aside while the drop is aimed at `to`. */
|
|
95
|
+
export function shiftFor(slots: DragSlots, index: number, to: number): number {
|
|
96
|
+
if (index === slots.from) return 0
|
|
97
|
+
if (index > slots.from && index <= to) return -slots.step
|
|
98
|
+
if (index < slots.from && index >= to) return slots.step
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
|
4
|
+
import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
|
5
|
+
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled'
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
useCallback,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useId,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from 'react'
|
|
17
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect'
|
|
18
|
+
import { autoScrollAncestors, autoScrollBothWays } from '../../../utils/auto-scroll-ancestors'
|
|
19
|
+
import { type DragSlots, measureSlots, resolveSlot, shiftFor } from './slot-geometry'
|
|
20
|
+
|
|
21
|
+
/** Marks the elements the list reorders. Set by `useSortableItem`, never by hand. */
|
|
22
|
+
export const SORTABLE_ITEM_ATTRIBUTE = 'data-sortable-item'
|
|
23
|
+
|
|
24
|
+
/** How long a displaced item takes to slide into the slot it is giving up. */
|
|
25
|
+
const SHIFT_MS = 200
|
|
26
|
+
/** How long the dropped item takes to travel from the pointer into its slot. */
|
|
27
|
+
const DROP_MS = 150
|
|
28
|
+
const EASING = 'cubic-bezier(0.2, 0, 0, 1)'
|
|
29
|
+
|
|
30
|
+
interface SortableListContextValue {
|
|
31
|
+
/** Distinguishes this list's drags from any other list's on the page. */
|
|
32
|
+
dragType: string
|
|
33
|
+
disabled: boolean
|
|
34
|
+
/** Reorders the list. Used by the drop AND by a handle's arrow keys. */
|
|
35
|
+
reorder: (from: number, to: number) => void
|
|
36
|
+
/** Every registered item, in DOM order. */
|
|
37
|
+
readItems: () => HTMLElement[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SortableListContext = createContext<SortableListContextValue | null>(null)
|
|
41
|
+
|
|
42
|
+
export function useSortableListContext(): SortableListContextValue {
|
|
43
|
+
const context = useContext(SortableListContext)
|
|
44
|
+
if (!context) throw new Error('useSortableItem must be used inside a <SortableList>')
|
|
45
|
+
return context
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SortableListProps {
|
|
49
|
+
/** Called once, on drop or on an arrow key — never mid-drag. */
|
|
50
|
+
onReorder: (from: number, to: number) => void
|
|
51
|
+
/**
|
|
52
|
+
* Names an item for the live region, e.g. `index => scripts[index].name`.
|
|
53
|
+
* Without it a move is announced as "Item moved to position 2 of 5".
|
|
54
|
+
*/
|
|
55
|
+
getItemLabel?: (index: number) => string | undefined
|
|
56
|
+
disabled?: boolean
|
|
57
|
+
className?: string
|
|
58
|
+
children: ReactNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A vertical list whose items can be reordered by dragging a handle.
|
|
63
|
+
*
|
|
64
|
+
* **The drag moves nothing but transforms.** The item being dragged keeps its
|
|
65
|
+
* slot and is translated to follow the pointer; the items it passes are
|
|
66
|
+
* translated by exactly its height plus the row gap, into the slot it vacated.
|
|
67
|
+
* The list's layout — and so the height of the page around it — is therefore
|
|
68
|
+
* identical from the first frame of the drag to the last, which is what makes
|
|
69
|
+
* the slide safe to animate. Opening and closing real space instead (margins,
|
|
70
|
+
* `display: none`) cannot be: interrupt one of those transitions by moving the
|
|
71
|
+
* pointer, or reorder the DOM on drop, and the two ends run at different rates
|
|
72
|
+
* and the page jumps.
|
|
73
|
+
*
|
|
74
|
+
* The native drag image is switched off — the browser draws it washed out and
|
|
75
|
+
* offers no way to style it — so what follows the pointer is the real item, at
|
|
76
|
+
* full opacity, styled by the consumer off `isDragging`.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately NOT what the ticket board does: a board moves cards between
|
|
79
|
+
* columns and its cards are drop targets in their own right. Here the answer is
|
|
80
|
+
* geometry over a single column, which is why it fits in one small module. The
|
|
81
|
+
* rules it sorts by live in `slot-geometry.ts`.
|
|
82
|
+
*/
|
|
83
|
+
export function SortableList({ onReorder, getItemLabel, disabled = false, className, children }: SortableListProps) {
|
|
84
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
// Stable across renders and never rendered, so there is no hydration to
|
|
86
|
+
// mismatch — unlike keying a drag on a random per-item id.
|
|
87
|
+
const dragType = useId()
|
|
88
|
+
const [announcement, setAnnouncement] = useState('')
|
|
89
|
+
|
|
90
|
+
const onReorderRef = useRef(onReorder)
|
|
91
|
+
onReorderRef.current = onReorder
|
|
92
|
+
const getItemLabelRef = useRef(getItemLabel)
|
|
93
|
+
getItemLabelRef.current = getItemLabel
|
|
94
|
+
|
|
95
|
+
// Where every item sat on screen the instant the drop happened — the "First"
|
|
96
|
+
// of the FLIP played once the reorder has committed.
|
|
97
|
+
const flipFrom = useRef<Array<readonly [HTMLElement, number]> | null>(null)
|
|
98
|
+
const [flipTick, setFlipTick] = useState(0)
|
|
99
|
+
const flipTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
100
|
+
|
|
101
|
+
const readItems = useCallback(
|
|
102
|
+
() => [...(listRef.current?.querySelectorAll<HTMLElement>(`[${SORTABLE_ITEM_ATTRIBUTE}]`) ?? [])],
|
|
103
|
+
[],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/** The one way this list ever changes: say what happened, then do it. */
|
|
107
|
+
const reorder = useCallback(
|
|
108
|
+
(from: number, to: number) => {
|
|
109
|
+
if (from === to) return
|
|
110
|
+
const label = getItemLabelRef.current?.(from) ?? 'Item'
|
|
111
|
+
setAnnouncement(`${label} moved to position ${to + 1} of ${readItems().length}.`)
|
|
112
|
+
onReorderRef.current(from, to)
|
|
113
|
+
},
|
|
114
|
+
[readItems],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const list = listRef.current
|
|
119
|
+
if (!list) return
|
|
120
|
+
|
|
121
|
+
/** Alive for one drag; this effect body is its whole scope. */
|
|
122
|
+
let drag: (DragSlots & { items: HTMLElement[]; to: number; startY: number }) | null = null
|
|
123
|
+
|
|
124
|
+
return combine(
|
|
125
|
+
// Dragging towards the edge of the screen — or past it — scrolls whatever
|
|
126
|
+
// actually scrolls there: the page, the layout's `<main>`, or the list
|
|
127
|
+
// itself when it has its own overflow.
|
|
128
|
+
autoScrollAncestors(list),
|
|
129
|
+
autoScrollBothWays(list),
|
|
130
|
+
// One target for the whole list: which slot a drop lands in is answered by
|
|
131
|
+
// geometry, so this only has to say "inside the list, or outside it".
|
|
132
|
+
dropTargetForElements({
|
|
133
|
+
element: list,
|
|
134
|
+
canDrop: ({ source }) => source.data.type === dragType,
|
|
135
|
+
}),
|
|
136
|
+
monitorForElements({
|
|
137
|
+
canMonitor: ({ source }) => source.data.type === dragType,
|
|
138
|
+
|
|
139
|
+
onDragStart: ({ source, location }) => {
|
|
140
|
+
// Only the list is a drop target, so a drag carried anywhere else on
|
|
141
|
+
// the page has none — and a native drag with no target under it falls
|
|
142
|
+
// back to the browser's own drop effect, which is `copy` and draws the
|
|
143
|
+
// green plus cursor. This holds it at `move` for the whole drag.
|
|
144
|
+
preventUnhandled.start()
|
|
145
|
+
const items = readItems()
|
|
146
|
+
const from = items.indexOf(source.element as HTMLElement)
|
|
147
|
+
if (from < 0) return
|
|
148
|
+
const gap = Number.parseFloat(getComputedStyle(list).rowGap) || 0
|
|
149
|
+
// The list's own top is the frame of reference for the whole drag, so
|
|
150
|
+
// that auto-scrolling under the pointer changes nothing but the origin.
|
|
151
|
+
const origin = list.getBoundingClientRect().top
|
|
152
|
+
drag = {
|
|
153
|
+
...measureSlots(items, from, gap, origin),
|
|
154
|
+
items,
|
|
155
|
+
to: from,
|
|
156
|
+
startY: location.current.input.clientY - origin,
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
onDrag: ({ location }) => {
|
|
161
|
+
const current = drag
|
|
162
|
+
if (!current) return
|
|
163
|
+
// Re-read the origin every frame: auto-scroll moves the list under a
|
|
164
|
+
// pointer that has not moved, and the item has to keep up with it.
|
|
165
|
+
const offset = location.current.input.clientY - list.getBoundingClientRect().top - current.startY
|
|
166
|
+
|
|
167
|
+
// Vertical only, the way `restrictToVerticalAxis` pinned it, and with
|
|
168
|
+
// no transition: this one tracks the pointer exactly.
|
|
169
|
+
const dragged = current.items[current.from]
|
|
170
|
+
dragged.style.transition = 'none'
|
|
171
|
+
dragged.style.transform = `translateY(${offset}px)`
|
|
172
|
+
|
|
173
|
+
const to = resolveSlot(current, current.to, offset)
|
|
174
|
+
// Re-applying an unchanged shift would restart its transition every
|
|
175
|
+
// frame, so the items would creep instead of sliding.
|
|
176
|
+
if (to === current.to) return
|
|
177
|
+
current.to = to
|
|
178
|
+
|
|
179
|
+
current.items.forEach((item, index) => {
|
|
180
|
+
if (index === current.from) return
|
|
181
|
+
const shift = shiftFor(current, index, to)
|
|
182
|
+
item.style.transition = `transform ${SHIFT_MS}ms ${EASING}`
|
|
183
|
+
item.style.transform = shift ? `translateY(${shift}px)` : ''
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
onDrop: ({ location }) => {
|
|
188
|
+
preventUnhandled.stop()
|
|
189
|
+
const finished = drag
|
|
190
|
+
drag = null
|
|
191
|
+
if (!finished) return
|
|
192
|
+
|
|
193
|
+
// Read where everything IS, transforms and all, before letting go of
|
|
194
|
+
// them — this is what the drop animation plays back from.
|
|
195
|
+
flipFrom.current = finished.items.map(item => [item, item.getBoundingClientRect().top] as const)
|
|
196
|
+
if (flipTimer.current) clearTimeout(flipTimer.current)
|
|
197
|
+
for (const item of finished.items) {
|
|
198
|
+
item.style.transition = 'none'
|
|
199
|
+
item.style.transform = ''
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Empty means released outside the list, or cancelled with Escape —
|
|
203
|
+
// the item still has to travel back to its own slot, so the animation
|
|
204
|
+
// is scheduled either way.
|
|
205
|
+
const cancelled = location.current.dropTargets.length === 0
|
|
206
|
+
setFlipTick(tick => tick + 1)
|
|
207
|
+
if (!cancelled) reorder(finished.from, finished.to)
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
}, [dragType, readItems, reorder])
|
|
212
|
+
|
|
213
|
+
// Runs after the commit that carries BOTH the reorder and the tick above, so
|
|
214
|
+
// the DOM is already in its final order — which is why `onReorder` never has
|
|
215
|
+
// to wait for an animation. Every item that ended up somewhere other than
|
|
216
|
+
// where it looked is put back there and released; the items that stood aside
|
|
217
|
+
// during the drag are already in place and come out with a delta of zero, so
|
|
218
|
+
// this animates the dropped item and nothing else.
|
|
219
|
+
useIsomorphicLayoutEffect(() => {
|
|
220
|
+
const from = flipFrom.current
|
|
221
|
+
flipFrom.current = null
|
|
222
|
+
if (!from) return
|
|
223
|
+
|
|
224
|
+
const moving: HTMLElement[] = []
|
|
225
|
+
for (const [item, top] of from) {
|
|
226
|
+
const delta = top - item.getBoundingClientRect().top
|
|
227
|
+
if (!delta) continue
|
|
228
|
+
item.style.transition = 'none'
|
|
229
|
+
item.style.transform = `translateY(${delta}px)`
|
|
230
|
+
moving.push(item)
|
|
231
|
+
}
|
|
232
|
+
if (moving.length === 0) return
|
|
233
|
+
|
|
234
|
+
// One forced reflow, so the browser takes the inverted position as the
|
|
235
|
+
// animation's starting point instead of coalescing it away unseen.
|
|
236
|
+
void listRef.current?.offsetHeight
|
|
237
|
+
|
|
238
|
+
for (const item of moving) {
|
|
239
|
+
item.style.transition = `transform ${DROP_MS}ms ${EASING}`
|
|
240
|
+
item.style.transform = ''
|
|
241
|
+
}
|
|
242
|
+
flipTimer.current = setTimeout(() => {
|
|
243
|
+
for (const item of moving) item.style.transition = ''
|
|
244
|
+
}, DROP_MS)
|
|
245
|
+
}, [flipTick])
|
|
246
|
+
|
|
247
|
+
useEffect(
|
|
248
|
+
() => () => {
|
|
249
|
+
if (flipTimer.current) clearTimeout(flipTimer.current)
|
|
250
|
+
},
|
|
251
|
+
[],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const context = useMemo<SortableListContextValue>(
|
|
255
|
+
() => ({ dragType, disabled, reorder, readItems }),
|
|
256
|
+
[dragType, disabled, reorder, readItems],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<SortableListContext.Provider value={context}>
|
|
261
|
+
<div ref={listRef} className={className}>
|
|
262
|
+
{children}
|
|
263
|
+
{/* Absolutely positioned, so it is not a flex item and adds no gap. */}
|
|
264
|
+
<p aria-live="polite" className="sr-only">
|
|
265
|
+
{announcement}
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</SortableListContext.Provider>
|
|
269
|
+
)
|
|
270
|
+
}
|