forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useState} from 'react'
2import {View} from 'react-native'
3
4import {useTheme} from '#/alf'
5import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
6
7/**
8 * Web implementation of SortableList using pointer events.
9 * See SortableList.tsx for the native version using gesture-handler + Reanimated.
10 */
11
12interface SortableListProps<T> {
13 data: T[]
14 keyExtractor: (item: T) => string
15 renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
16 onReorder: (data: T[]) => void
17 onDragStart?: () => void
18 onDragEnd?: () => void
19 /** Fixed row height used for position math. */
20 itemHeight: number
21}
22
23export function SortableList<T>({
24 data,
25 keyExtractor,
26 renderItem,
27 onReorder,
28 onDragStart,
29 onDragEnd,
30 itemHeight,
31}: SortableListProps<T>) {
32 const t = useTheme()
33 const [dragState, setDragState] = useState<{
34 activeIndex: number
35 currentY: number
36 startY: number
37 } | null>(null)
38
39 const getNewPosition = (state: {
40 activeIndex: number
41 currentY: number
42 startY: number
43 }) => {
44 const translationY = state.currentY - state.startY
45 const rawNewPos = Math.round(
46 (state.activeIndex * itemHeight + translationY) / itemHeight,
47 )
48 return Math.max(0, Math.min(rawNewPos, data.length - 1))
49 }
50
51 const handlePointerMove = (e: React.PointerEvent) => {
52 if (!dragState) return
53 e.preventDefault()
54 setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null))
55 }
56
57 const handlePointerUp = () => {
58 if (!dragState) return
59 const newPos = getNewPosition(dragState)
60 if (newPos !== dragState.activeIndex) {
61 const next = [...data]
62 const [moved] = next.splice(dragState.activeIndex, 1)
63 next.splice(newPos, 0, moved)
64 onReorder(next)
65 }
66 setDragState(null)
67 onDragEnd?.()
68 }
69
70 const handlePointerDown = (e: React.PointerEvent, index: number) => {
71 e.preventDefault()
72 ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
73 setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY})
74 onDragStart?.()
75 }
76
77 const newPos = dragState ? getNewPosition(dragState) : -1
78
79 return (
80 <View
81 style={[
82 {height: data.length * itemHeight, position: 'relative'},
83 t.atoms.bg_contrast_25,
84 ]}
85 // @ts-expect-error web-only pointer events
86 onPointerMove={handlePointerMove}
87 onPointerUp={handlePointerUp}
88 onPointerCancel={handlePointerUp}>
89 {data.map((item, index) => {
90 const isActive = dragState?.activeIndex === index
91
92 // Clamp translation so the item stays within list bounds.
93 const rawTranslationY = isActive
94 ? dragState.currentY - dragState.startY
95 : 0
96 const translationY = isActive
97 ? Math.max(
98 -index * itemHeight,
99 Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight),
100 )
101 : 0
102
103 // Non-dragged items shift to make room for the dragged item.
104 let offset = 0
105 if (dragState && !isActive) {
106 const orig = dragState.activeIndex
107 if (orig < newPos && index > orig && index <= newPos) {
108 offset = -itemHeight
109 } else if (orig > newPos && index < orig && index >= newPos) {
110 offset = itemHeight
111 }
112 }
113
114 const dragHandle = (
115 <div
116 onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
117 handlePointerDown(e, index)
118 }
119 style={{
120 display: 'flex',
121 justifyContent: 'center',
122 alignItems: 'center',
123 paddingLeft: 8,
124 paddingRight: 8,
125 paddingTop: 12,
126 paddingBottom: 12,
127 cursor: isActive ? 'grabbing' : 'grab',
128 touchAction: 'none',
129 userSelect: 'none',
130 }}>
131 <GripIcon
132 size="lg"
133 fill={t.atoms.text_contrast_medium.color}
134 style={{pointerEvents: 'none'} as any}
135 />
136 </div>
137 )
138
139 return (
140 <View
141 key={keyExtractor(item)}
142 style={[
143 {
144 position: 'absolute',
145 top: index * itemHeight,
146 left: 0,
147 right: 0,
148 height: itemHeight,
149 transform: [{translateY: isActive ? translationY : offset}],
150 scale: isActive ? 1.03 : 1,
151 zIndex: isActive ? 999 : 0,
152 boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
153 // Animate scale/shadow on pickup, and transform for
154 // non-dragged items shifting into place.
155 transition: isActive
156 ? 'box-shadow 200ms ease, scale 200ms ease'
157 : dragState
158 ? 'transform 200ms ease'
159 : 'none',
160 } as any,
161 ]}>
162 {renderItem(item, dragHandle)}
163 </View>
164 )
165 })}
166 </View>
167 )
168}