@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.
Files changed (163) hide show
  1. package/dist/{chunk-5756Q6UN.js → chunk-25YLMIWU.js} +2 -2
  2. package/dist/{chunk-FLSEJSTN.js → chunk-26HAJBSQ.js} +2 -2
  3. package/dist/{chunk-JDUPAHJR.cjs → chunk-33FOBAWS.cjs} +11 -11
  4. package/dist/{chunk-JDUPAHJR.cjs.map → chunk-33FOBAWS.cjs.map} +1 -1
  5. package/dist/{chunk-CRGIXNUE.cjs → chunk-4EGNYOZA.cjs} +20 -20
  6. package/dist/{chunk-CRGIXNUE.cjs.map → chunk-4EGNYOZA.cjs.map} +1 -1
  7. package/dist/{chunk-SXMJLNOF.cjs → chunk-4WREPWRL.cjs} +3 -3
  8. package/dist/{chunk-SXMJLNOF.cjs.map → chunk-4WREPWRL.cjs.map} +1 -1
  9. package/dist/{chunk-LZD3VSPS.cjs → chunk-5HNZTPAB.cjs} +9 -9
  10. package/dist/{chunk-LZD3VSPS.cjs.map → chunk-5HNZTPAB.cjs.map} +1 -1
  11. package/dist/{chunk-HYABNMBN.cjs → chunk-5JF6H7OJ.cjs} +62 -62
  12. package/dist/{chunk-HYABNMBN.cjs.map → chunk-5JF6H7OJ.cjs.map} +1 -1
  13. package/dist/{chunk-BNMKHG2L.cjs → chunk-64HHDGRN.cjs} +4 -4
  14. package/dist/{chunk-BNMKHG2L.cjs.map → chunk-64HHDGRN.cjs.map} +1 -1
  15. package/dist/{chunk-Z3R3LOC5.cjs → chunk-6YXEZCVG.cjs} +5 -5
  16. package/dist/{chunk-Z3R3LOC5.cjs.map → chunk-6YXEZCVG.cjs.map} +1 -1
  17. package/dist/{chunk-BZKUID4R.cjs → chunk-6ZOVSD4M.cjs} +14 -14
  18. package/dist/{chunk-BZKUID4R.cjs.map → chunk-6ZOVSD4M.cjs.map} +1 -1
  19. package/dist/{chunk-Q5SZ2QTZ.cjs → chunk-A6TAVIBX.cjs} +39 -39
  20. package/dist/{chunk-Q5SZ2QTZ.cjs.map → chunk-A6TAVIBX.cjs.map} +1 -1
  21. package/dist/{chunk-ICG7JRYT.cjs → chunk-BE6TRUKY.cjs} +31 -31
  22. package/dist/{chunk-ICG7JRYT.cjs.map → chunk-BE6TRUKY.cjs.map} +1 -1
  23. package/dist/{chunk-JRWOHH7K.js → chunk-DRNSIK3V.js} +2 -2
  24. package/dist/{chunk-VF4DCOPD.js → chunk-FXVMRZIQ.js} +4 -4
  25. package/dist/{chunk-XGBVHQP7.js → chunk-G3NBTL4I.js} +3244 -2555
  26. package/dist/chunk-G3NBTL4I.js.map +1 -0
  27. package/dist/{chunk-JXJ4FTKN.js → chunk-G7ROZAY5.js} +2 -2
  28. package/dist/{chunk-S4LL6QI6.cjs → chunk-IPO24G7U.cjs} +1630 -941
  29. package/dist/chunk-IPO24G7U.cjs.map +1 -0
  30. package/dist/{chunk-5M4PS3XX.js → chunk-LJWTXODF.js} +2 -2
  31. package/dist/{chunk-CGUSNV6R.js → chunk-NNGQ6ONX.js} +6 -6
  32. package/dist/{chunk-HOEOGFQM.cjs → chunk-QCBBX6DO.cjs} +86 -86
  33. package/dist/{chunk-HOEOGFQM.cjs.map → chunk-QCBBX6DO.cjs.map} +1 -1
  34. package/dist/{chunk-VXNMOGRP.js → chunk-RPCY2FAC.js} +4 -4
  35. package/dist/{chunk-WX5UCLL7.js → chunk-RRWWAH37.js} +2 -2
  36. package/dist/{chunk-5JWLZJK5.js → chunk-SBKX2VSG.js} +2 -2
  37. package/dist/{chunk-5EES5HD4.js → chunk-UTEQHK3H.js} +5 -5
  38. package/dist/{chunk-7Q7GFIFB.cjs → chunk-V5EYGEX7.cjs} +7 -7
  39. package/dist/{chunk-7Q7GFIFB.cjs.map → chunk-V5EYGEX7.cjs.map} +1 -1
  40. package/dist/{chunk-YM2WHYYK.js → chunk-VJL46NHW.js} +2 -2
  41. package/dist/{chunk-FZT7BYNF.js → chunk-Y6TXTBIQ.js} +2 -2
  42. package/dist/{chunk-C74PZU6M.cjs → chunk-YHRE4JBF.cjs} +26 -26
  43. package/dist/{chunk-C74PZU6M.cjs.map → chunk-YHRE4JBF.cjs.map} +1 -1
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/board/aim.d.ts +57 -0
  57. package/dist/components/features/board/aim.d.ts.map +1 -0
  58. package/dist/components/features/board/board-column-header.d.ts.map +1 -1
  59. package/dist/components/features/board/board-column.d.ts +8 -4
  60. package/dist/components/features/board/board-column.d.ts.map +1 -1
  61. package/dist/components/features/board/board.d.ts +15 -4
  62. package/dist/components/features/board/board.d.ts.map +1 -1
  63. package/dist/components/features/board/drop-aim.d.ts +34 -0
  64. package/dist/components/features/board/drop-aim.d.ts.map +1 -0
  65. package/dist/components/features/board/lane-geometry.d.ts +22 -0
  66. package/dist/components/features/board/lane-geometry.d.ts.map +1 -0
  67. package/dist/components/features/board/pending-move.d.ts +77 -0
  68. package/dist/components/features/board/pending-move.d.ts.map +1 -0
  69. package/dist/components/features/board/resolve-drop.d.ts +19 -0
  70. package/dist/components/features/board/resolve-drop.d.ts.map +1 -0
  71. package/dist/components/features/board/ticket-card.d.ts +25 -9
  72. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  73. package/dist/components/features/board/use-board-collapse.d.ts.map +1 -1
  74. package/dist/components/features/board/use-lane-scroll-anchor.d.ts +27 -0
  75. package/dist/components/features/board/use-lane-scroll-anchor.d.ts.map +1 -0
  76. package/dist/components/features/index.cjs +8 -2
  77. package/dist/components/features/index.cjs.map +1 -1
  78. package/dist/components/features/index.d.ts +1 -0
  79. package/dist/components/features/index.d.ts.map +1 -1
  80. package/dist/components/features/index.js +7 -1
  81. package/dist/components/features/sortable-list/index.d.ts +4 -0
  82. package/dist/components/features/sortable-list/index.d.ts.map +1 -0
  83. package/dist/components/features/sortable-list/slot-geometry.d.ts +57 -0
  84. package/dist/components/features/sortable-list/slot-geometry.d.ts.map +1 -0
  85. package/dist/components/features/sortable-list/sortable-list.d.ts +50 -0
  86. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -0
  87. package/dist/components/features/sortable-list/use-sortable-item.d.ts +29 -0
  88. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -0
  89. package/dist/components/help-center-pages/index.cjs +22 -22
  90. package/dist/components/help-center-pages/index.js +13 -13
  91. package/dist/components/index.cjs +137 -131
  92. package/dist/components/index.cjs.map +1 -1
  93. package/dist/components/index.js +17 -11
  94. package/dist/components/index.js.map +1 -1
  95. package/dist/components/meeting-scheduler/index.cjs +28 -28
  96. package/dist/components/meeting-scheduler/index.js +3 -3
  97. package/dist/components/navigation/index.cjs +2 -2
  98. package/dist/components/navigation/index.js +1 -1
  99. package/dist/components/onboarding-guides/index.cjs +5 -5
  100. package/dist/components/onboarding-guides/index.js +4 -4
  101. package/dist/components/related-content/index.cjs +3 -3
  102. package/dist/components/related-content/index.js +2 -2
  103. package/dist/components/tickets/index.cjs +6 -6
  104. package/dist/components/tickets/index.js +5 -5
  105. package/dist/components/ui/index.cjs +2 -2
  106. package/dist/components/ui/index.js +1 -1
  107. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.cjs.map +1 -1
  110. package/dist/index.js +7 -1
  111. package/dist/utils/auto-scroll-ancestors.d.ts +43 -0
  112. package/dist/utils/auto-scroll-ancestors.d.ts.map +1 -0
  113. package/dist/utils/drag-effect.d.ts +21 -0
  114. package/dist/utils/drag-effect.d.ts.map +1 -0
  115. package/dist/utils/scroll-parent.d.ts +11 -0
  116. package/dist/utils/scroll-parent.d.ts.map +1 -0
  117. package/package.json +6 -3
  118. package/src/__tests__/board-aim.test.ts +152 -0
  119. package/src/__tests__/board-pending-move.test.ts +113 -0
  120. package/src/__tests__/board-resolve-drop.test.ts +68 -0
  121. package/src/__tests__/sortable-slot-geometry.test.ts +105 -0
  122. package/src/components/features/board/.board-column.md +17 -4
  123. package/src/components/features/board/.board.md +11 -7
  124. package/src/components/features/board/.ticket-card.md +27 -5
  125. package/src/components/features/board/aim.ts +131 -0
  126. package/src/components/features/board/board-column-header.tsx +2 -9
  127. package/src/components/features/board/board-column.tsx +137 -66
  128. package/src/components/features/board/board.tsx +501 -258
  129. package/src/components/features/board/drop-aim.ts +50 -0
  130. package/src/components/features/board/lane-geometry.ts +44 -0
  131. package/src/components/features/board/pending-move.ts +116 -0
  132. package/src/components/features/board/resolve-drop.ts +28 -0
  133. package/src/components/features/board/ticket-card.tsx +256 -47
  134. package/src/components/features/board/use-board-collapse.ts +16 -14
  135. package/src/components/features/board/use-lane-scroll-anchor.ts +79 -0
  136. package/src/components/features/index.ts +1 -0
  137. package/src/components/features/sortable-list/.sortable-list.md +82 -0
  138. package/src/components/features/sortable-list/index.ts +3 -0
  139. package/src/components/features/sortable-list/slot-geometry.ts +100 -0
  140. package/src/components/features/sortable-list/sortable-list.tsx +270 -0
  141. package/src/components/features/sortable-list/use-sortable-item.ts +92 -0
  142. package/src/components/ui/square-avatar.tsx +5 -0
  143. package/src/stories/.TicketCard.stories.md +6 -12
  144. package/src/stories/Board.stories.tsx +16 -7
  145. package/src/stories/TicketCard.stories.tsx +38 -76
  146. package/src/utils/auto-scroll-ancestors.ts +90 -0
  147. package/src/utils/drag-effect.ts +26 -0
  148. package/src/utils/scroll-parent.ts +23 -0
  149. package/dist/chunk-S4LL6QI6.cjs.map +0 -1
  150. package/dist/chunk-XGBVHQP7.js.map +0 -1
  151. /package/dist/{chunk-5756Q6UN.js.map → chunk-25YLMIWU.js.map} +0 -0
  152. /package/dist/{chunk-FLSEJSTN.js.map → chunk-26HAJBSQ.js.map} +0 -0
  153. /package/dist/{chunk-JRWOHH7K.js.map → chunk-DRNSIK3V.js.map} +0 -0
  154. /package/dist/{chunk-VF4DCOPD.js.map → chunk-FXVMRZIQ.js.map} +0 -0
  155. /package/dist/{chunk-JXJ4FTKN.js.map → chunk-G7ROZAY5.js.map} +0 -0
  156. /package/dist/{chunk-5M4PS3XX.js.map → chunk-LJWTXODF.js.map} +0 -0
  157. /package/dist/{chunk-CGUSNV6R.js.map → chunk-NNGQ6ONX.js.map} +0 -0
  158. /package/dist/{chunk-VXNMOGRP.js.map → chunk-RPCY2FAC.js.map} +0 -0
  159. /package/dist/{chunk-WX5UCLL7.js.map → chunk-RRWWAH37.js.map} +0 -0
  160. /package/dist/{chunk-5JWLZJK5.js.map → chunk-SBKX2VSG.js.map} +0 -0
  161. /package/dist/{chunk-5EES5HD4.js.map → chunk-UTEQHK3H.js.map} +0 -0
  162. /package/dist/{chunk-YM2WHYYK.js.map → chunk-VJL46NHW.js.map} +0 -0
  163. /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
- const toggle = useCallback(
25
- (columnId: string) => {
26
- setMap(prev => ({ ...prev, [columnId]: !prev[columnId] }))
27
- },
28
- [setMap],
29
- )
30
-
31
- const setCollapsed = useCallback(
32
- (columnId: string, value: boolean) => {
33
- setMap(prev => ({ ...prev, [columnId]: value }))
34
- },
35
- [setMap],
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
+ }
@@ -93,4 +93,5 @@ export * from './waitlist-form'
93
93
  export * from './notifications'
94
94
  export * from './time-tracker'
95
95
  export * from './board'
96
+ export * from './sortable-list'
96
97
  export * from './ticket-status-config-list'
@@ -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,3 @@
1
+ export { SLOT_STICKINESS } from './slot-geometry'
2
+ export { SortableList, type SortableListProps } from './sortable-list'
3
+ export { type SortableItem, useSortableItem } from './use-sortable-item'
@@ -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
+ }