@dxos/react-ui-list 0.9.0 → 0.9.1-main.c7dcc2e112

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 (110) hide show
  1. package/dist/lib/browser/index.mjs +993 -521
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +993 -521
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/aspects/index.d.ts +6 -0
  8. package/dist/types/src/aspects/index.d.ts.map +1 -0
  9. package/dist/types/src/aspects/useListDisclosure.d.ts +60 -0
  10. package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -0
  11. package/dist/types/src/aspects/useListDisclosure.test.d.ts +2 -0
  12. package/dist/types/src/aspects/useListDisclosure.test.d.ts.map +1 -0
  13. package/dist/types/src/aspects/useListGrid.d.ts +30 -0
  14. package/dist/types/src/aspects/useListGrid.d.ts.map +1 -0
  15. package/dist/types/src/aspects/useListGrid.test.d.ts +2 -0
  16. package/dist/types/src/aspects/useListGrid.test.d.ts.map +1 -0
  17. package/dist/types/src/aspects/useListNavigation.d.ts +68 -0
  18. package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -0
  19. package/dist/types/src/aspects/useListNavigation.test.d.ts +2 -0
  20. package/dist/types/src/aspects/useListNavigation.test.d.ts.map +1 -0
  21. package/dist/types/src/aspects/useListSelection.d.ts +48 -0
  22. package/dist/types/src/aspects/useListSelection.d.ts.map +1 -0
  23. package/dist/types/src/aspects/useListSelection.test.d.ts +2 -0
  24. package/dist/types/src/aspects/useListSelection.test.d.ts.map +1 -0
  25. package/dist/types/src/aspects/useReorder.d.ts +103 -0
  26. package/dist/types/src/aspects/useReorder.d.ts.map +1 -0
  27. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  28. package/dist/types/src/components/Accordion/AccordionItem.d.ts +5 -3
  29. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  30. package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
  31. package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
  32. package/dist/types/src/components/Listbox/Listbox.d.ts +60 -20
  33. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
  34. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +27 -3
  35. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/OrderedList/OrderedList.d.ts +49 -0
  37. package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -0
  38. package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts +11 -0
  39. package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/OrderedList/OrderedList.test.d.ts +2 -0
  41. package/dist/types/src/components/OrderedList/OrderedList.test.d.ts.map +1 -0
  42. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts +94 -0
  43. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -0
  44. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +73 -0
  45. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -0
  46. package/dist/types/src/components/OrderedList/index.d.ts +2 -0
  47. package/dist/types/src/components/OrderedList/index.d.ts.map +1 -0
  48. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  50. package/dist/types/src/components/index.d.ts +1 -2
  51. package/dist/types/src/components/index.d.ts.map +1 -1
  52. package/dist/types/src/index.d.ts +1 -0
  53. package/dist/types/src/index.d.ts.map +1 -1
  54. package/dist/types/src/vitest-setup.d.ts +2 -0
  55. package/dist/types/src/vitest-setup.d.ts.map +1 -0
  56. package/dist/types/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +18 -15
  58. package/src/aspects/index.ts +9 -0
  59. package/src/aspects/useListDisclosure.test.ts +72 -0
  60. package/src/aspects/useListDisclosure.ts +160 -0
  61. package/src/aspects/useListGrid.test.ts +41 -0
  62. package/src/aspects/useListGrid.ts +61 -0
  63. package/src/aspects/useListNavigation.test.ts +44 -0
  64. package/src/aspects/useListNavigation.ts +160 -0
  65. package/src/aspects/useListSelection.test.ts +101 -0
  66. package/src/aspects/useListSelection.ts +162 -0
  67. package/src/aspects/useReorder.ts +370 -0
  68. package/src/components/Accordion/Accordion.stories.tsx +1 -1
  69. package/src/components/Accordion/AccordionItem.tsx +11 -6
  70. package/src/components/Accordion/AccordionRoot.tsx +4 -1
  71. package/src/components/Listbox/Listbox.stories.tsx +171 -21
  72. package/src/components/Listbox/Listbox.tsx +302 -145
  73. package/src/components/OrderedList/OrderedList.stories.tsx +379 -0
  74. package/src/components/OrderedList/OrderedList.test.tsx +59 -0
  75. package/src/components/OrderedList/OrderedList.tsx +63 -0
  76. package/src/components/OrderedList/OrderedListItem.tsx +348 -0
  77. package/src/components/OrderedList/OrderedListRoot.tsx +173 -0
  78. package/src/components/OrderedList/index.ts +5 -0
  79. package/src/components/Tree/TreeItem.tsx +2 -0
  80. package/src/components/Tree/TreeItemHeading.tsx +1 -2
  81. package/src/components/index.ts +1 -2
  82. package/src/index.ts +1 -0
  83. package/src/vitest-setup.ts +11 -0
  84. package/dist/types/src/components/List/List.d.ts +0 -40
  85. package/dist/types/src/components/List/List.d.ts.map +0 -1
  86. package/dist/types/src/components/List/List.stories.d.ts +0 -18
  87. package/dist/types/src/components/List/List.stories.d.ts.map +0 -1
  88. package/dist/types/src/components/List/ListItem.d.ts +0 -49
  89. package/dist/types/src/components/List/ListItem.d.ts.map +0 -1
  90. package/dist/types/src/components/List/ListRoot.d.ts +0 -29
  91. package/dist/types/src/components/List/ListRoot.d.ts.map +0 -1
  92. package/dist/types/src/components/List/index.d.ts +0 -2
  93. package/dist/types/src/components/List/index.d.ts.map +0 -1
  94. package/dist/types/src/components/List/testing.d.ts +0 -15
  95. package/dist/types/src/components/List/testing.d.ts.map +0 -1
  96. package/dist/types/src/components/RowList/RowList.d.ts +0 -61
  97. package/dist/types/src/components/RowList/RowList.d.ts.map +0 -1
  98. package/dist/types/src/components/RowList/RowList.stories.d.ts +0 -35
  99. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +0 -1
  100. package/dist/types/src/components/RowList/index.d.ts +0 -3
  101. package/dist/types/src/components/RowList/index.d.ts.map +0 -1
  102. package/src/components/List/List.stories.tsx +0 -129
  103. package/src/components/List/List.tsx +0 -47
  104. package/src/components/List/ListItem.tsx +0 -287
  105. package/src/components/List/ListRoot.tsx +0 -106
  106. package/src/components/List/index.ts +0 -5
  107. package/src/components/List/testing.ts +0 -31
  108. package/src/components/RowList/RowList.stories.tsx +0 -163
  109. package/src/components/RowList/RowList.tsx +0 -350
  110. package/src/components/RowList/index.ts +0 -6
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { act, renderHook } from '@testing-library/react';
6
+ import { type FocusEvent } from 'react';
7
+ import { describe, test, vi } from 'vitest';
8
+
9
+ import { useListSelection } from './useListSelection';
10
+
11
+ // Minimal synthetic focus event exposing only the fields the selection-follows-focus handler reads.
12
+ // Constructing a partial event for a unit test is a genuine type boundary (React synthesizes the rest).
13
+ const focusEvent = (
14
+ fields: Pick<FocusEvent<HTMLElement>, 'currentTarget' | 'relatedTarget'>,
15
+ ): FocusEvent<HTMLElement> => fields as FocusEvent<HTMLElement>;
16
+
17
+ describe('useListSelection', () => {
18
+ describe('single mode', () => {
19
+ test('click selects the row', ({ expect }) => {
20
+ const onValueChange = vi.fn();
21
+ const { result } = renderHook(() => useListSelection({ mode: 'single', onValueChange }));
22
+ act(() => result.current.bind('a').rowProps.onClick({} as any));
23
+ expect(onValueChange).toHaveBeenLastCalledWith('a');
24
+ });
25
+
26
+ test('focus selects the row when selection-follows-focus is enabled (default)', ({ expect }) => {
27
+ const onValueChange = vi.fn();
28
+ const { result } = renderHook(() => useListSelection({ mode: 'single', onValueChange }));
29
+ act(() => result.current.bind('a').rowProps.onFocus?.({} as any));
30
+ expect(onValueChange).toHaveBeenLastCalledWith('a');
31
+ });
32
+
33
+ test('focus entering the list from outside does not change selection', ({ expect }) => {
34
+ const onValueChange = vi.fn();
35
+ // A listbox with one option, and an unrelated element outside it.
36
+ const container = document.createElement('div');
37
+ container.setAttribute('role', 'listbox');
38
+ const optionA = document.createElement('div');
39
+ const outside = document.createElement('div');
40
+ container.append(optionA);
41
+ document.body.append(container, outside);
42
+
43
+ const { result } = renderHook(() => useListSelection({ mode: 'single', value: 'b', onValueChange }));
44
+
45
+ // Entry focus (e.g. a popover auto-focusing on open): relatedTarget is outside the list.
46
+ act(() =>
47
+ result.current.bind('a').rowProps.onFocus?.(focusEvent({ currentTarget: optionA, relatedTarget: outside })),
48
+ );
49
+ expect(onValueChange).not.toHaveBeenCalled();
50
+
51
+ // Navigation within the list: relatedTarget is inside the list, so selection follows focus.
52
+ act(() =>
53
+ result.current.bind('a').rowProps.onFocus?.(focusEvent({ currentTarget: optionA, relatedTarget: container })),
54
+ );
55
+ expect(onValueChange).toHaveBeenLastCalledWith('a');
56
+
57
+ container.remove();
58
+ outside.remove();
59
+ });
60
+
61
+ test('disabled rows do not update selection on click', ({ expect }) => {
62
+ const onValueChange = vi.fn();
63
+ const { result } = renderHook(() => useListSelection({ mode: 'single', onValueChange }));
64
+ act(() => result.current.bind('a', { disabled: true }).rowProps.onClick({} as any));
65
+ expect(onValueChange).not.toHaveBeenCalled();
66
+ });
67
+
68
+ test('aria-selected mirrors controlled value', ({ expect }) => {
69
+ const { result, rerender } = renderHook(({ value }) => useListSelection({ mode: 'single', value }), {
70
+ initialProps: { value: 'a' as string | undefined },
71
+ });
72
+ expect(result.current.bind('a').rowProps['aria-selected']).toBe(true);
73
+ expect(result.current.bind('b').rowProps['aria-selected']).toBe(false);
74
+ rerender({ value: 'b' });
75
+ expect(result.current.bind('a').rowProps['aria-selected']).toBe(false);
76
+ expect(result.current.bind('b').rowProps['aria-selected']).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe('multi mode', () => {
81
+ test('click toggles row in/out of selection set', ({ expect }) => {
82
+ const onValueChange = vi.fn();
83
+ const { result } = renderHook(() => useListSelection({ mode: 'multi', onValueChange }));
84
+ act(() => result.current.bind('a').rowProps.onClick({} as any));
85
+ const firstCall = onValueChange.mock.calls[0]?.[0] as Set<string>;
86
+ expect(firstCall.has('a')).toBe(true);
87
+ });
88
+
89
+ test('does not follow focus by default', ({ expect }) => {
90
+ const { result } = renderHook(() => useListSelection({ mode: 'multi' }));
91
+ expect(result.current.bind('a').rowProps.onFocus).toBeUndefined();
92
+ });
93
+
94
+ test('follows focus when explicitly enabled', ({ expect }) => {
95
+ const onValueChange = vi.fn();
96
+ const { result } = renderHook(() => useListSelection({ mode: 'multi', followsFocus: true, onValueChange }));
97
+ act(() => result.current.bind('a').rowProps.onFocus?.({} as any));
98
+ expect(onValueChange).toHaveBeenCalled();
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,162 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type FocusEvent, type MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
6
+
7
+ export type ListSelectionMode = 'single' | 'multi';
8
+
9
+ type SingleValue = string | undefined;
10
+ type MultiValue = ReadonlySet<string>;
11
+
12
+ type ValueFor<M extends ListSelectionMode> = M extends 'single' ? SingleValue : MultiValue;
13
+
14
+ export type UseListSelectionOptions<M extends ListSelectionMode = ListSelectionMode> = {
15
+ mode: M;
16
+ /** Controlled selected id (single mode) or set of ids (multi mode). */
17
+ value?: ValueFor<M>;
18
+ defaultValue?: ValueFor<M>;
19
+ onValueChange?: (next: ValueFor<M>) => void;
20
+ /**
21
+ * Selection follows focus. Defaults to `true` for single-select (matches the WAI-ARIA
22
+ * listbox pattern) and `false` for multi-select (focus and toggle are separate gestures).
23
+ */
24
+ followsFocus?: boolean;
25
+ };
26
+
27
+ export type SelectionItemBinding = {
28
+ selected: boolean;
29
+ toggle: () => void;
30
+ /** Spread onto the row element to bind click + focus + ARIA. */
31
+ rowProps: {
32
+ 'aria-selected': boolean;
33
+ onClick: (event: MouseEvent) => void;
34
+ onFocus?: (event: FocusEvent) => void;
35
+ };
36
+ };
37
+
38
+ export type UseListSelectionReturn = {
39
+ bind: (id: string, opts?: { disabled?: boolean }) => SelectionItemBinding;
40
+ };
41
+
42
+ const isMulti = (value: SingleValue | MultiValue): value is MultiValue => value instanceof Set;
43
+
44
+ /**
45
+ * Selection aspect for list-shaped surfaces. Owns single- or multi-select state with
46
+ * controllable value semantics; emits `aria-selected` + click/focus handlers per row.
47
+ *
48
+ * `single` mode: at most one selected id, selection follows focus by default. Matches the
49
+ * existing `RowList` behaviour and the WAI-ARIA listbox single-select pattern.
50
+ *
51
+ * `multi` mode: tracks a `Set<string>`. Selection does NOT follow focus by default — multi
52
+ * select usually pairs with an explicit toggle affordance (checkbox or keyboard Space) rather
53
+ * than implicit focus-tracking.
54
+ */
55
+ export const useListSelection: {
56
+ <M extends ListSelectionMode>(opts: UseListSelectionOptions<M>): UseListSelectionReturn;
57
+ } = (opts) => {
58
+ const { mode, value, defaultValue, onValueChange, followsFocus } = opts;
59
+
60
+ // Latches whenever the consumer passes the `value` prop key (even as undefined). A controlled
61
+ // single-select consumer (`selectedId: string | undefined`) must be able to clear to undefined
62
+ // without the row falling back to stale internal state — Radix `useControllableState` (1.1.0)
63
+ // mishandles that case, so we mirror useListDisclosure's hand-rolled controller here.
64
+ const wasControlledRef = useRef(Object.prototype.hasOwnProperty.call(opts, 'value'));
65
+ if (Object.prototype.hasOwnProperty.call(opts, 'value')) {
66
+ wasControlledRef.current = true;
67
+ }
68
+ const isControlled = wasControlledRef.current;
69
+
70
+ const [internalValue, setInternalValue] = useState<SingleValue | MultiValue>(() => defaultValue);
71
+
72
+ useEffect(() => {
73
+ if (isControlled) {
74
+ setInternalValue(value);
75
+ }
76
+ }, [isControlled, value]);
77
+
78
+ const resolvedValue = isControlled ? value : internalValue;
79
+ const setResolvedValue = useCallback(
80
+ (next: SingleValue | MultiValue) => {
81
+ if (!isControlled) {
82
+ setInternalValue(next);
83
+ }
84
+ onValueChange?.(next as ValueFor<typeof mode>);
85
+ },
86
+ [isControlled, onValueChange, mode],
87
+ );
88
+
89
+ const isSelected = useCallback(
90
+ (id: string) => {
91
+ if (mode === 'multi') {
92
+ return isMulti(resolvedValue) && resolvedValue.has(id);
93
+ }
94
+ return resolvedValue === id;
95
+ },
96
+ [mode, resolvedValue],
97
+ );
98
+
99
+ const setSelected = useCallback(
100
+ (id: string, selected: boolean) => {
101
+ if (mode === 'multi') {
102
+ const current = isMulti(resolvedValue) ? resolvedValue : new Set<string>();
103
+ const next = new Set(current);
104
+ if (selected) {
105
+ next.add(id);
106
+ } else {
107
+ next.delete(id);
108
+ }
109
+ setResolvedValue(next);
110
+ } else {
111
+ setResolvedValue(selected ? id : undefined);
112
+ }
113
+ },
114
+ [mode, resolvedValue, setResolvedValue],
115
+ );
116
+
117
+ const followFocusDefault = mode === 'single';
118
+ const trackFocus = followsFocus ?? followFocusDefault;
119
+
120
+ const bind = useCallback(
121
+ (id: string, { disabled }: { disabled?: boolean } = {}): SelectionItemBinding => {
122
+ const selected = isSelected(id);
123
+ return {
124
+ selected,
125
+ toggle: () => {
126
+ if (!disabled) {
127
+ setSelected(id, mode === 'multi' ? !selected : true);
128
+ }
129
+ },
130
+ rowProps: {
131
+ 'aria-selected': selected,
132
+ onClick: () => {
133
+ if (disabled) {
134
+ return;
135
+ }
136
+ setSelected(id, mode === 'multi' ? !selected : true);
137
+ },
138
+ ...(trackFocus && {
139
+ onFocus: (event: FocusEvent) => {
140
+ if (disabled || selected) {
141
+ return;
142
+ }
143
+ // Selection follows focus only while navigating *between* options within the list.
144
+ // Focus entering the list from outside (e.g. a popover auto-focusing on open) must
145
+ // not change selection, or it would clobber the controlled value when entry focus
146
+ // lands on a non-selected option. Detect entry via `relatedTarget`: a synthetic event
147
+ // without DOM context (unit tests) falls through to the original follow-focus path.
148
+ const container = event.currentTarget?.closest?.('[role="listbox"],[role="list"],[role="grid"]');
149
+ if (container && !container.contains(event.relatedTarget)) {
150
+ return;
151
+ }
152
+ setSelected(id, true);
153
+ },
154
+ }),
155
+ },
156
+ };
157
+ },
158
+ [isSelected, mode, setSelected, trackFocus],
159
+ );
160
+
161
+ return { bind };
162
+ };
@@ -0,0 +1,370 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
6
+ import {
7
+ type Edge,
8
+ attachClosestEdge,
9
+ extractClosestEdge,
10
+ } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
11
+ import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index';
12
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
13
+ import {
14
+ type ElementDragPayload,
15
+ draggable,
16
+ dropTargetForElements,
17
+ monitorForElements,
18
+ } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
19
+ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
20
+ import { type ReactNode, type RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
+
22
+ /**
23
+ * Internal payload key. We attach this to every draggable's data so the root monitor can
24
+ * recognise drops that belong to its own list and ignore foreign payloads.
25
+ */
26
+ const REORDER_LIST_KEY = '__dxosReorderListId';
27
+
28
+ let reorderListIdCounter = 0;
29
+ const allocateReorderListId = (): string => `reorder-${++reorderListIdCounter}`;
30
+
31
+ export type ReorderAxis = 'vertical' | 'horizontal';
32
+
33
+ export type ReorderItemState =
34
+ | { type: 'idle' }
35
+ | { type: 'preview'; container: HTMLElement }
36
+ | { type: 'dragging' }
37
+ | { type: 'dragging-over'; closestEdge: Edge | null };
38
+
39
+ const IDLE: ReorderItemState = { type: 'idle' };
40
+
41
+ export type UseReorderListOptions<T> = {
42
+ /** Authoritative item list. Read on each drop to compute the new index. */
43
+ items: readonly T[];
44
+ /** Stable identifier per item. The monitor uses this to find source/target indices. */
45
+ getId: (item: T) => string;
46
+ /** Called with `(fromIndex, toIndex)` when a drop completes inside this list. */
47
+ onMove: (fromIndex: number, toIndex: number) => void;
48
+ /** Drop axis. Defaults to `'vertical'`. */
49
+ axis?: ReorderAxis;
50
+ /** When true, drag handles report as disabled. Defaults to false. */
51
+ readonly?: boolean;
52
+ /** Forwarded to pragmatic-dnd's `getInitialData` on each draggable. */
53
+ getInitialData?: (item: T, index: number) => Record<string, unknown>;
54
+ /** Overrides the default `canDrop` (which matches payloads tagged with this list's id). */
55
+ canDrop?: (args: { source: ElementDragPayload }) => boolean;
56
+ /** Renderer for a custom native drag preview. */
57
+ getDragPreview?: (item: T) => ReactNode;
58
+ };
59
+
60
+ export type ReorderActive<T> = { id: string; item: T; container: HTMLElement } | null;
61
+
62
+ /**
63
+ * Stable controller created by `useReorderList`. Item components consume it via
64
+ * `useReorderItem(controller, id)`. The controller is reference-stable across renders.
65
+ */
66
+ export type ReorderListController<T> = {
67
+ /** Internal: list id; payload tagged with this is the only data the monitor accepts by default. */
68
+ listId: string;
69
+ /** Look up an item by id. Used by the item hook to bind pragmatic-dnd at register time. */
70
+ getItem: (id: string) => { item: T; index: number } | null;
71
+ /** Bind a row's pragmatic-dnd primitives. Idempotent; returns a cleanup function. */
72
+ bindItem: (
73
+ id: string,
74
+ refs: { row: HTMLElement; handle: HTMLElement },
75
+ onItemState: (state: ReorderItemState) => void,
76
+ ) => () => void;
77
+ };
78
+
79
+ export type UseReorderListReturn<T> = {
80
+ controller: ReorderListController<T>;
81
+ /** The currently-dragging item, or null. Used by global drag preview portals. */
82
+ active: ReorderActive<T>;
83
+ /** Same as `controller.listId`. Exposed for diagnostics. */
84
+ listId: string;
85
+ };
86
+
87
+ /**
88
+ * List-level reorder aspect. Spins up pragmatic-dnd's `monitorForElements` and produces a
89
+ * stable controller that per-row hooks consume. Item registration + the per-item draggable /
90
+ * drop-target wiring lives in `useReorderItem` so each row owns its own React state without
91
+ * re-rendering siblings on hover.
92
+ */
93
+ export const useReorderList = <T>({
94
+ items,
95
+ getId,
96
+ onMove,
97
+ axis = 'vertical',
98
+ readonly = false,
99
+ getInitialData,
100
+ canDrop,
101
+ getDragPreview,
102
+ }: UseReorderListOptions<T>): UseReorderListReturn<T> => {
103
+ const listIdRef = useRef<string | null>(null);
104
+ if (!listIdRef.current) {
105
+ listIdRef.current = allocateReorderListId();
106
+ }
107
+ const listId = listIdRef.current;
108
+
109
+ // Stable refs to mutable inputs so the controller and the monitor effect don't tear down
110
+ // on every render. The functions returned in `controller` read from these refs.
111
+ const itemsRef = useRef(items);
112
+ itemsRef.current = items;
113
+ const getIdRef = useRef(getId);
114
+ getIdRef.current = getId;
115
+ const onMoveRef = useRef(onMove);
116
+ onMoveRef.current = onMove;
117
+ const canDropRef = useRef(canDrop);
118
+ canDropRef.current = canDrop;
119
+ const getInitialDataRef = useRef(getInitialData);
120
+ getInitialDataRef.current = getInitialData;
121
+ const getDragPreviewRef = useRef(getDragPreview);
122
+ getDragPreviewRef.current = getDragPreview;
123
+ const readonlyRef = useRef(readonly);
124
+ readonlyRef.current = readonly;
125
+
126
+ const [active, setActive] = useState<ReorderActive<T>>(null);
127
+
128
+ const findIndex = useCallback((id: string): number => {
129
+ return itemsRef.current.findIndex((item) => getIdRef.current(item) === id);
130
+ }, []);
131
+
132
+ const findIndexFromPayload = useCallback(
133
+ (data: Record<string, unknown> | undefined): number => {
134
+ if (!data || data[REORDER_LIST_KEY] !== listId) {
135
+ return -1;
136
+ }
137
+ const id = data.id as string | undefined;
138
+ return id ? findIndex(id) : -1;
139
+ },
140
+ [listId, findIndex],
141
+ );
142
+
143
+ // The list-level monitor watches drops belonging to this list and computes the destination
144
+ // index using pragmatic-dnd's helper. Mounted once per list lifetime.
145
+ useEffect(() => {
146
+ return monitorForElements({
147
+ canMonitor: ({ source }) => {
148
+ if (canDropRef.current) {
149
+ return canDropRef.current({ source });
150
+ }
151
+ return source.data[REORDER_LIST_KEY] === listId;
152
+ },
153
+ onDrop: ({ location, source }) => {
154
+ const target = location.current.dropTargets[0];
155
+ if (!target) {
156
+ return;
157
+ }
158
+ const sourceIdx = findIndexFromPayload(source.data);
159
+ const targetIdx = findIndexFromPayload(target.data);
160
+ if (sourceIdx < 0 || targetIdx < 0) {
161
+ return;
162
+ }
163
+ const destinationIndex = getReorderDestinationIndex({
164
+ closestEdgeOfTarget: extractClosestEdge(target.data),
165
+ startIndex: sourceIdx,
166
+ indexOfTarget: targetIdx,
167
+ axis,
168
+ });
169
+ onMoveRef.current(sourceIdx, destinationIndex);
170
+ },
171
+ });
172
+ }, [listId, axis, findIndexFromPayload]);
173
+
174
+ // The controller is reference-stable. Item-level hooks call `bindItem` to register their
175
+ // DOM elements with pragmatic-dnd; `getItem` is the synchronous lookup used at bind time.
176
+ const controller = useMemo<ReorderListController<T>>(
177
+ () => ({
178
+ listId,
179
+ getItem: (id) => {
180
+ const index = findIndex(id);
181
+ if (index < 0) {
182
+ return null;
183
+ }
184
+ return { item: itemsRef.current[index], index };
185
+ },
186
+ bindItem: (id, refs, onItemState) => {
187
+ const lookup = () => {
188
+ const index = findIndex(id);
189
+ return { item: itemsRef.current[index], index };
190
+ };
191
+ const { item, index } = lookup();
192
+ if (!item && index < 0) {
193
+ return () => {};
194
+ }
195
+ const allowedEdges: Edge[] = axis === 'vertical' ? ['top', 'bottom'] : ['left', 'right'];
196
+ return combine(
197
+ draggable({
198
+ element: refs.row,
199
+ dragHandle: refs.handle,
200
+ canDrag: () => !readonlyRef.current,
201
+ getInitialData: () => {
202
+ const current = lookup();
203
+ return {
204
+ [REORDER_LIST_KEY]: listId,
205
+ id,
206
+ ...(getInitialDataRef.current?.(current.item, current.index) ?? {}),
207
+ };
208
+ },
209
+ onGenerateDragPreview: getDragPreviewRef.current
210
+ ? ({ nativeSetDragImage, source }) => {
211
+ const rect = source.element.getBoundingClientRect();
212
+ setCustomNativeDragPreview({
213
+ nativeSetDragImage,
214
+ getOffset: ({ container }) => ({ x: 20, y: container.getBoundingClientRect().height / 2 }),
215
+ render: ({ container }) => {
216
+ container.style.width = `${rect.width}px`;
217
+ onItemState({ type: 'preview', container });
218
+ const current = lookup();
219
+ setActive({ id, item: current.item, container });
220
+ return () => {
221
+ onItemState(IDLE);
222
+ setActive(null);
223
+ };
224
+ },
225
+ });
226
+ }
227
+ : undefined,
228
+ onDragStart: () => {
229
+ onItemState({ type: 'dragging' });
230
+ const current = lookup();
231
+ setActive({ id, item: current.item, container: refs.row });
232
+ },
233
+ onDrop: () => {
234
+ onItemState(IDLE);
235
+ setActive(null);
236
+ },
237
+ }),
238
+ dropTargetForElements({
239
+ element: refs.row,
240
+ canDrop: ({ source }) => {
241
+ if (source.element === refs.row) {
242
+ return false;
243
+ }
244
+ if (canDropRef.current) {
245
+ return canDropRef.current({ source });
246
+ }
247
+ return source.data[REORDER_LIST_KEY] === listId;
248
+ },
249
+ getData: ({ input }) =>
250
+ attachClosestEdge({ [REORDER_LIST_KEY]: listId, id }, { element: refs.row, input, allowedEdges }),
251
+ getIsSticky: () => true,
252
+ onDragEnter: ({ self }) => {
253
+ onItemState({ type: 'dragging-over', closestEdge: extractClosestEdge(self.data) });
254
+ },
255
+ onDrag: ({ self }) => {
256
+ onItemState({ type: 'dragging-over', closestEdge: extractClosestEdge(self.data) });
257
+ },
258
+ onDragLeave: () => onItemState(IDLE),
259
+ onDrop: () => onItemState(IDLE),
260
+ }),
261
+ );
262
+ },
263
+ }),
264
+ [listId, axis, findIndex],
265
+ );
266
+
267
+ return { controller, active, listId };
268
+ };
269
+
270
+ export type ReorderItemBinding = {
271
+ rowRef: RefCallback<HTMLElement>;
272
+ handleRef: RefCallback<HTMLElement>;
273
+ state: ReorderItemState;
274
+ isDragging: boolean;
275
+ closestEdge: Edge | null;
276
+ };
277
+
278
+ /**
279
+ * Per-row reorder hook. Owns the row's local state and registers the row's DOM elements
280
+ * with the list controller once both `rowRef` and `handleRef` are attached. Unmounting (or
281
+ * detaching either ref) tears down the registration.
282
+ *
283
+ * Called inside the item component, not in the parent's render loop — this is what keeps
284
+ * us inside the rules of hooks.
285
+ */
286
+ export const useReorderItem = <T>(controller: ReorderListController<T>, id: string): ReorderItemBinding => {
287
+ const [state, setState] = useState<ReorderItemState>(IDLE);
288
+
289
+ // Snapshot the attached DOM nodes between renders without disturbing the React tree. When
290
+ // both refs have resolved we register with the list controller; either detaching triggers
291
+ // cleanup so we never leak pragmatic-dnd bindings.
292
+ const rowElement = useRef<HTMLElement | null>(null);
293
+ const handleElement = useRef<HTMLElement | null>(null);
294
+ const cleanupRef = useRef<(() => void) | null>(null);
295
+
296
+ const tryRegister = useCallback(() => {
297
+ if (!rowElement.current || !handleElement.current) {
298
+ return;
299
+ }
300
+ cleanupRef.current?.();
301
+ cleanupRef.current = controller.bindItem(id, { row: rowElement.current, handle: handleElement.current }, setState);
302
+ }, [controller, id]);
303
+
304
+ const teardown = useCallback(() => {
305
+ cleanupRef.current?.();
306
+ cleanupRef.current = null;
307
+ setState(IDLE);
308
+ }, []);
309
+
310
+ // Re-register if the controller or id changes (e.g. items reorder identity-stable).
311
+ useEffect(() => {
312
+ tryRegister();
313
+ return teardown;
314
+ }, [tryRegister, teardown]);
315
+
316
+ const rowRef = useCallback<RefCallback<HTMLElement>>(
317
+ (node) => {
318
+ rowElement.current = node;
319
+ if (node) {
320
+ tryRegister();
321
+ } else {
322
+ teardown();
323
+ }
324
+ },
325
+ [tryRegister, teardown],
326
+ );
327
+
328
+ const handleRef = useCallback<RefCallback<HTMLElement>>(
329
+ (node) => {
330
+ handleElement.current = node;
331
+ if (node && rowElement.current) {
332
+ tryRegister();
333
+ } else if (!node) {
334
+ // Mirror rowRef: if either ref detaches, tear down pragmatic-dnd bindings so a
335
+ // re-attaching handle creates a fresh registration rather than racing the old one.
336
+ teardown();
337
+ }
338
+ },
339
+ [tryRegister, teardown],
340
+ );
341
+
342
+ return {
343
+ rowRef,
344
+ handleRef,
345
+ state,
346
+ isDragging: state.type === 'dragging',
347
+ closestEdge: state.type === 'dragging-over' ? state.closestEdge : null,
348
+ };
349
+ };
350
+
351
+ /**
352
+ * Wire pragmatic-dnd's auto-scroll on a scrollable container. While any pragmatic-dnd drag
353
+ * is in flight, hovering near the edges of the registered element scrolls the container
354
+ * automatically. Pair with `OrderedList.Viewport` (or any caller-owned ScrollArea) so long
355
+ * lists can be reordered without manually scrolling first.
356
+ *
357
+ * `autoScrollForElements` is global — it activates on every drag regardless of which list
358
+ * started it — so it's safe to register one element per scroll surface.
359
+ *
360
+ * Returns a callback ref so the registration fires as soon as the element attaches and the
361
+ * cleanup fires when it detaches; React doesn't re-run effects on mutable `ref.current`
362
+ * changes, so a plain `useEffect` on a `useRef` would miss late-mounting elements entirely.
363
+ */
364
+ export const useReorderAutoScroll = (): RefCallback<HTMLElement> => {
365
+ const cleanupRef = useRef<(() => void) | null>(null);
366
+ return useCallback((node) => {
367
+ cleanupRef.current?.();
368
+ cleanupRef.current = node ? autoScrollForElements({ element: node }) : null;
369
+ }, []);
370
+ };
@@ -27,7 +27,7 @@ const DefaultStory = () => {
27
27
  <div className='flex flex-col w-full border-y border-separator divide-y divide-separator'>
28
28
  {items.map((item) => (
29
29
  <Accordion.Item key={item.id} item={item} classNames='border-x border-separator'>
30
- <Accordion.ItemHeader>{item.name}</Accordion.ItemHeader>
30
+ <Accordion.ItemHeader icon='ph--circle--regular'>{item.name}</Accordion.ItemHeader>
31
31
  <Accordion.ItemBody>
32
32
  <p>{item.text}</p>
33
33
  </Accordion.ItemBody>
@@ -9,7 +9,8 @@ import React, { type PropsWithChildren } from 'react';
9
9
  import { Icon, type ThemedClassName } from '@dxos/react-ui';
10
10
  import { mx } from '@dxos/ui-theme';
11
11
 
12
- import { type ListItemRecord } from '../List';
12
+ // See `AccordionRoot.tsx` for the rationale on `ListItemRecord = any`.
13
+ type ListItemRecord = any;
13
14
  import { useAccordionContext } from './AccordionRoot';
14
15
 
15
16
  const ACCORDION_ITEM_NAME = 'AccordionItem';
@@ -37,17 +38,21 @@ export const AccordionItem = <T extends ListItemRecord>({ children, classNames,
37
38
  );
38
39
  };
39
40
 
40
- export type AccordionItemHeaderProps = ThemedClassName<AccordionPrimitive.AccordionHeaderProps>;
41
+ export type AccordionItemHeaderProps = ThemedClassName<AccordionPrimitive.AccordionHeaderProps & { icon?: string }>;
41
42
 
42
- export const AccordionItemHeader = ({ classNames, children, ...props }: AccordionItemHeaderProps) => {
43
+ export const AccordionItemHeader = ({ classNames, children, icon, ...props }: AccordionItemHeaderProps) => {
43
44
  return (
44
45
  <AccordionPrimitive.Header {...props} className={mx(classNames)}>
45
- <AccordionPrimitive.Trigger className='group flex items-center p-2 dx-focus-ring-inset w-full text-start'>
46
- {children}
46
+ {/* `justify-between` pins the toggle caret to the trailing edge of the row regardless of
47
+ the header content's intrinsic width — so the affordance lives at a predictable
48
+ right-end position. The content wrapper grabs the remaining space. */}
49
+ <AccordionPrimitive.Trigger className='group flex items-center justify-between gap-2 p-2 dx-focus-ring-inset w-full text-start'>
50
+ {icon && <Icon icon={icon} size={4} />}
51
+ <span className='min-w-0 flex-1 truncate'>{children}</span>
47
52
  <Icon
48
53
  icon='ph--caret-right--regular'
49
54
  size={4}
50
- classNames='transition-transform duration-200 group-data-[state=open]:rotate-90'
55
+ classNames='shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90'
51
56
  />
52
57
  </AccordionPrimitive.Trigger>
53
58
  </AccordionPrimitive.Header>