@cdx-ui/components 0.2.1 → 0.2.3

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 (158) hide show
  1. package/README.md +4 -2
  2. package/coverage.svg +1 -1
  3. package/lib/commonjs/components/Button/styles.js +7 -2
  4. package/lib/commonjs/components/Button/styles.js.map +1 -1
  5. package/lib/commonjs/components/Checkbox/index.js.map +1 -1
  6. package/lib/commonjs/components/Field/index.js +1 -1
  7. package/lib/commonjs/components/Field/index.js.map +1 -1
  8. package/lib/commonjs/components/Heading/index.js.map +1 -1
  9. package/lib/commonjs/components/Icon/index.js.map +1 -1
  10. package/lib/commonjs/components/ListItem/index.js +170 -9
  11. package/lib/commonjs/components/ListItem/index.js.map +1 -1
  12. package/lib/commonjs/components/ListItem/styles.js +36 -1
  13. package/lib/commonjs/components/ListItem/styles.js.map +1 -1
  14. package/lib/commonjs/components/Menu/index.js.map +1 -1
  15. package/lib/commonjs/components/RangeCalendar/index.js.map +1 -1
  16. package/lib/commonjs/components/ReorderableList/ReorderableListShared.js +91 -0
  17. package/lib/commonjs/components/ReorderableList/ReorderableListShared.js.map +1 -0
  18. package/lib/commonjs/components/ReorderableList/index.js +135 -0
  19. package/lib/commonjs/components/ReorderableList/index.js.map +1 -0
  20. package/lib/commonjs/components/ReorderableList/index.web.js +233 -0
  21. package/lib/commonjs/components/ReorderableList/index.web.js.map +1 -0
  22. package/lib/commonjs/components/ReorderableList/labels.js +47 -0
  23. package/lib/commonjs/components/ReorderableList/labels.js.map +1 -0
  24. package/lib/commonjs/components/ReorderableList/reorderItems.js +16 -0
  25. package/lib/commonjs/components/ReorderableList/reorderItems.js.map +1 -0
  26. package/lib/commonjs/components/ReorderableList/rowContext.js +42 -0
  27. package/lib/commonjs/components/ReorderableList/rowContext.js.map +1 -0
  28. package/lib/commonjs/components/ReorderableList/types.js +6 -0
  29. package/lib/commonjs/components/ReorderableList/types.js.map +1 -0
  30. package/lib/commonjs/components/ReorderableList/useReorder.js +131 -0
  31. package/lib/commonjs/components/ReorderableList/useReorder.js.map +1 -0
  32. package/lib/commonjs/components/ReorderableList/webDnd.js +64 -0
  33. package/lib/commonjs/components/ReorderableList/webDnd.js.map +1 -0
  34. package/lib/commonjs/components/ScopedTheme/index.js.map +1 -1
  35. package/lib/commonjs/components/Skeleton/index.js.map +1 -1
  36. package/lib/commonjs/components/TablePagination/index.js.map +1 -1
  37. package/lib/commonjs/components/Text/index.js.map +1 -1
  38. package/lib/commonjs/components/Tile/styles.js +1 -1
  39. package/lib/commonjs/components/Tile/styles.js.map +1 -1
  40. package/lib/commonjs/components/index.js.map +1 -1
  41. package/lib/commonjs/reorderable-list.js +17 -0
  42. package/lib/commonjs/reorderable-list.js.map +1 -0
  43. package/lib/commonjs/styles.css +18 -0
  44. package/lib/module/components/Button/styles.js +7 -2
  45. package/lib/module/components/Button/styles.js.map +1 -1
  46. package/lib/module/components/Checkbox/index.js.map +1 -1
  47. package/lib/module/components/Field/index.js +1 -1
  48. package/lib/module/components/Field/index.js.map +1 -1
  49. package/lib/module/components/Heading/index.js.map +1 -1
  50. package/lib/module/components/Icon/index.js.map +1 -1
  51. package/lib/module/components/ListItem/index.js +163 -14
  52. package/lib/module/components/ListItem/index.js.map +1 -1
  53. package/lib/module/components/ListItem/styles.js +35 -0
  54. package/lib/module/components/ListItem/styles.js.map +1 -1
  55. package/lib/module/components/Menu/index.js.map +1 -1
  56. package/lib/module/components/RangeCalendar/index.js.map +1 -1
  57. package/lib/module/components/ReorderableList/ReorderableListShared.js +86 -0
  58. package/lib/module/components/ReorderableList/ReorderableListShared.js.map +1 -0
  59. package/lib/module/components/ReorderableList/index.js +130 -0
  60. package/lib/module/components/ReorderableList/index.js.map +1 -0
  61. package/lib/module/components/ReorderableList/index.web.js +229 -0
  62. package/lib/module/components/ReorderableList/index.web.js.map +1 -0
  63. package/lib/module/components/ReorderableList/labels.js +41 -0
  64. package/lib/module/components/ReorderableList/labels.js.map +1 -0
  65. package/lib/module/components/ReorderableList/reorderItems.js +12 -0
  66. package/lib/module/components/ReorderableList/reorderItems.js.map +1 -0
  67. package/lib/module/components/ReorderableList/rowContext.js +38 -0
  68. package/lib/module/components/ReorderableList/rowContext.js.map +1 -0
  69. package/lib/module/components/ReorderableList/types.js +4 -0
  70. package/lib/module/components/ReorderableList/types.js.map +1 -0
  71. package/lib/module/components/ReorderableList/useReorder.js +123 -0
  72. package/lib/module/components/ReorderableList/useReorder.js.map +1 -0
  73. package/lib/module/components/ReorderableList/webDnd.js +59 -0
  74. package/lib/module/components/ReorderableList/webDnd.js.map +1 -0
  75. package/lib/module/components/ScopedTheme/index.js.map +1 -1
  76. package/lib/module/components/Skeleton/index.js.map +1 -1
  77. package/lib/module/components/TablePagination/index.js.map +1 -1
  78. package/lib/module/components/Text/index.js.map +1 -1
  79. package/lib/module/components/Tile/styles.js +1 -1
  80. package/lib/module/components/Tile/styles.js.map +1 -1
  81. package/lib/module/components/index.js.map +1 -1
  82. package/lib/module/reorderable-list.js +4 -0
  83. package/lib/module/reorderable-list.js.map +1 -0
  84. package/lib/module/styles.css +18 -0
  85. package/lib/typescript/components/Button/styles.d.ts.map +1 -1
  86. package/lib/typescript/components/Checkbox/index.d.ts +22 -0
  87. package/lib/typescript/components/Checkbox/index.d.ts.map +1 -1
  88. package/lib/typescript/components/Heading/index.d.ts +20 -2
  89. package/lib/typescript/components/Heading/index.d.ts.map +1 -1
  90. package/lib/typescript/components/Icon/index.d.ts +2 -0
  91. package/lib/typescript/components/Icon/index.d.ts.map +1 -1
  92. package/lib/typescript/components/ListItem/index.d.ts +23 -1
  93. package/lib/typescript/components/ListItem/index.d.ts.map +1 -1
  94. package/lib/typescript/components/ListItem/styles.d.ts +5 -0
  95. package/lib/typescript/components/ListItem/styles.d.ts.map +1 -1
  96. package/lib/typescript/components/Menu/index.d.ts +116 -4
  97. package/lib/typescript/components/Menu/index.d.ts.map +1 -1
  98. package/lib/typescript/components/RangeCalendar/index.d.ts +2 -0
  99. package/lib/typescript/components/RangeCalendar/index.d.ts.map +1 -1
  100. package/lib/typescript/components/ReorderableList/ReorderableListShared.d.ts +31 -0
  101. package/lib/typescript/components/ReorderableList/ReorderableListShared.d.ts.map +1 -0
  102. package/lib/typescript/components/ReorderableList/index.d.ts +8 -0
  103. package/lib/typescript/components/ReorderableList/index.d.ts.map +1 -0
  104. package/lib/typescript/components/ReorderableList/index.web.d.ts +8 -0
  105. package/lib/typescript/components/ReorderableList/index.web.d.ts.map +1 -0
  106. package/lib/typescript/components/ReorderableList/labels.d.ts +16 -0
  107. package/lib/typescript/components/ReorderableList/labels.d.ts.map +1 -0
  108. package/lib/typescript/components/ReorderableList/reorderItems.d.ts +3 -0
  109. package/lib/typescript/components/ReorderableList/reorderItems.d.ts.map +1 -0
  110. package/lib/typescript/components/ReorderableList/rowContext.d.ts +57 -0
  111. package/lib/typescript/components/ReorderableList/rowContext.d.ts.map +1 -0
  112. package/lib/typescript/components/ReorderableList/types.d.ts +75 -0
  113. package/lib/typescript/components/ReorderableList/types.d.ts.map +1 -0
  114. package/lib/typescript/components/ReorderableList/useReorder.d.ts +31 -0
  115. package/lib/typescript/components/ReorderableList/useReorder.d.ts.map +1 -0
  116. package/lib/typescript/components/ReorderableList/webDnd.d.ts +27 -0
  117. package/lib/typescript/components/ReorderableList/webDnd.d.ts.map +1 -0
  118. package/lib/typescript/components/ScopedTheme/index.d.ts +6 -1
  119. package/lib/typescript/components/ScopedTheme/index.d.ts.map +1 -1
  120. package/lib/typescript/components/Skeleton/index.d.ts +3 -0
  121. package/lib/typescript/components/Skeleton/index.d.ts.map +1 -1
  122. package/lib/typescript/components/TablePagination/index.d.ts +54 -0
  123. package/lib/typescript/components/TablePagination/index.d.ts.map +1 -1
  124. package/lib/typescript/components/Text/index.d.ts +12 -2
  125. package/lib/typescript/components/Text/index.d.ts.map +1 -1
  126. package/lib/typescript/components/Tile/styles.d.ts.map +1 -1
  127. package/lib/typescript/components/ToggleButton/styles.d.ts +1 -1
  128. package/lib/typescript/components/index.d.ts +1 -0
  129. package/lib/typescript/components/index.d.ts.map +1 -1
  130. package/lib/typescript/reorderable-list.d.ts +2 -0
  131. package/lib/typescript/reorderable-list.d.ts.map +1 -0
  132. package/package.json +34 -7
  133. package/src/components/Button/styles.ts +7 -1
  134. package/src/components/Checkbox/index.tsx +22 -0
  135. package/src/components/Field/index.tsx +1 -1
  136. package/src/components/Heading/index.tsx +10 -1
  137. package/src/components/Icon/index.tsx +2 -0
  138. package/src/components/ListItem/index.tsx +227 -11
  139. package/src/components/ListItem/styles.ts +40 -0
  140. package/src/components/Menu/index.tsx +117 -4
  141. package/src/components/RangeCalendar/index.tsx +2 -0
  142. package/src/components/ReorderableList/ReorderableListShared.tsx +113 -0
  143. package/src/components/ReorderableList/index.tsx +162 -0
  144. package/src/components/ReorderableList/index.web.tsx +254 -0
  145. package/src/components/ReorderableList/labels.ts +52 -0
  146. package/src/components/ReorderableList/reorderItems.ts +16 -0
  147. package/src/components/ReorderableList/rowContext.tsx +72 -0
  148. package/src/components/ReorderableList/types.ts +80 -0
  149. package/src/components/ReorderableList/useReorder.ts +159 -0
  150. package/src/components/ReorderableList/webDnd.ts +60 -0
  151. package/src/components/ScopedTheme/index.tsx +6 -1
  152. package/src/components/Skeleton/index.tsx +3 -0
  153. package/src/components/TablePagination/index.tsx +54 -0
  154. package/src/components/Text/index.tsx +6 -1
  155. package/src/components/Tile/styles.ts +4 -1
  156. package/src/components/index.ts +5 -0
  157. package/src/reorderable-list.ts +1 -0
  158. package/src/styles.css +18 -0
@@ -0,0 +1,113 @@
1
+ import {
2
+ Fragment,
3
+ forwardRef,
4
+ useEffect,
5
+ useMemo,
6
+ type ForwardedRef,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { ScrollView, StyleSheet, type ScrollViewProps, type ViewStyle } from 'react-native';
11
+ import { dataAttributes, mergeDataAttributes } from '@cdx-ui/primitives';
12
+ import { findDuplicateKeys } from './useReorder';
13
+
14
+ export interface ReorderableRowInfo<T> {
15
+ item: T;
16
+ /** Consumer key from `keyExtractor`. */
17
+ key: string;
18
+ /** Position of `item` in `data`. */
19
+ index: number;
20
+ count: number;
21
+ }
22
+
23
+ export interface ReorderableListSharedProps<T> extends Omit<ScrollViewProps, 'children'> {
24
+ data: T[];
25
+ keyExtractor: (item: T, index: number) => string;
26
+ /** Renders each row. Ignored when `children` is set. */
27
+ renderRow?: (info: ReorderableRowInfo<T>) => ReactNode;
28
+ /** Replaces the per-row rendering (native renders the engine's own container here). */
29
+ children?: ReactNode;
30
+ /** Scroll host. Defaults to `ScrollView`. */
31
+ ScrollComponent?: typeof ScrollView;
32
+ className?: string;
33
+ }
34
+
35
+ let hasWarnedDuplicateKeys = false;
36
+
37
+ const styles = StyleSheet.create({
38
+ root: { flexGrow: 0, minHeight: 0 },
39
+ content: { flexGrow: 0 },
40
+ });
41
+
42
+ /**
43
+ * Longhands for a consumer `flex`, placed between the `flexGrow: 0` default and the consumer style.
44
+ * Yoga lets a longhand beat the shorthand, so `flex: 1` alone keeps grow 0 with basis 0 and the
45
+ * list collapses to zero height.
46
+ */
47
+ export function flexLonghands(flex: number | undefined): ViewStyle | undefined {
48
+ if (flex === undefined) return undefined;
49
+ if (flex > 0) return { flexGrow: flex, flexShrink: 1, flexBasis: 0 };
50
+ if (flex < 0) return { flexGrow: 0, flexShrink: -flex };
51
+ return { flexGrow: 0, flexShrink: 0 };
52
+ }
53
+
54
+ function ReorderableListSharedInner<T>(
55
+ {
56
+ data,
57
+ keyExtractor,
58
+ renderRow,
59
+ children,
60
+ ScrollComponent = ScrollView,
61
+ style,
62
+ contentContainerStyle,
63
+ ...props
64
+ }: ReorderableListSharedProps<T>,
65
+ ref: ForwardedRef<ScrollView>,
66
+ ) {
67
+ const keyToIndex = useMemo(() => {
68
+ const map = new Map<string, number>();
69
+ data.forEach((item, index) => map.set(keyExtractor(item, index), index));
70
+ return map;
71
+ }, [data, keyExtractor]);
72
+
73
+ useEffect(() => {
74
+ if (!__DEV__ || hasWarnedDuplicateKeys) return;
75
+ const duplicates = findDuplicateKeys(data, keyExtractor);
76
+ if (duplicates.length > 0) {
77
+ hasWarnedDuplicateKeys = true;
78
+ console.warn(
79
+ `[ReorderableList] keyExtractor returned duplicate keys (${duplicates.join(', ')}). Keys must be stable and unique per item — do not derive them from the index.`,
80
+ );
81
+ }
82
+ }, [data, keyExtractor]);
83
+
84
+ return (
85
+ <ScrollComponent
86
+ ref={ref}
87
+ style={[styles.root, flexLonghands(StyleSheet.flatten(style)?.flex), style]}
88
+ contentContainerStyle={[styles.content, contentContainerStyle]}
89
+ {...props}
90
+ role="list"
91
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'reorderable-list' }))}
92
+ >
93
+ {children ??
94
+ data.map((item, index) => {
95
+ const key = keyExtractor(item, index);
96
+ return (
97
+ <Fragment key={key}>
98
+ {renderRow?.({
99
+ item,
100
+ key,
101
+ index: keyToIndex.get(key) ?? index,
102
+ count: data.length,
103
+ })}
104
+ </Fragment>
105
+ );
106
+ })}
107
+ </ScrollComponent>
108
+ );
109
+ }
110
+
111
+ export const ReorderableListShared = forwardRef(ReorderableListSharedInner) as <T>(
112
+ props: ReorderableListSharedProps<T> & { ref?: ForwardedRef<ScrollView> },
113
+ ) => ReactElement | null;
@@ -0,0 +1,162 @@
1
+ import {
2
+ createContext,
3
+ forwardRef,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ type ReactElement,
9
+ type Ref,
10
+ } from 'react';
11
+ import { AccessibilityInfo, ScrollView } from 'react-native';
12
+ import Animated, { useAnimatedReaction, useAnimatedRef } from 'react-native-reanimated';
13
+ import Sortable, {
14
+ useItemContext,
15
+ type SortableGridDragEndParams,
16
+ type SortableGridRenderItemInfo,
17
+ } from 'react-native-sortables';
18
+ import { scheduleOnRN } from 'react-native-worklets';
19
+ import { mergeRefs } from '@cdx-ui/utils';
20
+ import { ReorderableListShared } from './ReorderableListShared';
21
+ import { ReorderableRowProvider, type ReorderableRowHandle } from './rowContext';
22
+ import type { ReorderableListProps } from './types';
23
+ import { useReorderableRowValue, useReorderController, type ReorderController } from './useReorder';
24
+
25
+ export type {
26
+ ReorderableListLabels,
27
+ ReorderableListProps,
28
+ ReorderableListRenderItemInfo,
29
+ } from './types';
30
+
31
+ const nativeHandle: ReorderableRowHandle = {
32
+ props: {},
33
+ ref: undefined,
34
+ wrap: (host, style) => <Sortable.Handle style={style}>{host}</Sortable.Handle>,
35
+ };
36
+
37
+ const AnimatedScrollView = Animated.ScrollView as unknown as typeof ScrollView;
38
+
39
+ interface NativeListContextValue<T> {
40
+ renderItem: ReorderableListProps<T>['renderItem'];
41
+ controller: ReorderController<T>;
42
+ }
43
+
44
+ const NativeListContext = createContext<NativeListContextValue<unknown> | null>(null);
45
+
46
+ /**
47
+ * sortables keeps rendered rows mounted and only re-renders a row when its item, index, or the
48
+ * grid's `renderItem` identity changes. The grid gets a stable `renderItem`; the consumer's
49
+ * (usually inline) `renderItem` reaches rows through context instead.
50
+ */
51
+ function NativeRow<T>({ item, index }: SortableGridRenderItemInfo<T>) {
52
+ const list = useContext(NativeListContext) as NativeListContextValue<T>;
53
+ const { isActive } = useItemContext();
54
+ const [isDragging, setIsDragging] = useState(false);
55
+
56
+ useAnimatedReaction(
57
+ () => isActive.get(),
58
+ (active, previous) => {
59
+ if (active !== (previous ?? false)) scheduleOnRN(setIsDragging, active);
60
+ },
61
+ );
62
+
63
+ const rowValue = useReorderableRowValue({
64
+ index,
65
+ count: list.controller.data.length,
66
+ isDragging,
67
+ handle: nativeHandle,
68
+ labels: list.controller.labels,
69
+ moveItem: list.controller.moveItem,
70
+ });
71
+
72
+ return (
73
+ <ReorderableRowProvider value={rowValue}>
74
+ {list.renderItem({ item, index, isDragging })}
75
+ </ReorderableRowProvider>
76
+ );
77
+ }
78
+
79
+ const renderGridItem = (info: SortableGridRenderItemInfo<unknown>) => <NativeRow {...info} />;
80
+
81
+ function ReorderableListInner<T>(
82
+ {
83
+ data,
84
+ keyExtractor,
85
+ onReorder,
86
+ onReorderIndices,
87
+ renderItem,
88
+ getItemLabel,
89
+ labels,
90
+ scrollEventThrottle = 16,
91
+ ...rest
92
+ }: ReorderableListProps<T>,
93
+ ref: Ref<ScrollView>,
94
+ ) {
95
+ const scrollableRef = useAnimatedRef<ScrollView>();
96
+ const mergedRef = useMemo(() => mergeRefs(ref, scrollableRef), [ref, scrollableRef]);
97
+
98
+ const controller = useReorderController({
99
+ data,
100
+ onReorder,
101
+ onReorderIndices,
102
+ getItemLabel,
103
+ labels,
104
+ announce: AccessibilityInfo.announceForAccessibility,
105
+ });
106
+
107
+ const listValue = useMemo(
108
+ () => ({ renderItem, controller }) as NativeListContextValue<unknown>,
109
+ [controller, renderItem],
110
+ );
111
+
112
+ const gridKeyExtractor = useMemo(() => {
113
+ const keys = new Map<T, string>();
114
+ data.forEach((item, index) => keys.set(item, keyExtractor(item, index)));
115
+ return (item: T) => keys.get(item) ?? keyExtractor(item, data.indexOf(item));
116
+ }, [data, keyExtractor]);
117
+
118
+ const handleDragEnd = useCallback(
119
+ ({ fromIndex, toIndex, data: reordered }: SortableGridDragEndParams<T>) => {
120
+ if (fromIndex === toIndex) return;
121
+ const message = controller.describe('dropped', fromIndex, toIndex);
122
+ onReorder(reordered);
123
+ onReorderIndices?.(fromIndex, toIndex);
124
+ if (message) AccessibilityInfo.announceForAccessibility(message);
125
+ },
126
+ [controller, onReorder, onReorderIndices],
127
+ );
128
+
129
+ return (
130
+ <NativeListContext.Provider value={listValue}>
131
+ <ReorderableListShared
132
+ ref={mergedRef}
133
+ ScrollComponent={AnimatedScrollView}
134
+ data={data}
135
+ keyExtractor={keyExtractor}
136
+ scrollEventThrottle={scrollEventThrottle}
137
+ {...rest}
138
+ >
139
+ <Sortable.Grid
140
+ columns={1}
141
+ data={data}
142
+ keyExtractor={gridKeyExtractor}
143
+ renderItem={renderGridItem as (info: SortableGridRenderItemInfo<T>) => ReactElement}
144
+ scrollableRef={scrollableRef}
145
+ customHandle
146
+ dragActivationDelay={0}
147
+ overDrag="vertical"
148
+ activeItemScale={1}
149
+ inactiveItemOpacity={1}
150
+ activeItemShadowOpacity={0}
151
+ onDragEnd={handleDragEnd}
152
+ />
153
+ </ReorderableListShared>
154
+ </NativeListContext.Provider>
155
+ );
156
+ }
157
+
158
+ export const ReorderableList = forwardRef(ReorderableListInner) as <T>(
159
+ props: ReorderableListProps<T> & { ref?: Ref<ScrollView> },
160
+ ) => ReactElement | null;
161
+
162
+ (ReorderableList as { displayName?: string }).displayName = 'ReorderableList';
@@ -0,0 +1,254 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type ReactElement,
11
+ type Ref,
12
+ } from 'react';
13
+ import { ScrollView, Text, View, type ViewProps, type ViewStyle } from 'react-native';
14
+ import {
15
+ DndContext,
16
+ KeyboardSensor,
17
+ PointerSensor,
18
+ closestCenter,
19
+ useDndContext,
20
+ useSensor,
21
+ useSensors,
22
+ type DragEndEvent,
23
+ } from '@dnd-kit/core';
24
+ import {
25
+ SortableContext,
26
+ sortableKeyboardCoordinates,
27
+ useSortable,
28
+ verticalListSortingStrategy,
29
+ } from '@dnd-kit/sortable';
30
+ import { CSS } from '@dnd-kit/utilities';
31
+ import { cn } from '@cdx-ui/utils';
32
+ import { ReorderableListShared, type ReorderableRowInfo } from './ReorderableListShared';
33
+ import { reorderItems } from './reorderItems';
34
+ import { ReorderableRowProvider, type ReorderableRowHandle } from './rowContext';
35
+ import type { ReorderableListProps } from './types';
36
+ import { useReorderableRowValue, useReorderController, type ReorderController } from './useReorder';
37
+ import { createReorderAnnouncements, resolveDragEnd } from './webDnd';
38
+
39
+ export type {
40
+ ReorderableListLabels,
41
+ ReorderableListProps,
42
+ ReorderableListRenderItemInfo,
43
+ } from './types';
44
+
45
+ const handleTouchStyle = { touchAction: 'none' } as ViewStyle;
46
+ const passThrough: ReorderableRowHandle['wrap'] = (host) => host;
47
+
48
+ /**
49
+ * react-native-web's `Pressable` stops Enter/Space `keydown` from bubbling, so when the handle is a
50
+ * pressable control (`asChild` with an `IconButton`) the drop key never reaches dnd-kit's document
51
+ * listener. While dragging, take the key at the handle and hand it to the document directly.
52
+ */
53
+ function forwardDropKeyToDocument(event: ReactKeyboardEvent) {
54
+ const { key, code } = event.nativeEvent;
55
+ if (key !== 'Enter' && key !== ' ' && code !== 'Enter' && code !== 'Space') return;
56
+ event.preventDefault();
57
+ event.stopPropagation();
58
+ const { ownerDocument } = event.currentTarget as unknown as Node;
59
+ ownerDocument?.dispatchEvent(new KeyboardEvent('keydown', { key, code, cancelable: true }));
60
+ }
61
+
62
+ interface SortableRowProps<T> {
63
+ info: ReorderableRowInfo<T>;
64
+ renderItem: ReorderableListProps<T>['renderItem'];
65
+ controller: ReorderController<T>;
66
+ }
67
+
68
+ function SortableRow<T>({ info, renderItem, controller }: SortableRowProps<T>) {
69
+ const { item, index, count, key } = info;
70
+ const {
71
+ attributes,
72
+ listeners,
73
+ setNodeRef,
74
+ setActivatorNodeRef,
75
+ transform,
76
+ transition,
77
+ isDragging,
78
+ } = useSortable({ id: key });
79
+ const { activatorEvent } = useDndContext();
80
+
81
+ const nodeRef = useRef<HTMLElement | null>(null);
82
+ const swallowNextClickRef = useRef(false);
83
+
84
+ const setRowRef = useCallback(
85
+ (node: View | null) => {
86
+ const element = node as unknown as HTMLElement | null;
87
+ nodeRef.current = element;
88
+ setNodeRef(element);
89
+ },
90
+ [setNodeRef],
91
+ );
92
+
93
+ // dnd-kit's shift/drop animation is written to the row's DOM node; React never owns these keys.
94
+ useLayoutEffect(() => {
95
+ const node = nodeRef.current;
96
+ if (!node) return;
97
+ node.style.transform = transform ? (CSS.Translate.toString({ ...transform, x: 0 }) ?? '') : '';
98
+ node.style.transition = transition ?? '';
99
+ }, [transform, transition]);
100
+
101
+ // A pointer drag ends with a click on the row; swallow that one click so a pressable row does
102
+ // not fire after a drop or cancel. Scoped to this row, and reset by the next pointerdown.
103
+ useEffect(() => {
104
+ if (isDragging && activatorEvent?.type.startsWith('pointer')) {
105
+ swallowNextClickRef.current = true;
106
+ }
107
+ }, [activatorEvent, isDragging]);
108
+
109
+ useEffect(() => {
110
+ const node = nodeRef.current;
111
+ if (!node) return undefined;
112
+ const onClick = (event: MouseEvent) => {
113
+ if (!swallowNextClickRef.current) return;
114
+ swallowNextClickRef.current = false;
115
+ event.stopPropagation();
116
+ event.preventDefault();
117
+ };
118
+ const onPointerDown = () => {
119
+ swallowNextClickRef.current = false;
120
+ };
121
+ node.addEventListener('click', onClick, true);
122
+ node.addEventListener('pointerdown', onPointerDown, true);
123
+ return () => {
124
+ node.removeEventListener('click', onClick, true);
125
+ node.removeEventListener('pointerdown', onPointerDown, true);
126
+ };
127
+ }, []);
128
+
129
+ const handle = useMemo<ReorderableRowHandle>(() => {
130
+ const { tabIndex, role, ...aria } = attributes;
131
+ return {
132
+ props: {
133
+ ...aria,
134
+ role: role as ViewProps['role'],
135
+ tabIndex: tabIndex === 0 || tabIndex === -1 ? tabIndex : undefined,
136
+ style: handleTouchStyle,
137
+ ...listeners,
138
+ onKeyDownCapture: isDragging ? forwardDropKeyToDocument : undefined,
139
+ },
140
+ ref: (node) => setActivatorNodeRef(node as unknown as HTMLElement | null),
141
+ wrap: passThrough,
142
+ };
143
+ }, [attributes, isDragging, listeners, setActivatorNodeRef]);
144
+
145
+ const rowValue = useReorderableRowValue({
146
+ index,
147
+ count,
148
+ isDragging,
149
+ handle,
150
+ labels: controller.labels,
151
+ moveItem: controller.moveItem,
152
+ });
153
+
154
+ return (
155
+ <ReorderableRowProvider value={rowValue}>
156
+ <View ref={setRowRef} className={cn(isDragging && 'relative z-20')}>
157
+ {renderItem({ item, index, isDragging })}
158
+ </View>
159
+ </ReorderableRowProvider>
160
+ );
161
+ }
162
+
163
+ function ReorderableListInner<T>(
164
+ {
165
+ data,
166
+ keyExtractor,
167
+ renderItem,
168
+ onReorder,
169
+ onReorderIndices,
170
+ getItemLabel,
171
+ labels,
172
+ ...rest
173
+ }: ReorderableListProps<T>,
174
+ ref: Ref<ScrollView>,
175
+ ) {
176
+ const [announcement, setAnnouncement] = useState('');
177
+
178
+ const controller = useReorderController({
179
+ data,
180
+ onReorder,
181
+ onReorderIndices,
182
+ getItemLabel,
183
+ labels,
184
+ announce: setAnnouncement,
185
+ });
186
+
187
+ const sortableIds = useMemo(
188
+ () => data.map((item, index) => keyExtractor(item, index)),
189
+ [data, keyExtractor],
190
+ );
191
+
192
+ const sensors = useSensors(
193
+ useSensor(PointerSensor, {
194
+ activationConstraint: { distance: 4 },
195
+ }),
196
+ useSensor(KeyboardSensor, {
197
+ coordinateGetter: sortableKeyboardCoordinates,
198
+ }),
199
+ );
200
+
201
+ const { describe, labels: resolvedLabels } = controller;
202
+ const accessibility = useMemo(
203
+ () => ({
204
+ announcements: createReorderAnnouncements(sortableIds, describe),
205
+ screenReaderInstructions: { draggable: resolvedLabels.screenReaderInstructions },
206
+ }),
207
+ [describe, resolvedLabels, sortableIds],
208
+ );
209
+
210
+ const handleDragEnd = useCallback(
211
+ ({ active, over }: DragEndEvent) => {
212
+ const move = resolveDragEnd(sortableIds, active.id, over?.id);
213
+ if (!move) return;
214
+ onReorder(reorderItems(data, move.from, move.to));
215
+ onReorderIndices?.(move.from, move.to);
216
+ },
217
+ [data, onReorder, onReorderIndices, sortableIds],
218
+ );
219
+
220
+ const renderRow = useCallback(
221
+ (info: ReorderableRowInfo<T>) => (
222
+ <SortableRow info={info} renderItem={renderItem} controller={controller} />
223
+ ),
224
+ [controller, renderItem],
225
+ );
226
+
227
+ return (
228
+ <DndContext
229
+ accessibility={accessibility}
230
+ collisionDetection={closestCenter}
231
+ onDragEnd={handleDragEnd}
232
+ sensors={sensors}
233
+ >
234
+ <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
235
+ <ReorderableListShared
236
+ ref={ref}
237
+ data={data}
238
+ keyExtractor={keyExtractor}
239
+ renderRow={renderRow}
240
+ {...rest}
241
+ />
242
+ </SortableContext>
243
+ <View aria-live="polite" className="sr-only">
244
+ <Text>{announcement}</Text>
245
+ </View>
246
+ </DndContext>
247
+ );
248
+ }
249
+
250
+ export const ReorderableList = forwardRef(ReorderableListInner) as <T>(
251
+ props: ReorderableListProps<T> & { ref?: Ref<ScrollView> },
252
+ ) => ReactElement | null;
253
+
254
+ (ReorderableList as { displayName?: string }).displayName = 'ReorderableList';
@@ -0,0 +1,52 @@
1
+ import type { ReorderableListLabels } from './types';
2
+
3
+ export const DEFAULT_REORDERABLE_LIST_LABELS: ReorderableListLabels = {
4
+ item: 'item {position}',
5
+ pickedUp: 'Picked up {item}. Position {position} of {count}.',
6
+ movedTo: 'Moved {item} to position {position} of {count}.',
7
+ dropped: 'Dropped {item} at position {position} of {count}.',
8
+ cancelled: 'Reorder cancelled. {item} returned to position {position} of {count}.',
9
+ screenReaderInstructions:
10
+ 'To pick up an item, press Space or Enter. Use the arrow keys to move it, then press Space or Enter to drop it, or Escape to cancel.',
11
+ moveUp: 'Move up',
12
+ moveDown: 'Move down',
13
+ moveToTop: 'Move to top',
14
+ moveToBottom: 'Move to bottom',
15
+ };
16
+
17
+ export function resolveReorderableListLabels(
18
+ partial?: Partial<ReorderableListLabels>,
19
+ ): ReorderableListLabels {
20
+ return { ...DEFAULT_REORDERABLE_LIST_LABELS, ...partial };
21
+ }
22
+
23
+ export type ReorderMessageKey = 'pickedUp' | 'movedTo' | 'dropped' | 'cancelled';
24
+
25
+ export interface ReorderMessageInput<T> {
26
+ labels: ReorderableListLabels;
27
+ getItemLabel?: (item: T, index: number) => string;
28
+ item: T;
29
+ /** Zero-based position of the item before the move — names the item. */
30
+ fromIndex: number;
31
+ /** Zero-based position announced in the message. */
32
+ position: number;
33
+ count: number;
34
+ }
35
+
36
+ function fill(template: string, values: Record<string, string>): string {
37
+ return template.replace(/\{(\w+)\}/g, (match, name: string) => values[name] ?? match);
38
+ }
39
+
40
+ export function formatReorderMessage<T>(
41
+ key: ReorderMessageKey,
42
+ { labels, getItemLabel, item, fromIndex, position, count }: ReorderMessageInput<T>,
43
+ ): string {
44
+ const itemName = getItemLabel
45
+ ? getItemLabel(item, fromIndex)
46
+ : fill(labels.item, { position: String(fromIndex + 1) });
47
+ return fill(labels[key], {
48
+ item: itemName,
49
+ position: String(position + 1),
50
+ count: String(count),
51
+ });
52
+ }
@@ -0,0 +1,16 @@
1
+ /** Immutable insert-strategy move. No-ops when indices are equal or out of range. */
2
+ export function reorderItems<T>(data: T[], fromIndex: number, toIndex: number): T[] {
3
+ if (
4
+ fromIndex === toIndex ||
5
+ fromIndex < 0 ||
6
+ toIndex < 0 ||
7
+ fromIndex >= data.length ||
8
+ toIndex >= data.length
9
+ ) {
10
+ return data;
11
+ }
12
+
13
+ const next = data.slice();
14
+ next.splice(toIndex, 0, ...next.splice(fromIndex, 1));
15
+ return next;
16
+ }
@@ -0,0 +1,72 @@
1
+ import { createContext, useContext, type ReactElement, type RefCallback } from 'react';
2
+ import type {
3
+ AccessibilityActionEvent,
4
+ AccessibilityActionInfo,
5
+ StyleProp,
6
+ View,
7
+ ViewProps,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+
11
+ /**
12
+ * Props that make a host element the row's drag activator.
13
+ *
14
+ * - **Web:** dnd-kit `attributes` (`role`, `tabIndex`, `aria-*`), `listeners` (`onPointerDown`,
15
+ * `onKeyDown`), and `style: { touchAction: 'none' }`. Some keys (`aria-roledescription`,
16
+ * `aria-describedby`) are outside `ViewProps`, hence the index signature.
17
+ * - **Native:** empty — the gesture is attached by `wrap`.
18
+ */
19
+ export type ReorderableHandleHostProps = ViewProps & Readonly<Record<string, unknown>>;
20
+
21
+ /**
22
+ * Binds a handle element to the row's drag gesture without adding a focusable wrapper.
23
+ *
24
+ * Render one host (a `View`, or the consumer child under `asChild` via `Slot`), then:
25
+ * 1. spread `props` onto it before consumer props (handlers compose through `Slot`),
26
+ * 2. compose `ref` with the host's own ref,
27
+ * 3. return `wrap(host)`.
28
+ *
29
+ * Web: `ref` is dnd-kit's `setActivatorNodeRef` and `wrap` returns the host unchanged.
30
+ * Native: `ref` is `undefined` and `wrap` places the host inside `Sortable.Handle`, which must
31
+ * wrap because react-native-sortables only attaches its handle gesture through that component. It
32
+ * renders a non-accessible `View`, so the host stays the single focusable element; pass layout
33
+ * styles that must sit on the outer box (flex, alignSelf, margin) as `wrap`'s second argument.
34
+ */
35
+ export interface ReorderableRowHandle {
36
+ props: ReorderableHandleHostProps;
37
+ ref: RefCallback<View> | undefined;
38
+ wrap: (host: ReactElement, style?: StyleProp<ViewStyle>) => ReactElement;
39
+ }
40
+
41
+ /** Per-row reorder state and actions, provided to every row rendered by `ReorderableList`. */
42
+ export interface ReorderableRowContextValue {
43
+ /** `true` while this row is dragged. */
44
+ isDragging: boolean;
45
+ /** Zero-based position of the row in `data`. */
46
+ index: number;
47
+ /** Number of rows in the list. */
48
+ count: number;
49
+ /**
50
+ * Moves this row to `toIndex` (clamped to the list), calls `onReorder`/`onReorderIndices`, and
51
+ * announces the new position. No-op when the position does not change.
52
+ */
53
+ move: (toIndex: number) => void;
54
+ /**
55
+ * `Move up` / `Move down` / `Move to top` / `Move to bottom`, omitting those that do not apply at
56
+ * the ends. Spread with `onAccessibilityAction` onto the handle host. Native screen readers
57
+ * expose them as custom actions; react-native-web drops both props, and web keyboard users
58
+ * reorder through dnd-kit's keyboard sensor on the handle instead.
59
+ */
60
+ accessibilityActions: AccessibilityActionInfo[];
61
+ onAccessibilityAction: (event: AccessibilityActionEvent) => void;
62
+ handle: ReorderableRowHandle;
63
+ }
64
+
65
+ const ReorderableRowContext = createContext<ReorderableRowContextValue | null>(null);
66
+
67
+ export const ReorderableRowProvider = ReorderableRowContext.Provider;
68
+
69
+ /** Reorder state for the enclosing `ReorderableList` row, or `null` outside a list. */
70
+ export function useReorderableRow(): ReorderableRowContextValue | null {
71
+ return useContext(ReorderableRowContext);
72
+ }