@gtkx/components 1.0.0-rc.4 → 1.1.0

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 (86) hide show
  1. package/README.md +5 -5
  2. package/dist/column-view.d.ts +2 -2
  3. package/dist/column-view.d.ts.map +1 -1
  4. package/dist/column-view.js +14 -12
  5. package/dist/column-view.js.map +1 -1
  6. package/dist/grid-view.js +1 -1
  7. package/dist/grid-view.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/internal/cells.d.ts +31 -23
  12. package/dist/internal/cells.d.ts.map +1 -1
  13. package/dist/internal/cells.js +168 -77
  14. package/dist/internal/cells.js.map +1 -1
  15. package/dist/internal/collection-index.d.ts +8 -11
  16. package/dist/internal/collection-index.d.ts.map +1 -1
  17. package/dist/internal/collection-index.js +53 -73
  18. package/dist/internal/collection-index.js.map +1 -1
  19. package/dist/internal/collection-model.d.ts +24 -4
  20. package/dist/internal/collection-model.d.ts.map +1 -1
  21. package/dist/internal/collection-model.js +247 -96
  22. package/dist/internal/collection-model.js.map +1 -1
  23. package/dist/internal/collection.d.ts +8 -10
  24. package/dist/internal/collection.d.ts.map +1 -1
  25. package/dist/internal/collection.js +38 -48
  26. package/dist/internal/collection.js.map +1 -1
  27. package/dist/internal/controlled-sync.d.ts +11 -0
  28. package/dist/internal/controlled-sync.d.ts.map +1 -0
  29. package/dist/internal/controlled-sync.js +19 -0
  30. package/dist/internal/controlled-sync.js.map +1 -0
  31. package/dist/internal/drop-down.d.ts.map +1 -1
  32. package/dist/internal/drop-down.js +27 -15
  33. package/dist/internal/drop-down.js.map +1 -1
  34. package/dist/internal/expansion.d.ts +3 -2
  35. package/dist/internal/expansion.d.ts.map +1 -1
  36. package/dist/internal/expansion.js +73 -48
  37. package/dist/internal/expansion.js.map +1 -1
  38. package/dist/internal/keys.d.ts +5 -0
  39. package/dist/internal/keys.d.ts.map +1 -0
  40. package/dist/internal/keys.js +13 -0
  41. package/dist/internal/keys.js.map +1 -0
  42. package/dist/internal/selection.d.ts +3 -2
  43. package/dist/internal/selection.d.ts.map +1 -1
  44. package/dist/internal/selection.js +68 -35
  45. package/dist/internal/selection.js.map +1 -1
  46. package/dist/internal/slots.d.ts +10 -0
  47. package/dist/internal/slots.d.ts.map +1 -0
  48. package/dist/internal/slots.js +43 -0
  49. package/dist/internal/slots.js.map +1 -0
  50. package/dist/internal/tree-expansion.d.ts +19 -0
  51. package/dist/internal/tree-expansion.d.ts.map +1 -0
  52. package/dist/internal/tree-expansion.js +72 -0
  53. package/dist/internal/tree-expansion.js.map +1 -0
  54. package/dist/internal/tree-order.d.ts +26 -0
  55. package/dist/internal/tree-order.d.ts.map +1 -0
  56. package/dist/internal/tree-order.js +109 -0
  57. package/dist/internal/tree-order.js.map +1 -0
  58. package/dist/internal/use-collection.d.ts +1 -1
  59. package/dist/internal/use-collection.d.ts.map +1 -1
  60. package/dist/internal/use-collection.js +4 -4
  61. package/dist/internal/use-collection.js.map +1 -1
  62. package/dist/list-view.d.ts.map +1 -1
  63. package/dist/list-view.js +6 -6
  64. package/dist/list-view.js.map +1 -1
  65. package/dist/types.d.ts +72 -22
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +17 -9
  69. package/src/column-view.tsx +28 -25
  70. package/src/grid-view.tsx +1 -1
  71. package/src/index.ts +3 -0
  72. package/src/internal/cells.tsx +339 -117
  73. package/src/internal/collection-index.ts +68 -92
  74. package/src/internal/collection-model.ts +327 -112
  75. package/src/internal/collection.ts +45 -65
  76. package/src/internal/controlled-sync.ts +34 -0
  77. package/src/internal/drop-down.tsx +41 -20
  78. package/src/internal/expansion.ts +95 -70
  79. package/src/internal/keys.ts +18 -0
  80. package/src/internal/selection.tsx +100 -51
  81. package/src/internal/slots.ts +67 -0
  82. package/src/internal/tree-expansion.ts +115 -0
  83. package/src/internal/tree-order.ts +208 -0
  84. package/src/internal/use-collection.tsx +5 -5
  85. package/src/list-view.tsx +14 -10
  86. package/src/types.ts +78 -20
@@ -2,15 +2,19 @@ import type * as Gio from "@gtkx/gi/gio";
2
2
  import type { ReactElement } from "react";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
4
  import { GtkMultiSelection, GtkNoSelection, GtkSingleSelection } from "@gtkx/jsx/gtk";
5
- import { useEffectEvent, useLayoutEffect, useState } from "react";
5
+ import { useState } from "react";
6
6
  import type { Collection } from "./collection.js";
7
+ import type { ItemsChangeHandler } from "./expansion.js";
8
+ import { isCollectionIdle } from "./collection.js";
9
+ import { useControlledSync } from "./controlled-sync.js";
10
+ import { joinParts } from "./keys.js";
7
11
 
8
12
  type SelectionOptions = {
9
13
  collection: Collection;
10
14
  selectedIds?: string[] | null | undefined;
11
15
  onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
12
16
  selectionMode?: Gtk.SelectionMode | null | undefined;
13
- onItemsChanged: () => void;
17
+ onItemsChanged: ItemsChangeHandler;
14
18
  };
15
19
 
16
20
  type LastSelection = {
@@ -18,29 +22,60 @@ type LastSelection = {
18
22
  key: string | null;
19
23
  };
20
24
 
25
+ type SelectionContext = {
26
+ selection: Gtk.SelectionModel | null;
27
+ collection: Collection;
28
+ last: LastSelection;
29
+ onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
30
+ };
31
+
21
32
  type SelectionElementProps = {
22
33
  ref: (value: Gtk.SelectionModel | null) => void;
23
34
  model: Gio.ListModel;
24
35
  onSelectionChanged: () => void;
25
- onItemsChanged: () => void;
36
+ onItemsChanged: ItemsChangeHandler;
26
37
  };
27
38
 
28
- function getSelectedIds(selection: Gtk.SelectionModel, collection: Collection): string[] {
39
+ function selectedPositions(selection: Gtk.SelectionModel): number[] {
29
40
  const bitset = selection.getSelection();
30
41
  const size = Number(bitset.getSize());
31
- const ids: string[] = [];
42
+ const positions: number[] = [];
32
43
 
33
44
  for (let index = 0; index < size; index++) {
34
- pushId(collection.idAt(bitset.getNth(index)), ids);
45
+ positions.push(bitset.getNth(index));
46
+ }
47
+
48
+ return positions;
49
+ }
50
+
51
+ function idsAt(collection: Collection, positions: number[]): string[] {
52
+ const ids: string[] = [];
53
+
54
+ for (const position of positions) {
55
+ const id = collection.idAt(position);
56
+
57
+ if (id !== null) {
58
+ ids.push(id);
59
+ }
35
60
  }
36
61
 
37
62
  return ids;
38
63
  }
39
64
 
40
- function pushId(id: string | null, ids: string[]): void {
41
- if (id !== null) {
42
- ids.push(id);
65
+ function plannedPositions(context: SelectionContext, ids: string[]): number[] | null {
66
+ const { selection, collection } = context;
67
+
68
+ if (selection === null || selection instanceof Gtk.NoSelection) {
69
+ return null;
43
70
  }
71
+
72
+ const positions = collection.positionsFor(ids);
73
+
74
+ if (ids.length > 0 && positions.length === 0) {
75
+ return null;
76
+ }
77
+
78
+ return selection instanceof Gtk.SingleSelection ? positions.slice(0, 1) : positions;
44
79
  }
45
80
 
46
81
  function applySingleSelection(selection: Gtk.SingleSelection, positions: number[]): void {
@@ -65,17 +100,7 @@ function applyMultiSelection(selection: Gtk.SelectionModel, positions: number[])
65
100
  selection.setSelection(selected, Gtk.Bitset.newRange(0, selection.getNItems()));
66
101
  }
67
102
 
68
- function applySelection(selection: Gtk.SelectionModel, collection: Collection, ids: string[]): void {
69
- if (selection instanceof Gtk.NoSelection) {
70
- return;
71
- }
72
-
73
- const positions = collection.positionsFor(ids);
74
-
75
- if (ids.length > 0 && positions.length === 0) {
76
- return;
77
- }
78
-
103
+ function applySelection(selection: Gtk.SelectionModel, positions: number[]): void {
79
104
  if (selection instanceof Gtk.SingleSelection) {
80
105
  applySingleSelection(selection, positions);
81
106
 
@@ -85,26 +110,20 @@ function applySelection(selection: Gtk.SelectionModel, collection: Collection, i
85
110
  applyMultiSelection(selection, positions);
86
111
  }
87
112
 
88
- function reportSelection(
89
- selection: Gtk.SelectionModel | null,
90
- collection: Collection,
91
- last: LastSelection,
92
- onSelectionChanged: ((ids: string[]) => void) | null | undefined,
93
- ): void {
94
- if (selection === null) {
95
- return;
96
- }
97
-
98
- const ids = getSelectedIds(selection, collection);
99
- const key = ids.join(" ");
113
+ function reportSelection(context: SelectionContext, ids: string[]): void {
114
+ const key = joinParts(ids);
100
115
 
101
- if (last.selection === selection && last.key === key) {
116
+ if (context.last.selection === context.selection && context.last.key === key) {
102
117
  return;
103
118
  }
104
119
 
105
- last.selection = selection;
106
- last.key = key;
107
- onSelectionChanged?.(ids);
120
+ context.last.selection = context.selection;
121
+ context.last.key = key;
122
+ context.onSelectionChanged?.(ids);
123
+ }
124
+
125
+ function hasSelectionDrifted(actual: number[], planned: number[]): boolean {
126
+ return actual.length !== planned.length || planned.some((position, index) => actual[index] !== position);
108
127
  }
109
128
 
110
129
  function selectionElement(mode: Gtk.SelectionMode | null | undefined, props: SelectionElementProps): ReactElement {
@@ -119,16 +138,45 @@ function selectionElement(mode: Gtk.SelectionMode | null | undefined, props: Sel
119
138
  return <GtkSingleSelection {...props} autoselect={false} canUnselect />;
120
139
  }
121
140
 
122
- function syncSelection(
123
- selection: Gtk.SelectionModel | null,
124
- collection: Collection,
141
+ function applyControlledSelection(context: SelectionContext, selectedIds: string[] | null | undefined): void {
142
+ const { selection, collection } = context;
143
+
144
+ if (selection === null) {
145
+ return;
146
+ }
147
+
148
+ const planned = plannedPositions(context, selectedIds ?? []);
149
+
150
+ if (planned !== null) {
151
+ applySelection(selection, planned);
152
+ }
153
+
154
+ reportSelection(context, idsAt(collection, selectedPositions(selection)));
155
+ }
156
+
157
+ function observeSelection(
158
+ context: SelectionContext,
125
159
  selectedIds: string[] | null | undefined,
160
+ onDrift: () => void,
126
161
  ): void {
127
- if (selection === null || selectedIds == null) {
162
+ const { selection, collection } = context;
163
+
164
+ if (selection === null) {
165
+ return;
166
+ }
167
+
168
+ const positions = selectedPositions(selection);
169
+ reportSelection(context, idsAt(collection, positions));
170
+
171
+ if (!isCollectionIdle(collection)) {
128
172
  return;
129
173
  }
130
174
 
131
- applySelection(selection, collection, selectedIds);
175
+ const planned = plannedPositions(context, selectedIds ?? []);
176
+
177
+ if (planned !== null && hasSelectionDrifted(positions, planned)) {
178
+ onDrift();
179
+ }
132
180
  }
133
181
 
134
182
  function newLastSelection(): LastSelection {
@@ -139,24 +187,25 @@ function useSelection(options: SelectionOptions): ReactElement {
139
187
  const { collection, selectedIds, onSelectionChanged, selectionMode } = options;
140
188
  const [selection, setSelection] = useState<Gtk.SelectionModel | null>(null);
141
189
  const [last] = useState<LastSelection>(newLastSelection);
142
-
143
- const report = useEffectEvent((): void => {
144
- reportSelection(selection, collection, last, onSelectionChanged);
190
+ const context: SelectionContext = { selection, collection, last, onSelectionChanged };
191
+
192
+ const markDrift = useControlledSync({
193
+ ids: selectedIds,
194
+ collection,
195
+ widget: selection,
196
+ apply: (ids) => {
197
+ applyControlledSelection(context, ids);
198
+ },
145
199
  });
146
200
 
147
- useLayoutEffect(() => {
148
- syncSelection(selection, collection, selectedIds);
149
- report();
150
- }, [selection, collection, selectedIds]);
151
-
152
201
  return selectionElement(selectionMode, {
153
202
  ref: setSelection,
154
203
  model: collection.model,
155
204
  onSelectionChanged: () => {
156
- reportSelection(selection, collection, last, onSelectionChanged);
205
+ observeSelection(context, selectedIds, markDrift);
157
206
  },
158
207
  onItemsChanged: options.onItemsChanged,
159
208
  });
160
209
  }
161
210
 
162
- export { useSelection, type SelectionOptions };
211
+ export { useSelection };
@@ -0,0 +1,67 @@
1
+ import { decodePartAt, encodePart } from "./keys.js";
2
+
3
+ type SlotKey = {
4
+ levelPath: string;
5
+ slot: number;
6
+ };
7
+
8
+ type SlotMap = Map<string, Set<number>>;
9
+
10
+ function getLevelBoundary(path: string): number {
11
+ let offset = 0;
12
+ let boundary = 0;
13
+
14
+ while (offset < path.length) {
15
+ const part = decodePartAt(path, offset);
16
+
17
+ if (part === null) {
18
+ return boundary;
19
+ }
20
+
21
+ boundary = offset;
22
+ offset += encodePart(part).length;
23
+ }
24
+
25
+ return boundary;
26
+ }
27
+
28
+ function getSlotKey(path: string): SlotKey | null {
29
+ const boundary = getLevelBoundary(path);
30
+ const part = decodePartAt(path, boundary);
31
+
32
+ if (part === null) {
33
+ return null;
34
+ }
35
+
36
+ return { levelPath: path.slice(0, boundary), slot: Number(part) };
37
+ }
38
+
39
+ function trackPath(slots: SlotMap, path: string): void {
40
+ const key = getSlotKey(path);
41
+
42
+ if (key === null) {
43
+ return;
44
+ }
45
+
46
+ const level = slots.get(key.levelPath);
47
+
48
+ if (level === undefined) {
49
+ slots.set(key.levelPath, new Set([key.slot]));
50
+
51
+ return;
52
+ }
53
+
54
+ level.add(key.slot);
55
+ }
56
+
57
+ function trackPaths(paths: Iterable<string>): SlotMap {
58
+ const slots: SlotMap = new Map();
59
+
60
+ for (const path of paths) {
61
+ trackPath(slots, path);
62
+ }
63
+
64
+ return slots;
65
+ }
66
+
67
+ export { getSlotKey, trackPath, trackPaths, type SlotMap };
@@ -0,0 +1,115 @@
1
+ import type { CollectionIndex } from "./collection-index.js";
2
+ import type { SlotMap } from "./slots.js";
3
+ import type { VisibleOrder } from "./tree-order.js";
4
+ import { encodePart } from "./keys.js";
5
+ import { getSlotKey, trackPath, trackPaths } from "./slots.js";
6
+ import { buildVisibleOrder } from "./tree-order.js";
7
+
8
+ type TreeExpansion = {
9
+ index: CollectionIndex;
10
+ expanded: Set<string>;
11
+ slots: SlotMap;
12
+ order: VisibleOrder | null;
13
+ isApplying: boolean;
14
+ isSyncing: boolean;
15
+ };
16
+
17
+ function dropLevel(expansion: TreeExpansion, path: string, pending: string[]): void {
18
+ expansion.expanded.delete(path);
19
+ const level = expansion.slots.get(path);
20
+
21
+ if (level === undefined) {
22
+ return;
23
+ }
24
+
25
+ expansion.slots.delete(path);
26
+
27
+ for (const slot of level) {
28
+ pending.push(path + encodePart(String(slot)));
29
+ }
30
+ }
31
+
32
+ function dropSubtree(expansion: TreeExpansion, path: string): void {
33
+ const pending: string[] = [path];
34
+
35
+ while (pending.length > 0) {
36
+ const current = pending.pop();
37
+
38
+ if (current !== undefined) {
39
+ dropLevel(expansion, current, pending);
40
+ }
41
+ }
42
+ }
43
+
44
+ function createTreeExpansion(index: CollectionIndex): TreeExpansion {
45
+ return { index, expanded: new Set(), slots: new Map(), order: null, isApplying: false, isSyncing: false };
46
+ }
47
+
48
+ function orderFor(expansion: TreeExpansion): VisibleOrder {
49
+ expansion.order ??= buildVisibleOrder(expansion.index, expansion.slots);
50
+
51
+ return expansion.order;
52
+ }
53
+
54
+ function adoptOrder(expansion: TreeExpansion, order: VisibleOrder): void {
55
+ expansion.expanded = new Set(order.expandedPaths);
56
+ expansion.slots = trackPaths(order.expandedPaths);
57
+ expansion.order = order;
58
+ }
59
+
60
+ function prunePath(expansion: TreeExpansion, path: string): void {
61
+ const key = getSlotKey(path);
62
+
63
+ if (key !== null) {
64
+ expansion.slots.get(key.levelPath)?.delete(key.slot);
65
+ }
66
+
67
+ dropSubtree(expansion, path);
68
+ expansion.order = null;
69
+ }
70
+
71
+ function pruneSlots(expansion: TreeExpansion, levelPath: string, isPruned: (slot: number) => boolean): void {
72
+ const level = expansion.slots.get(levelPath);
73
+
74
+ if (level === undefined) {
75
+ return;
76
+ }
77
+
78
+ for (const slot of level) {
79
+ if (!isPruned(slot)) {
80
+ continue;
81
+ }
82
+
83
+ level.delete(slot);
84
+ dropSubtree(expansion, levelPath + encodePart(String(slot)));
85
+ }
86
+
87
+ expansion.order = null;
88
+ }
89
+
90
+ function markExpanded(expansion: TreeExpansion, path: string, isExpanded: boolean): void {
91
+ if (isExpanded) {
92
+ expansion.expanded.add(path);
93
+ trackPath(expansion.slots, path);
94
+ expansion.order = null;
95
+
96
+ return;
97
+ }
98
+
99
+ prunePath(expansion, path);
100
+ }
101
+
102
+ function adoptIndex(expansion: TreeExpansion, index: CollectionIndex): void {
103
+ expansion.index = index;
104
+ expansion.order = null;
105
+ }
106
+
107
+ export {
108
+ adoptIndex,
109
+ adoptOrder,
110
+ createTreeExpansion,
111
+ markExpanded,
112
+ orderFor,
113
+ pruneSlots,
114
+ type TreeExpansion,
115
+ };
@@ -0,0 +1,208 @@
1
+ import type { ListItem } from "../types.js";
2
+ import type { CollectionIndex, Level } from "./collection-index.js";
3
+ import type { SlotMap } from "./slots.js";
4
+
5
+ type VisibleRow = {
6
+ item: ListItem;
7
+ position: number;
8
+ isOpen: boolean;
9
+ isMarked: boolean;
10
+ };
11
+
12
+ type VisibleOrder = {
13
+ expandedPaths: string[];
14
+ expandedIds: string[];
15
+ };
16
+
17
+ type RowVisitor = (row: VisibleRow) => void;
18
+
19
+ type WalkOptions = {
20
+ index: CollectionIndex;
21
+ slots: SlotMap;
22
+ marks?: SlotMap | undefined;
23
+ visit?: RowVisitor | undefined;
24
+ };
25
+
26
+ type LevelFrame = {
27
+ level: Level;
28
+ cursor: number;
29
+ };
30
+
31
+ type WalkFrame = LevelFrame & {
32
+ open: Set<number> | undefined;
33
+ marked: Set<number> | undefined;
34
+ };
35
+
36
+ type WalkState = {
37
+ options: WalkOptions;
38
+ stack: WalkFrame[];
39
+ row: VisibleRow;
40
+ order: VisibleOrder;
41
+ };
42
+
43
+ type OpenFrame = LevelFrame & {
44
+ owner: ListItem | null;
45
+ };
46
+
47
+ type OpenWalk = {
48
+ index: CollectionIndex;
49
+ ids: Set<string>;
50
+ paths: Set<string>;
51
+ stack: OpenFrame[];
52
+ ancestors: Set<ListItem>;
53
+ };
54
+
55
+ const NO_ITEM: ListItem = { id: "", value: undefined };
56
+
57
+ function advanceStack<F extends LevelFrame>(
58
+ stack: F[],
59
+ onSlot: (frame: F, slot: number) => void,
60
+ onLeave?: (frame: F) => void,
61
+ ): void {
62
+ const frame = stack.at(-1);
63
+
64
+ if (frame === undefined) {
65
+ return;
66
+ }
67
+
68
+ if (frame.cursor >= frame.level.items.length) {
69
+ stack.pop();
70
+ onLeave?.(frame);
71
+
72
+ return;
73
+ }
74
+
75
+ const slot = frame.cursor;
76
+ frame.cursor += 1;
77
+ onSlot(frame, slot);
78
+ }
79
+
80
+ function frameFor(options: WalkOptions, level: Level): WalkFrame {
81
+ return { level, cursor: 0, open: options.slots.get(level.path), marked: options.marks?.get(level.path) };
82
+ }
83
+
84
+ function descend(state: WalkState, level: Level, slot: number): void {
85
+ const child = state.options.index.childLevel(level, slot);
86
+
87
+ if (child === undefined) {
88
+ return;
89
+ }
90
+
91
+ state.order.expandedPaths.push(child.path);
92
+ state.order.expandedIds.push(state.row.item.id);
93
+ state.stack.push(frameFor(state.options, child));
94
+ }
95
+
96
+ function visitSlot(state: WalkState, frame: WalkFrame, slot: number): void {
97
+ const item = frame.level.items[slot];
98
+
99
+ if (item === undefined) {
100
+ return;
101
+ }
102
+
103
+ const { row } = state;
104
+ row.item = item;
105
+ row.isOpen = frame.open?.has(slot) ?? false;
106
+ row.isMarked = frame.marked?.has(slot) ?? false;
107
+ state.options.visit?.(row);
108
+ row.position += 1;
109
+
110
+ if (row.isOpen) {
111
+ descend(state, frame.level, slot);
112
+ }
113
+ }
114
+
115
+ function walkVisible(options: WalkOptions): VisibleOrder {
116
+ const order: VisibleOrder = { expandedPaths: [], expandedIds: [] };
117
+ const row: VisibleRow = { item: NO_ITEM, position: 0, isOpen: false, isMarked: false };
118
+ const frames = options.index.groups.map((level) => frameFor(options, level));
119
+ const state: WalkState = { options, stack: frames.toReversed(), row, order };
120
+
121
+ const onSlot = (frame: WalkFrame, slot: number): void => {
122
+ visitSlot(state, frame, slot);
123
+ };
124
+
125
+ while (state.stack.length > 0) {
126
+ advanceStack(state.stack, onSlot);
127
+ }
128
+
129
+ return order;
130
+ }
131
+
132
+ function openSlot(walk: OpenWalk, frame: OpenFrame, slot: number): void {
133
+ const item = frame.level.items[slot];
134
+
135
+ if (item === undefined || !walk.ids.has(item.id) || walk.ancestors.has(item)) {
136
+ return;
137
+ }
138
+
139
+ const child = walk.index.childLevel(frame.level, slot);
140
+
141
+ if (child === undefined) {
142
+ return;
143
+ }
144
+
145
+ walk.paths.add(child.path);
146
+ walk.ancestors.add(item);
147
+ walk.stack.push({ level: child, cursor: 0, owner: item });
148
+ }
149
+
150
+ function leaveOpenFrame(walk: OpenWalk, frame: OpenFrame): void {
151
+ if (frame.owner !== null) {
152
+ walk.ancestors.delete(frame.owner);
153
+ }
154
+ }
155
+
156
+ function expandedPathsFor(index: CollectionIndex, ids: Set<string>): Set<string> {
157
+ const paths: Set<string> = new Set();
158
+
159
+ if (ids.size === 0 || !index.isTree) {
160
+ return paths;
161
+ }
162
+
163
+ const frames = index.groups.map((level) => ({ level, cursor: 0, owner: null }));
164
+ const walk: OpenWalk = { index, ids, paths, stack: frames.toReversed(), ancestors: new Set() };
165
+
166
+ const onSlot = (frame: OpenFrame, slot: number): void => {
167
+ openSlot(walk, frame, slot);
168
+ };
169
+
170
+ const onLeave = (frame: OpenFrame): void => {
171
+ leaveOpenFrame(walk, frame);
172
+ };
173
+
174
+ while (walk.stack.length > 0) {
175
+ advanceStack(walk.stack, onSlot, onLeave);
176
+ }
177
+
178
+ return paths;
179
+ }
180
+
181
+ function buildVisibleOrder(index: CollectionIndex, slots: SlotMap): VisibleOrder {
182
+ return walkVisible({ index, slots });
183
+ }
184
+
185
+ function findPositions(index: CollectionIndex, slots: SlotMap, ids: Set<string>): number[] {
186
+ const found: number[] = [];
187
+
188
+ walkVisible({
189
+ index,
190
+ slots,
191
+ visit: (row) => {
192
+ if (ids.has(row.item.id)) {
193
+ found.push(row.position);
194
+ }
195
+ },
196
+ });
197
+
198
+ return found;
199
+ }
200
+
201
+ export {
202
+ buildVisibleOrder,
203
+ expandedPathsFor,
204
+ findPositions,
205
+ walkVisible,
206
+ type VisibleOrder,
207
+ type WalkOptions,
208
+ };
@@ -23,13 +23,13 @@ type CollectionResult = {
23
23
 
24
24
  function useCollectionData(options: CollectionDataOptions): Collection {
25
25
  const { items, sections, isFlat } = options;
26
- const [gtk] = useState(createCollectionModel);
26
+ const [collectionModel] = useState(createCollectionModel);
27
27
  const index = useMemo(() => createCollectionIndex(items, sections, isFlat === true), [items, sections, isFlat]);
28
- const collection = useMemo(() => createCollection(gtk, index), [gtk, index]);
28
+ const collection = useMemo(() => createCollection(collectionModel, index), [collectionModel, index]);
29
29
 
30
30
  useLayoutEffect(() => {
31
- gtk.sync(index);
32
- }, [gtk, index]);
31
+ collectionModel.sync(index);
32
+ }, [collectionModel, index]);
33
33
 
34
34
  return collection;
35
35
  }
@@ -54,4 +54,4 @@ function useCollection(options: CollectionOptions): CollectionResult {
54
54
  return { collection, selection };
55
55
  }
56
56
 
57
- export { useCollection, useCollectionData, type CollectionDataOptions, type CollectionOptions };
57
+ export { useCollection, useCollectionData };
package/src/list-view.tsx CHANGED
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
2
2
  import { GtkListView, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
3
3
  import { omit } from "@gtkx/utils";
4
4
  import type { ListViewProps } from "./types.js";
5
- import { HeaderPortals, ItemPortals, useHeaderCells, useItemCells } from "./internal/cells.js";
5
+ import { ItemPortals, useItemCells, useSectionHeader } from "./internal/cells.js";
6
6
  import { useCollection } from "./internal/use-collection.js";
7
7
 
8
8
  const LIST_VIEW_PROPS = [
@@ -15,6 +15,7 @@ const LIST_VIEW_PROPS = [
15
15
  "selectionMode",
16
16
  "expandedIds",
17
17
  "onExpandedChange",
18
+ "expanderDescriptions",
18
19
  "estimatedItemHeight",
19
20
  "estimatedItemWidth",
20
21
  ] as const satisfies (keyof ListViewProps)[];
@@ -24,27 +25,30 @@ const LIST_VIEW_PROPS = [
24
25
  * controlled selection, controlled tree expansion, and estimated item sizing.
25
26
  */
26
27
  function ListView<T = unknown, S = unknown>(props: ListViewProps<T, S>): ReactNode {
27
- const { renderItem, renderHeader, expandedIds, estimatedItemHeight, estimatedItemWidth } = props;
28
+ const { renderItem, renderHeader, expandedIds, expanderDescriptions } = props;
29
+ const { estimatedItemHeight, estimatedItemWidth } = props;
28
30
  const rest = omit(props, LIST_VIEW_PROPS);
29
31
  const size = { width: estimatedItemWidth ?? -1, height: estimatedItemHeight ?? -1 };
30
32
  const { collection, selection } = useCollection(props);
31
33
  const itemCells = useItemCells(size);
32
- const headerCells = useHeaderCells(size);
34
+ const header = useSectionHeader(renderHeader, collection, size);
33
35
 
34
36
  return (
35
37
  <>
36
38
  <GtkListView
37
39
  model={selection}
38
40
  factory={<GtkSignalListItemFactory {...itemCells.handlers} />}
39
- {...(renderHeader != null && {
40
- headerFactory: <GtkSignalListItemFactory {...headerCells.handlers} />,
41
- })}
41
+ {...header.factoryProps}
42
42
  {...rest}
43
43
  />
44
- <ItemPortals store={itemCells} render={renderItem} collection={collection} expandedIds={expandedIds} />
45
- {renderHeader != null && (
46
- <HeaderPortals store={headerCells} render={renderHeader} collection={collection} />
47
- )}
44
+ <ItemPortals
45
+ registry={itemCells}
46
+ render={renderItem}
47
+ collection={collection}
48
+ expandedIds={expandedIds}
49
+ expanderDescriptions={expanderDescriptions}
50
+ />
51
+ {header.portals}
48
52
  </>
49
53
  );
50
54
  }