@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-list",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "A list component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -26,6 +26,7 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@atlaskit/pragmatic-drag-and-drop": "1.7.7",
29
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
29
30
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
30
31
  "@effect-atom/atom-react": "^0.5.0",
31
32
  "@fluentui/react-tabster": "9.26.11",
@@ -33,33 +34,35 @@
33
34
  "@radix-ui/react-context": "1.1.1",
34
35
  "@radix-ui/react-slot": "1.1.2",
35
36
  "@radix-ui/react-use-controllable-state": "1.1.0",
36
- "@dxos/debug": "0.9.0",
37
- "@dxos/invariant": "0.9.0",
38
- "@dxos/react-list": "0.9.0",
39
- "@dxos/react-ui": "0.9.0",
40
- "@dxos/ui-theme": "0.9.0",
41
- "@dxos/log": "0.9.0",
42
- "@dxos/react-ui-text-tooltip": "0.9.0",
43
- "@dxos/echo": "0.9.0",
44
- "@dxos/ui-types": "0.9.0",
45
- "@dxos/util": "0.9.0"
37
+ "@dxos/debug": "0.9.1-main.c7dcc2e112",
38
+ "@dxos/echo": "0.9.1-main.c7dcc2e112",
39
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
40
+ "@dxos/react-list": "0.9.1-main.c7dcc2e112",
41
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
42
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
43
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
44
+ "@dxos/ui-types": "0.9.1-main.c7dcc2e112",
45
+ "@dxos/util": "0.9.1-main.c7dcc2e112"
46
46
  },
47
47
  "devDependencies": {
48
+ "@testing-library/jest-dom": "^6.9.1",
49
+ "@testing-library/react": "^16.3.0",
48
50
  "@types/react": "~19.2.7",
49
51
  "@types/react-dom": "~19.2.3",
50
52
  "effect": "3.21.3",
51
53
  "react": "~19.2.3",
52
54
  "react-dom": "~19.2.3",
55
+ "resize-observer-polyfill": "^1.5.1",
53
56
  "vite": "^8.0.16",
54
- "@dxos/storybook-utils": "0.9.0",
55
- "@dxos/random": "0.9.0"
57
+ "@dxos/random": "0.9.1-main.c7dcc2e112",
58
+ "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112"
56
59
  },
57
60
  "peerDependencies": {
58
61
  "effect": "3.21.3",
59
62
  "react": "~19.2.3",
60
63
  "react-dom": "~19.2.3",
61
- "@dxos/ui-theme": "0.9.0",
62
- "@dxos/react-ui": "0.9.0"
64
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
65
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112"
63
66
  },
64
67
  "publishConfig": {
65
68
  "access": "public"
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './useListDisclosure';
6
+ export * from './useListGrid';
7
+ export * from './useListNavigation';
8
+ export * from './useListSelection';
9
+ export * from './useReorder';
@@ -0,0 +1,72 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { act, renderHook } from '@testing-library/react';
6
+ import { describe, test, vi } from 'vitest';
7
+
8
+ import { useListDisclosure } from './useListDisclosure';
9
+
10
+ describe('useListDisclosure', () => {
11
+ describe('single mode', () => {
12
+ test('expanding an item sets it as the value', ({ expect }) => {
13
+ const onValueChange = vi.fn();
14
+ const { result } = renderHook(() => useListDisclosure({ mode: 'single', onValueChange }));
15
+ expect(result.current.bind('a').expanded).toBe(false);
16
+ act(() => result.current.bind('a').toggle());
17
+ expect(onValueChange).toHaveBeenLastCalledWith('a');
18
+ });
19
+
20
+ test('expanding a second item collapses the first', ({ expect }) => {
21
+ const { result, rerender } = renderHook(({ value }) => useListDisclosure({ mode: 'single', value }), {
22
+ initialProps: { value: 'a' as string | undefined },
23
+ });
24
+ expect(result.current.bind('a').expanded).toBe(true);
25
+ expect(result.current.bind('b').expanded).toBe(false);
26
+ rerender({ value: 'b' });
27
+ expect(result.current.bind('a').expanded).toBe(false);
28
+ expect(result.current.bind('b').expanded).toBe(true);
29
+ });
30
+
31
+ test('toggling an expanded item collapses it to undefined', ({ expect }) => {
32
+ const onValueChange = vi.fn();
33
+ const { result } = renderHook(() => useListDisclosure({ mode: 'single', defaultValue: 'a', onValueChange }));
34
+ expect(result.current.bind('a').expanded).toBe(true);
35
+ act(() => result.current.bind('a').toggle());
36
+ expect(onValueChange).toHaveBeenLastCalledWith(undefined);
37
+ });
38
+ });
39
+
40
+ describe('multi mode', () => {
41
+ test('expanding multiple items keeps all open', ({ expect }) => {
42
+ const onValueChange = vi.fn();
43
+ const { result } = renderHook(() => useListDisclosure({ mode: 'multi', onValueChange }));
44
+ act(() => result.current.bind('a').toggle());
45
+ act(() => result.current.bind('b').toggle());
46
+ const lastCallSet = onValueChange.mock.lastCall?.[0] as Set<string>;
47
+ expect(lastCallSet.has('a')).toBe(true);
48
+ expect(lastCallSet.has('b')).toBe(true);
49
+ });
50
+
51
+ test('toggling a multi-expanded item removes it from the set', ({ expect }) => {
52
+ const onValueChange = vi.fn();
53
+ const { result } = renderHook(() =>
54
+ useListDisclosure({ mode: 'multi', defaultValue: new Set(['a', 'b']), onValueChange }),
55
+ );
56
+ act(() => result.current.bind('a').toggle());
57
+ const lastCallSet = onValueChange.mock.lastCall?.[0] as Set<string>;
58
+ expect(lastCallSet.has('a')).toBe(false);
59
+ expect(lastCallSet.has('b')).toBe(true);
60
+ });
61
+ });
62
+
63
+ test('emits a stable trigger/panel id pair per item', ({ expect }) => {
64
+ const { result } = renderHook(() => useListDisclosure({ mode: 'single' }));
65
+ const first = result.current.bind('x');
66
+ const second = result.current.bind('x');
67
+ expect(first.triggerId).toBe(second.triggerId);
68
+ expect(first.panelId).toBe(second.panelId);
69
+ expect(first.triggerProps['aria-controls']).toBe(first.panelId);
70
+ expect(first.panelProps['aria-labelledby']).toBe(first.triggerId);
71
+ });
72
+ });
@@ -0,0 +1,160 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type MouseEvent, useCallback, useEffect, useId, useRef, useState } from 'react';
6
+
7
+ export type ListDisclosureMode = 'single' | 'multi';
8
+
9
+ type SingleValue = string | undefined;
10
+ type MultiValue = ReadonlySet<string>;
11
+
12
+ type ValueFor<M extends ListDisclosureMode> = M extends 'single' ? SingleValue : MultiValue;
13
+
14
+ export type UseListDisclosureOptions<M extends ListDisclosureMode = ListDisclosureMode> = {
15
+ mode: M;
16
+ /** Controlled value: a single id (single mode) or a set of ids (multi mode). */
17
+ value?: ValueFor<M>;
18
+ defaultValue?: ValueFor<M>;
19
+ /** Called whenever the disclosure value changes. */
20
+ onValueChange?: (next: ValueFor<M>) => void;
21
+ };
22
+
23
+ export type DisclosureItemBinding = {
24
+ expanded: boolean;
25
+ toggle: () => void;
26
+ /** Stable id on the disclosure trigger; the controlled panel references it via aria-labelledby. */
27
+ triggerId: string;
28
+ /** Stable id on the controlled panel; the trigger references it via aria-controls. */
29
+ panelId: string;
30
+ /** Spread onto the trigger element (button, title row, …). */
31
+ triggerProps: {
32
+ id: string;
33
+ 'aria-expanded': boolean;
34
+ 'aria-controls': string;
35
+ onClick: (event: MouseEvent) => void;
36
+ };
37
+ /** Spread onto the disclosed panel; carries role=region for SR navigation. */
38
+ panelProps: {
39
+ id: string;
40
+ role: 'region';
41
+ 'aria-labelledby': string;
42
+ };
43
+ };
44
+
45
+ export type UseListDisclosureReturn = {
46
+ /** Return the disclosure binding for a single item by id. */
47
+ bind: (id: string) => DisclosureItemBinding;
48
+ };
49
+
50
+ const isMulti = (value: SingleValue | MultiValue): value is MultiValue => value instanceof Set;
51
+
52
+ /**
53
+ * Disclosure (open/close) aspect for list items. Owns single- or multi-expand state and
54
+ * generates the trigger/panel ids needed for `aria-controls` / `aria-labelledby`. Pairs
55
+ * with `useListGrid`'s expand-caret slot.
56
+ *
57
+ * `single` mode tracks one expanded id; expanding another collapses the previous (matches
58
+ * the existing `OrderedList` behaviour).
59
+ *
60
+ * `multi` mode tracks a `Set<string>` of expanded ids; intended for `Tree` and other
61
+ * multi-branch disclosure surfaces.
62
+ *
63
+ * Controlled-ness keys on `onValueChange` rather than on the value's presence so that
64
+ * `undefined` (single mode) and the empty set (multi mode) remain valid "nothing expanded"
65
+ * values. Radix's `useControllableState` (1.1.0) flips to uncontrolled when a controlled
66
+ * value clears to `undefined`, then re-reads the stale internal state and fails to collapse —
67
+ * a hand-rolled controller is the only correct option here.
68
+ */
69
+ export const useListDisclosure: {
70
+ <M extends ListDisclosureMode>(opts: UseListDisclosureOptions<M>): UseListDisclosureReturn;
71
+ } = (opts) => {
72
+ const { mode, value, defaultValue, onValueChange } = opts;
73
+ const idPrefix = useId();
74
+
75
+ // Latches whenever the consumer passes the `value` prop key, regardless of whether the
76
+ // current value is `undefined`. A controlled single-expand parent (`expandedId: string | undefined`)
77
+ // must be able to clear to undefined without the row falling back to stale internal state —
78
+ // detecting controlled-ness by `value !== undefined` would misclassify "controlled and currently
79
+ // empty" as uncontrolled.
80
+ const wasControlledRef = useRef(Object.prototype.hasOwnProperty.call(opts, 'value'));
81
+ if (Object.prototype.hasOwnProperty.call(opts, 'value')) {
82
+ wasControlledRef.current = true;
83
+ }
84
+ const isControlled = wasControlledRef.current;
85
+
86
+ const [internalValue, setInternalValue] = useState<SingleValue | MultiValue>(() => defaultValue);
87
+
88
+ // Mirror the controlled prop into internal state so going uncontrolled in a later render
89
+ // doesn't surface a stale internal value (and so consumers can read `internalValue`
90
+ // uniformly regardless of mode).
91
+ useEffect(() => {
92
+ if (isControlled) {
93
+ setInternalValue(value);
94
+ }
95
+ }, [isControlled, value]);
96
+
97
+ const resolvedValue = isControlled ? value : internalValue;
98
+
99
+ const isExpanded = useCallback(
100
+ (id: string) => {
101
+ if (mode === 'multi') {
102
+ return isMulti(resolvedValue) && resolvedValue.has(id);
103
+ }
104
+ return resolvedValue === id;
105
+ },
106
+ [mode, resolvedValue],
107
+ );
108
+
109
+ const setExpanded = useCallback(
110
+ (id: string, expanded: boolean) => {
111
+ const computeNext = (): SingleValue | MultiValue => {
112
+ if (mode === 'multi') {
113
+ const current = isMulti(resolvedValue) ? resolvedValue : new Set<string>();
114
+ const next = new Set(current);
115
+ if (expanded) {
116
+ next.add(id);
117
+ } else {
118
+ next.delete(id);
119
+ }
120
+ return next;
121
+ }
122
+ return expanded ? id : undefined;
123
+ };
124
+ const next = computeNext();
125
+ if (!isControlled) {
126
+ setInternalValue(next);
127
+ }
128
+ onValueChange?.(next as ValueFor<typeof mode>);
129
+ },
130
+ [mode, resolvedValue, isControlled, onValueChange],
131
+ );
132
+
133
+ const bind = useCallback(
134
+ (id: string): DisclosureItemBinding => {
135
+ const expanded = isExpanded(id);
136
+ const triggerId = `${idPrefix}-${id}-trigger`;
137
+ const panelId = `${idPrefix}-${id}-panel`;
138
+ return {
139
+ expanded,
140
+ toggle: () => setExpanded(id, !expanded),
141
+ triggerId,
142
+ panelId,
143
+ triggerProps: {
144
+ id: triggerId,
145
+ 'aria-expanded': expanded,
146
+ 'aria-controls': panelId,
147
+ onClick: () => setExpanded(id, !expanded),
148
+ },
149
+ panelProps: {
150
+ id: panelId,
151
+ role: 'region',
152
+ 'aria-labelledby': triggerId,
153
+ },
154
+ };
155
+ },
156
+ [idPrefix, isExpanded, setExpanded],
157
+ );
158
+
159
+ return { bind };
160
+ };
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { renderHook } from '@testing-library/react';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { useListGrid } from './useListGrid';
9
+
10
+ describe('useListGrid', () => {
11
+ test('emits handle + title only by default', ({ expect }) => {
12
+ const { result } = renderHook(() => useListGrid());
13
+ expect(result.current.rowProps.style.gridTemplateColumns).toBe('var(--dx-rail-item) 1fr');
14
+ });
15
+
16
+ test('appends action slots between title and trailing', ({ expect }) => {
17
+ const { result } = renderHook(() => useListGrid({ actionSlots: 2 }));
18
+ expect(result.current.rowProps.style.gridTemplateColumns).toBe(
19
+ 'var(--dx-rail-item) 1fr var(--dx-rail-item) var(--dx-rail-item)',
20
+ );
21
+ });
22
+
23
+ test('reserves expand and trailing slots when requested', ({ expect }) => {
24
+ const { result } = renderHook(() => useListGrid({ expandable: true, trailing: true }));
25
+ expect(result.current.rowProps.style.gridTemplateColumns).toBe(
26
+ 'var(--dx-rail-item) 1fr var(--dx-rail-item) var(--dx-rail-item)',
27
+ );
28
+ });
29
+
30
+ test('combines actions, expand, and trailing in declared order', ({ expect }) => {
31
+ const { result } = renderHook(() => useListGrid({ actionSlots: 1, expandable: true, trailing: true }));
32
+ expect(result.current.rowProps.style.gridTemplateColumns).toBe(
33
+ 'var(--dx-rail-item) 1fr var(--dx-rail-item) var(--dx-rail-item) var(--dx-rail-item)',
34
+ );
35
+ });
36
+
37
+ test('anchors columns to the row top so trailing does not shift on body growth', ({ expect }) => {
38
+ const { result } = renderHook(() => useListGrid({ trailing: true }));
39
+ expect(result.current.rowProps.className).toContain('items-start');
40
+ });
41
+ });
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type CSSProperties, useMemo } from 'react';
6
+
7
+ export type UseListGridOptions = {
8
+ /**
9
+ * Number of inline action slots between title and expand caret. Each slot is sized to
10
+ * `var(--dx-rail-item)` so an `IconButton` lands on the same baseline as the trailing icon.
11
+ */
12
+ actionSlots?: number;
13
+ /** Reserve an expand-caret slot at the end of the title row. */
14
+ expandable?: boolean;
15
+ /** Reserve a trailing-action slot outside the row's card (e.g. delete). */
16
+ trailing?: boolean;
17
+ };
18
+
19
+ export type UseListGridReturn = {
20
+ /** Spread onto the outer row element to apply the grid template. */
21
+ rowProps: { className: string; style: CSSProperties };
22
+ };
23
+
24
+ /**
25
+ * Row layout aspect. Generates the CSS grid template that keeps drag handle, title,
26
+ * inline actions, expand caret, and trailing icon co-aligned on the same line —
27
+ * independent of whether the row body is expanded.
28
+ *
29
+ * Width tracks are `var(--dx-rail-item)` for icon-button slots and `1fr` for the
30
+ * title, so every icon-shaped slot is the same width as `IconBlock` / `IconButton iconOnly`
31
+ * and the line aligns without per-pixel adjustments.
32
+ */
33
+ export const useListGrid = ({
34
+ actionSlots = 0,
35
+ expandable = false,
36
+ trailing = false,
37
+ }: UseListGridOptions = {}): UseListGridReturn => {
38
+ // Grid columns: [handle] [title=1fr] [action × N] [expand?] [trailing?]
39
+ // `items-start` keeps trailing/handle anchored to the row top so they don't shift
40
+ // when the title-row card grows vertically (e.g. on expand).
41
+ const gridTemplateColumns = useMemo(() => {
42
+ const tracks = ['var(--dx-rail-item)', '1fr'];
43
+ for (let index = 0; index < actionSlots; index++) {
44
+ tracks.push('var(--dx-rail-item)');
45
+ }
46
+ if (expandable) {
47
+ tracks.push('var(--dx-rail-item)');
48
+ }
49
+ if (trailing) {
50
+ tracks.push('var(--dx-rail-item)');
51
+ }
52
+ return tracks.join(' ');
53
+ }, [actionSlots, expandable, trailing]);
54
+
55
+ return {
56
+ rowProps: {
57
+ className: 'grid items-start gap-1',
58
+ style: { gridTemplateColumns },
59
+ },
60
+ };
61
+ };
@@ -0,0 +1,44 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { renderHook } from '@testing-library/react';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { useListNavigation } from './useListNavigation';
9
+
10
+ describe('useListNavigation', () => {
11
+ test('list mode emits role=list / listitem with vertical orientation', ({ expect }) => {
12
+ const { result } = renderHook(() => useListNavigation({ mode: 'list' }));
13
+ expect(result.current.containerProps.role).toBe('list');
14
+ expect(result.current.containerProps['aria-orientation']).toBe('vertical');
15
+ expect(result.current.itemProps().role).toBe('listitem');
16
+ expect(result.current.itemProps().tabIndex).toBe(-1);
17
+ });
18
+
19
+ test('listbox mode emits role=listbox / option with focusable items', ({ expect }) => {
20
+ const { result } = renderHook(() => useListNavigation({ mode: 'listbox' }));
21
+ expect(result.current.containerProps.role).toBe('listbox');
22
+ expect(result.current.itemProps().role).toBe('option');
23
+ expect(result.current.itemProps().tabIndex).toBe(0);
24
+ });
25
+
26
+ test('grid mode emits role=grid / row and omits aria-orientation', ({ expect }) => {
27
+ const { result } = renderHook(() => useListNavigation({ mode: 'grid' }));
28
+ expect(result.current.containerProps.role).toBe('grid');
29
+ expect(result.current.containerProps['aria-orientation']).toBeUndefined();
30
+ expect(result.current.itemProps().role).toBe('row');
31
+ });
32
+
33
+ test('disabled item carries aria-disabled', ({ expect }) => {
34
+ const { result } = renderHook(() => useListNavigation({ mode: 'listbox' }));
35
+ expect(result.current.itemProps({ disabled: true })['aria-disabled']).toBe(true);
36
+ expect(result.current.itemProps()['aria-disabled']).toBeUndefined();
37
+ });
38
+
39
+ test('emits Tabster data attributes on the container', ({ expect }) => {
40
+ const { result } = renderHook(() => useListNavigation({ mode: 'listbox' }));
41
+ const tabsterKeys = Object.keys(result.current.containerProps).filter((key) => key.startsWith('data-tabster'));
42
+ expect(tabsterKeys.length).toBeGreaterThan(0);
43
+ });
44
+ });
@@ -0,0 +1,160 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type TabsterDOMAttribute, useArrowNavigationGroup } from '@fluentui/react-tabster';
6
+ import { type FocusEvent, useCallback, useMemo } from 'react';
7
+
8
+ export type ListNavigationMode = 'list' | 'listbox' | 'grid';
9
+
10
+ export type UseListNavigationOptions = {
11
+ /**
12
+ * Determines the bundled ARIA role + keyboard wiring.
13
+ *
14
+ * - `list` — `role='list'` items are `role='listitem'`. Arrow keys move focus among
15
+ * interactive descendants of the row (handles, buttons). No selection semantics.
16
+ * - `listbox` — `role='listbox'` items are `role='option'`. Arrow keys move focus
17
+ * between options; `memorizeCurrent` keeps the last-focused option when re-entering.
18
+ * - `grid` — `role='grid'` items are `role='row'`. Two-axis arrow navigation.
19
+ */
20
+ mode: ListNavigationMode;
21
+ /**
22
+ * Override default axis. `list` and `listbox` default to `'vertical'`; `grid`
23
+ * defaults to `'grid'` (two-axis). Set explicitly when a horizontal list is needed.
24
+ */
25
+ axis?: 'vertical' | 'horizontal' | 'grid' | 'grid-linear' | 'both';
26
+ /**
27
+ * Remember the last-focused item when re-entering the group. Defaults to `true`.
28
+ * Only meaningful for `listbox` mode.
29
+ */
30
+ memorizeCurrent?: boolean;
31
+ };
32
+
33
+ type ContainerRole = 'list' | 'listbox' | 'grid';
34
+ type ItemRole = 'listitem' | 'option' | 'row';
35
+
36
+ export type UseListNavigationReturn = {
37
+ /**
38
+ * Spread onto the container element to apply role + ARIA + Tabster attributes.
39
+ * Also wires a focus-on-entry handler that redirects to the selected or first
40
+ * focusable item — Tabster doesn't cover initial focus, only traversal.
41
+ *
42
+ * The shape is intentionally open — Tabster's `useArrowNavigationGroup` returns
43
+ * `TabsterDOMAttribute` (one or more `data-tabster*` attributes that may be undefined
44
+ * when the runtime is disabled), and the precise key set isn't part of Tabster's
45
+ * stable contract.
46
+ */
47
+ containerProps: TabsterDOMAttribute & {
48
+ role: ContainerRole;
49
+ 'aria-orientation'?: 'vertical' | 'horizontal';
50
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
51
+ };
52
+ /**
53
+ * Apply to each item. Returns role, tabIndex, and aria-disabled. Disabled options remain
54
+ * focusable so screen readers can announce them, per WAI-ARIA listbox guidance.
55
+ */
56
+ itemProps: (opts?: { disabled?: boolean }) => {
57
+ role: ItemRole;
58
+ tabIndex: number;
59
+ 'aria-disabled'?: true;
60
+ };
61
+ };
62
+
63
+ const containerRoleByMode: Record<ListNavigationMode, ContainerRole> = {
64
+ list: 'list',
65
+ listbox: 'listbox',
66
+ grid: 'grid',
67
+ };
68
+
69
+ const itemRoleByMode: Record<ListNavigationMode, ItemRole> = {
70
+ list: 'listitem',
71
+ listbox: 'option',
72
+ grid: 'row',
73
+ };
74
+
75
+ const defaultAxisByMode: Record<ListNavigationMode, 'vertical' | 'horizontal' | 'grid'> = {
76
+ list: 'vertical',
77
+ listbox: 'vertical',
78
+ grid: 'grid',
79
+ };
80
+
81
+ /**
82
+ * Find the focus-on-entry target inside a listbox container: the currently-selected
83
+ * option, or the first non-disabled option. Used to give arrow-key navigation a
84
+ * meaningful starting point when focus first lands on the listbox itself.
85
+ */
86
+ const findListboxEntryTarget = (container: HTMLElement): HTMLElement | null => {
87
+ return (
88
+ container.querySelector<HTMLElement>('[role="option"][aria-selected="true"]:not([aria-disabled="true"])') ??
89
+ container.querySelector<HTMLElement>('[role="option"]:not([aria-disabled="true"])')
90
+ );
91
+ };
92
+
93
+ /**
94
+ * Keyboard navigation + ARIA role aspect for list-shaped surfaces. Wraps Tabster's
95
+ * `useArrowNavigationGroup` with a `mode` that selects the appropriate role bundle
96
+ * and adds a focus-on-entry redirect (Tabster handles traversal once focus is on a
97
+ * child; first-entry is the consumer's responsibility).
98
+ *
99
+ * Canonical for all list-shaped surfaces (List, OrderedList, RowList, Tree,
100
+ * Combobox.List, Mosaic.Stack). Non-list focus zones — e.g. Composer's multi-pane
101
+ * chrome — should keep their own Tabster wiring (Focus.Group).
102
+ */
103
+ export const useListNavigation = ({
104
+ mode,
105
+ axis,
106
+ memorizeCurrent = true,
107
+ }: UseListNavigationOptions): UseListNavigationReturn => {
108
+ const tabsterAttrs = useArrowNavigationGroup({
109
+ axis: axis ?? defaultAxisByMode[mode],
110
+ memorizeCurrent,
111
+ });
112
+
113
+ const handleFocus = useCallback(
114
+ (event: FocusEvent<HTMLElement>) => {
115
+ if (event.target !== event.currentTarget) {
116
+ // Focus is already on a descendant; Tabster handles traversal from here.
117
+ return;
118
+ }
119
+ if (mode !== 'listbox') {
120
+ return;
121
+ }
122
+ // First-time entry on the listbox itself: redirect focus into a meaningful child
123
+ // so arrow keys have an immediate starting point.
124
+ const target = findListboxEntryTarget(event.currentTarget);
125
+ target?.focus();
126
+ },
127
+ [mode],
128
+ );
129
+
130
+ // `aria-orientation` only accepts 'vertical' or 'horizontal'. Tabster's `axis` permits
131
+ // grid-shaped values too ('grid', 'grid-linear', 'both'); collapse those (and the grid mode
132
+ // itself) so we never leak an invalid ARIA value into the DOM.
133
+ const orientation: 'vertical' | 'horizontal' | undefined =
134
+ mode === 'grid' ? undefined : axis === 'horizontal' ? 'horizontal' : 'vertical';
135
+
136
+ const containerProps = useMemo(
137
+ () => ({
138
+ role: containerRoleByMode[mode],
139
+ ...(orientation && { 'aria-orientation': orientation }),
140
+ ...tabsterAttrs,
141
+ onFocus: handleFocus,
142
+ }),
143
+ [mode, orientation, tabsterAttrs, handleFocus],
144
+ );
145
+
146
+ // Listbox items need tabIndex=0 so Tabster can focus them; list/grid items inherit
147
+ // their tabIndex from their interactive descendants (button-shaped handles, links).
148
+ const itemRole = itemRoleByMode[mode];
149
+ const itemTabIndex = mode === 'listbox' ? 0 : -1;
150
+ const itemProps = useCallback(
151
+ ({ disabled }: { disabled?: boolean } = {}) => ({
152
+ role: itemRole,
153
+ tabIndex: itemTabIndex,
154
+ ...(disabled && { 'aria-disabled': true as const }),
155
+ }),
156
+ [itemRole, itemTabIndex],
157
+ );
158
+
159
+ return { containerProps, itemProps };
160
+ };