@gtkx/components 2.0.0-beta.10 → 2.0.0-beta.12

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 +8 -168
  2. package/dist/column-view.d.ts.map +1 -1
  3. package/dist/column-view.js +20 -10
  4. package/dist/column-view.js.map +1 -1
  5. package/dist/grid-view.js +1 -1
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/internal/cells.d.ts +10 -7
  11. package/dist/internal/cells.d.ts.map +1 -1
  12. package/dist/internal/cells.js +88 -51
  13. package/dist/internal/cells.js.map +1 -1
  14. package/dist/internal/collection-index.d.ts +3 -2
  15. package/dist/internal/collection-index.d.ts.map +1 -1
  16. package/dist/internal/collection-index.js +3 -3
  17. package/dist/internal/collection-index.js.map +1 -1
  18. package/dist/internal/collection-model.d.ts +23 -2
  19. package/dist/internal/collection-model.d.ts.map +1 -1
  20. package/dist/internal/collection-model.js +118 -98
  21. package/dist/internal/collection-model.js.map +1 -1
  22. package/dist/internal/collection.d.ts +5 -4
  23. package/dist/internal/collection.d.ts.map +1 -1
  24. package/dist/internal/collection.js +20 -21
  25. package/dist/internal/collection.js.map +1 -1
  26. package/dist/internal/controlled-sync.d.ts +6 -8
  27. package/dist/internal/controlled-sync.d.ts.map +1 -1
  28. package/dist/internal/controlled-sync.js +3 -3
  29. package/dist/internal/controlled-sync.js.map +1 -1
  30. package/dist/internal/drop-down.d.ts.map +1 -1
  31. package/dist/internal/drop-down.js +44 -45
  32. package/dist/internal/drop-down.js.map +1 -1
  33. package/dist/internal/expansion.js +4 -4
  34. package/dist/internal/expansion.js.map +1 -1
  35. package/dist/internal/keys.d.ts.map +1 -1
  36. package/dist/internal/selection.d.ts +1 -0
  37. package/dist/internal/selection.d.ts.map +1 -1
  38. package/dist/internal/selection.js +6 -6
  39. package/dist/internal/selection.js.map +1 -1
  40. package/dist/internal/tree-expansion.d.ts +1 -1
  41. package/dist/internal/tree-expansion.d.ts.map +1 -1
  42. package/dist/internal/tree-expansion.js +3 -12
  43. package/dist/internal/tree-expansion.js.map +1 -1
  44. package/dist/internal/tree-order.d.ts +1 -1
  45. package/dist/internal/tree-order.d.ts.map +1 -1
  46. package/dist/internal/tree-order.js +33 -77
  47. package/dist/internal/tree-order.js.map +1 -1
  48. package/dist/internal/use-collection.d.ts +5 -1
  49. package/dist/internal/use-collection.d.ts.map +1 -1
  50. package/dist/internal/use-collection.js +21 -7
  51. package/dist/internal/use-collection.js.map +1 -1
  52. package/dist/internal/use-widget-ref.d.ts.map +1 -1
  53. package/dist/list-item-factory.d.ts +10 -0
  54. package/dist/list-item-factory.d.ts.map +1 -0
  55. package/dist/list-item-factory.js +14 -0
  56. package/dist/list-item-factory.js.map +1 -0
  57. package/dist/list-view.js +1 -1
  58. package/dist/tab-collection.d.ts +9 -0
  59. package/dist/tab-collection.d.ts.map +1 -0
  60. package/dist/tab-collection.js +114 -0
  61. package/dist/tab-collection.js.map +1 -0
  62. package/dist/toast.d.ts +3 -5
  63. package/dist/toast.d.ts.map +1 -1
  64. package/dist/toast.js +3 -12
  65. package/dist/toast.js.map +1 -1
  66. package/dist/types.d.ts +67 -51
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/types.js.map +1 -1
  69. package/package.json +13 -21
  70. package/src/column-view.tsx +23 -12
  71. package/src/index.ts +7 -2
  72. package/src/internal/cells.tsx +155 -103
  73. package/src/internal/collection-index.ts +8 -6
  74. package/src/internal/collection-model.ts +156 -134
  75. package/src/internal/collection.ts +28 -24
  76. package/src/internal/controlled-sync.ts +9 -12
  77. package/src/internal/drop-down.tsx +63 -63
  78. package/src/internal/expansion.ts +4 -4
  79. package/src/internal/selection.tsx +8 -8
  80. package/src/internal/tree-expansion.ts +3 -23
  81. package/src/internal/tree-order.ts +34 -130
  82. package/src/internal/use-collection.tsx +29 -8
  83. package/src/list-item-factory.tsx +27 -0
  84. package/src/tab-collection.tsx +124 -0
  85. package/src/toast.tsx +4 -18
  86. package/src/types.ts +81 -55
@@ -1,12 +1,12 @@
1
1
  import type * as GObject from "@gtkx/gi/gobject";
2
2
  import type { ElementType, ReactNode, Ref } from "react";
3
3
  import { GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
4
- import { useLatestRef } from "@gtkx/react/internal";
5
- import { omit } from "@gtkx/utils";
6
- import { useLayoutEffect, useRef, useState } from "react";
4
+ import { omit, type Primitive } from "@gtkx/utils";
5
+ import { useRef } from "react";
7
6
  import type { DropDownOwnProps, ListItemRenderArgs, ListItemRenderer } from "../types.js";
8
- import type { Collection } from "./collection.js";
9
7
  import { ItemPortals, useItemCells, useSectionHeader } from "./cells.js";
8
+ import { type Collection, isCollectionIdle } from "./collection.js";
9
+ import { useControlledSync } from "./controlled-sync.js";
10
10
  import { useCollectionData } from "./use-collection.js";
11
11
  import { useWidgetRef } from "./use-widget-ref.js";
12
12
 
@@ -24,17 +24,18 @@ type SelectionOptions = {
24
24
  };
25
25
 
26
26
  type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
27
- type ApplyState = { isApplying: boolean };
27
+ type ApplyState = { isApplying: boolean; isReady: boolean };
28
28
 
29
29
  type NotifyContext = {
30
30
  options: SelectionOptions;
31
31
  known: { current: string | null };
32
32
  state: ApplyState;
33
+ markDrift: () => void;
33
34
  };
34
35
 
35
36
  type KnownSelection = {
36
37
  known: { current: string | null };
37
- onSelectionChanged: ((id: string) => void) | null | undefined;
38
+ onSelectionChanged: ((id: string | null) => void) | null | undefined;
38
39
  };
39
40
 
40
41
  const DROP_DOWN_PROPS: string[] = [
@@ -51,41 +52,27 @@ const DROP_DOWN_PROPS: string[] = [
51
52
  ];
52
53
 
53
54
  function newApplyState(): ApplyState {
54
- return { isApplying: false };
55
+ return { isApplying: false, isReady: false };
55
56
  }
56
57
 
57
- const describeValue = (value: unknown): string => {
58
- if (typeof value === "string") {
59
- return value;
60
- }
58
+ const defaultRenderItem = ({ item }: ListItemRenderArgs<Primitive>): ReactNode =>
59
+ item == null ? null : <GtkLabel>{String(item)}</GtkLabel>;
60
+ const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
61
61
 
62
- try {
63
- return JSON.stringify(value);
64
- } catch {
65
- return String(value);
62
+ const resolvePosition = (widget: SelectableWidget, collection: Collection, selectedId: string | undefined): number => {
63
+ if (selectedId === undefined) {
64
+ return widget.getSelected();
66
65
  }
67
- };
68
-
69
- const defaultItemContent = (value: unknown): ReactNode =>
70
- value == null ? null : <GtkLabel>{describeValue(value)}</GtkLabel>;
71
-
72
- const defaultRenderItem = ({ item }: ListItemRenderArgs<unknown>): ReactNode => defaultItemContent(item);
73
- const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
74
66
 
75
- const resolvePosition = (
76
- widget: SelectableWidget,
77
- collection: Collection,
78
- selectedId: string | null | undefined,
79
- ): number => {
80
- const requested = selectedId == null ? -1 : collection.positionFor(selectedId);
67
+ const requested = collection.positionFor(selectedId);
81
68
 
82
69
  return requested >= 0 ? requested : widget.getSelected();
83
70
  };
84
71
 
85
72
  const updateKnownSelection = (
86
73
  tracker: KnownSelection,
87
- effectiveId: string,
88
- selectedId: string | null | undefined,
74
+ effectiveId: string | null,
75
+ selectedId: string | undefined,
89
76
  ): void => {
90
77
  const { known, onSelectionChanged } = tracker;
91
78
  const expectedId = selectedId ?? known.current;
@@ -100,7 +87,7 @@ const updateKnownSelection = (
100
87
  const reportKnownSelection = (tracker: KnownSelection, id: string | null): void => {
101
88
  const { known, onSelectionChanged } = tracker;
102
89
 
103
- if (id === null || id === known.current) {
90
+ if (id === known.current) {
104
91
  return;
105
92
  }
106
93
 
@@ -108,14 +95,18 @@ const reportKnownSelection = (tracker: KnownSelection, id: string | null): void
108
95
  onSelectionChanged?.(id);
109
96
  };
110
97
 
111
- const reportSelectedNotify = (options: SelectionOptions, tracker: KnownSelection): void => {
112
- const { widget, collection } = options;
113
-
114
- if (widget === null) {
115
- return;
98
+ const hasSelectionDrifted = (
99
+ widget: SelectableWidget,
100
+ collection: Collection,
101
+ selectedId: string | undefined,
102
+ ): boolean => {
103
+ if (selectedId === undefined) {
104
+ return false;
116
105
  }
117
106
 
118
- reportKnownSelection(tracker, collection.idAt(widget.getSelected()));
107
+ const requested = collection.positionFor(selectedId);
108
+
109
+ return requested >= 0 && widget.getSelected() !== requested;
119
110
  };
120
111
 
121
112
  const applySelectedPosition = (widget: SelectableWidget, position: number, state: ApplyState): void => {
@@ -129,49 +120,58 @@ const applySelectedPosition = (widget: SelectableWidget, position: number, state
129
120
  };
130
121
 
131
122
  const dispatchSelectedNotify = (context: NotifyContext, value: number | null, self: SelectableWidget): void => {
132
- const { options, known, state } = context;
123
+ const { options, known, state, markDrift } = context;
133
124
 
134
125
  if (state.isApplying) {
135
126
  return;
136
127
  }
137
128
 
138
- reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
139
129
  options.props.onNotifySelected?.(value, self);
130
+
131
+ if (!state.isReady || !isCollectionIdle(options.collection)) {
132
+ return;
133
+ }
134
+
135
+ reportKnownSelection(
136
+ { known, onSelectionChanged: options.props.onSelectionChanged },
137
+ options.collection.idAt(self.getSelected()),
138
+ );
139
+
140
+ if (hasSelectionDrifted(self, options.collection, options.props.selectedId)) {
141
+ markDrift();
142
+ }
140
143
  };
141
144
 
142
145
  const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler => {
143
146
  const { widget, collection } = options;
144
147
  const { selectedId } = options.props;
145
148
  const known = useRef<string | null>(null);
146
- const [applyState] = useState<ApplyState>(newApplyState);
149
+ const applyState = useRef<ApplyState>(newApplyState());
147
150
 
148
- const syncKnownSelection = (position: number): void => {
151
+ const syncKnownSelection = (position: number, requestedId: string | undefined): void => {
149
152
  const effectiveId = collection.idAt(position);
150
-
151
- if (effectiveId === null) {
152
- known.current = null;
153
-
154
- return;
155
- }
156
-
157
153
  const { onSelectionChanged } = options.props;
158
- updateKnownSelection({ known, onSelectionChanged }, effectiveId, selectedId);
154
+ updateKnownSelection({ known, onSelectionChanged }, effectiveId, requestedId);
155
+ applyState.current.isReady = true;
159
156
  };
160
157
 
161
- const syncRef = useLatestRef(syncKnownSelection);
162
-
163
- useLayoutEffect(() => {
164
- if (widget === null) {
165
- return;
166
- }
167
-
168
- const position = resolvePosition(widget, collection, selectedId);
169
- applySelectedPosition(widget, position, applyState);
170
- syncRef.current(position);
171
- }, [syncRef, widget, collection, selectedId, applyState]);
158
+ const markDrift = useControlledSync({
159
+ value: selectedId,
160
+ source: collection,
161
+ target: widget,
162
+ apply: (value) => {
163
+ if (widget === null) {
164
+ return;
165
+ }
166
+
167
+ const position = resolvePosition(widget, collection, value);
168
+ applySelectedPosition(widget, position, applyState.current);
169
+ syncKnownSelection(position, value);
170
+ },
171
+ });
172
172
 
173
173
  return (value, self) => {
174
- dispatchSelectedNotify({ options, known, state: applyState }, value, self);
174
+ dispatchSelectedNotify({ options, known, state: applyState.current, markDrift }, value, self);
175
175
  };
176
176
  };
177
177
 
@@ -179,7 +179,7 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
179
179
  const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
180
180
  const rest = omit(props, DROP_DOWN_PROPS);
181
181
  const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
182
- const collection = useCollectionData({ items, sections, isFlat: true });
182
+ const { collection, model } = useCollectionData({ items, sections, isFlat: true });
183
183
  const faceCells = useItemCells({ width: -1, height: -1 });
184
184
  const listCells = useItemCells({ width: -1, height: -1 });
185
185
  const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
@@ -189,7 +189,7 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
189
189
  <>
190
190
  <Component
191
191
  ref={refCallback}
192
- model={collection.model}
192
+ model={model}
193
193
  factory={<GtkSignalListItemFactory {...faceCells.handlers} />}
194
194
  {...(renderListItem != null && {
195
195
  listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
@@ -124,10 +124,10 @@ function useExpansion(options: ExpansionOptions): ItemsChangeHandler {
124
124
  const context: ExpansionContext = { collection, last, onExpandedChange };
125
125
 
126
126
  const markDrift = useControlledSync({
127
- ids: expandedIds,
128
- collection,
129
- apply: (ids) => {
130
- applyControlledExpansion(context, ids);
127
+ value: expandedIds,
128
+ source: collection,
129
+ apply: (value) => {
130
+ applyControlledExpansion(context, value);
131
131
  },
132
132
  });
133
133
 
@@ -1,4 +1,3 @@
1
- import type * as Gio from "@gtkx/gi/gio";
2
1
  import type { ReactElement } from "react";
3
2
  import * as Gtk from "@gtkx/gi/gtk";
4
3
  import { GtkMultiSelection, GtkNoSelection, GtkSingleSelection } from "@gtkx/jsx/gtk";
@@ -12,6 +11,7 @@ import { joinParts } from "./keys.js";
12
11
 
13
12
  type SelectionOptions = {
14
13
  collection: Collection;
14
+ model: ReactElement;
15
15
  selectedIds?: string[] | null | undefined;
16
16
  onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
17
17
  selectionMode?: Gtk.SelectionMode | null | undefined;
@@ -42,7 +42,7 @@ type PositionRun = {
42
42
 
43
43
  type SelectionElementProps = {
44
44
  ref: (value: Gtk.SelectionModel | null) => void;
45
- model: Gio.ListModel;
45
+ model: ReactElement;
46
46
  onSelectionChanged: () => void;
47
47
  onItemsChanged: ItemsChangeHandler;
48
48
  };
@@ -257,17 +257,17 @@ function useSelection(options: SelectionOptions): ReactElement {
257
257
  const context: SelectionContext = { selection, collection, last, state, onSelectionChanged };
258
258
 
259
259
  const markDrift = useControlledSync({
260
- ids: selectedIds,
261
- collection,
262
- widget: selection,
263
- apply: (ids) => {
264
- applyControlledSelection(context, ids);
260
+ value: selectedIds,
261
+ source: collection,
262
+ target: selection,
263
+ apply: (value) => {
264
+ applyControlledSelection(context, value);
265
265
  },
266
266
  });
267
267
 
268
268
  return selectionElement(selectionMode, {
269
269
  ref: setSelection,
270
- model: collection.model,
270
+ model: options.model,
271
271
  onSelectionChanged: () => {
272
272
  observeSelection(context, selectedIds, markDrift);
273
273
  },
@@ -14,7 +14,7 @@ type TreeExpansion = {
14
14
  isSyncing: boolean;
15
15
  };
16
16
 
17
- function dropLevel(expansion: TreeExpansion, path: string, pending: string[]): void {
17
+ function dropSubtree(expansion: TreeExpansion, path: string): void {
18
18
  expansion.expanded.delete(path);
19
19
  const level = expansion.slots.get(path);
20
20
 
@@ -25,19 +25,7 @@ function dropLevel(expansion: TreeExpansion, path: string, pending: string[]): v
25
25
  expansion.slots.delete(path);
26
26
 
27
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
- }
28
+ dropSubtree(expansion, path + encodePart(String(slot)));
41
29
  }
42
30
  }
43
31
 
@@ -104,12 +92,4 @@ function adoptIndex(expansion: TreeExpansion, index: CollectionIndex): void {
104
92
  expansion.order = null;
105
93
  }
106
94
 
107
- export {
108
- adoptIndex,
109
- adoptOrder,
110
- createTreeExpansion,
111
- markExpanded,
112
- orderFor,
113
- pruneSlots,
114
- type TreeExpansion,
115
- };
95
+ export { adoptIndex, adoptOrder, createTreeExpansion, markExpanded, orderFor, pruneSlots, type TreeExpansion };
@@ -28,133 +28,56 @@ type WalkOptions = {
28
28
  visit?: RowVisitor | undefined;
29
29
  };
30
30
 
31
- type LevelFrame = {
32
- level: Level;
33
- cursor: number;
34
- };
35
-
36
- type WalkFrame = LevelFrame & {
37
- open: Set<number> | undefined;
38
- marked: Set<number> | undefined;
39
- };
40
-
41
31
  type WalkState = {
42
- options: WalkOptions;
43
- stack: WalkFrame[];
44
- row: VisibleRow;
32
+ position: number;
45
33
  order: VisibleOrder;
46
34
  };
47
35
 
48
- type OpenFrame = LevelFrame & {
49
- owner: ListItem | null;
50
- };
36
+ function walkLevel(options: WalkOptions, level: Level, state: WalkState): void {
37
+ const open = options.slots.get(level.path);
38
+ const marked = options.marks?.get(level.path);
51
39
 
52
- type OpenWalk = {
53
- index: CollectionIndex;
54
- ids: Set<string>;
55
- paths: Set<string>;
56
- stack: OpenFrame[];
57
- ancestors: Set<ListItem>;
58
- };
40
+ for (const [slot, item] of level.items.entries()) {
41
+ const isOpen = open?.has(slot) ?? false;
42
+ options.visit?.({ item, position: state.position, isOpen, isMarked: marked?.has(slot) ?? false });
43
+ state.position += 1;
59
44
 
60
- const NO_ITEM: ListItem = { id: "", value: undefined };
45
+ if (!isOpen) {
46
+ continue;
47
+ }
61
48
 
62
- function advanceStack<F extends LevelFrame>(
63
- stack: F[],
64
- onSlot: (frame: F, slot: number) => void,
65
- onLeave?: (frame: F) => void,
66
- ): void {
67
- const frame = stack.at(-1);
49
+ const child = options.index.childLevel(level, slot);
68
50
 
69
- if (frame === undefined) {
70
- return;
71
- }
72
-
73
- if (frame.cursor >= frame.level.items.length) {
74
- stack.pop();
75
- onLeave?.(frame);
76
-
77
- return;
78
- }
79
-
80
- const slot = frame.cursor;
81
- frame.cursor += 1;
82
- onSlot(frame, slot);
83
- }
84
-
85
- function frameFor(options: WalkOptions, level: Level): WalkFrame {
86
- return { level, cursor: 0, open: options.slots.get(level.path), marked: options.marks?.get(level.path) };
87
- }
88
-
89
- function descend(state: WalkState, level: Level, slot: number): void {
90
- const child = state.options.index.childLevel(level, slot);
91
-
92
- if (child === undefined) {
93
- return;
94
- }
95
-
96
- state.order.expandedPaths.push(child.path);
97
- state.order.expandedIds.push(state.row.item.id);
98
- state.stack.push(frameFor(state.options, child));
99
- }
100
-
101
- function visitSlot(state: WalkState, frame: WalkFrame, slot: number): void {
102
- const item = frame.level.items[slot];
103
-
104
- if (item === undefined) {
105
- return;
106
- }
107
-
108
- const { row } = state;
109
- row.item = item;
110
- row.isOpen = frame.open?.has(slot) ?? false;
111
- row.isMarked = frame.marked?.has(slot) ?? false;
112
- state.options.visit?.(row);
113
- row.position += 1;
114
-
115
- if (row.isOpen) {
116
- descend(state, frame.level, slot);
51
+ if (child !== undefined) {
52
+ state.order.expandedPaths.push(child.path);
53
+ state.order.expandedIds.push(item.id);
54
+ walkLevel(options, child, state);
55
+ }
117
56
  }
118
57
  }
119
58
 
120
59
  function walkVisible(options: WalkOptions): VisibleOrder {
121
- const order: VisibleOrder = { expandedPaths: [], expandedIds: [] };
122
- const row: VisibleRow = { item: NO_ITEM, position: 0, isOpen: false, isMarked: false };
123
- const frames = options.index.groups.map((level) => frameFor(options, level));
124
- const state: WalkState = { options, stack: frames.toReversed(), row, order };
60
+ const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [] } };
125
61
 
126
- const onSlot = (frame: WalkFrame, slot: number): void => {
127
- visitSlot(state, frame, slot);
128
- };
129
-
130
- while (state.stack.length > 0) {
131
- advanceStack(state.stack, onSlot);
62
+ for (const level of options.index.groups) {
63
+ walkLevel(options, level, state);
132
64
  }
133
65
 
134
- return order;
66
+ return state.order;
135
67
  }
136
68
 
137
- function openSlot(walk: OpenWalk, frame: OpenFrame, slot: number): void {
138
- const item = frame.level.items[slot];
139
-
140
- if (item === undefined || !walk.ids.has(item.id) || walk.ancestors.has(item)) {
141
- return;
142
- }
143
-
144
- const child = walk.index.childLevel(frame.level, slot);
69
+ function expandLevel(index: CollectionIndex, level: Level, ids: Set<string>, paths: Set<string>): void {
70
+ for (const [slot, item] of level.items.entries()) {
71
+ if (!ids.has(item.id)) {
72
+ continue;
73
+ }
145
74
 
146
- if (child === undefined) {
147
- return;
148
- }
149
-
150
- walk.paths.add(child.path);
151
- walk.ancestors.add(item);
152
- walk.stack.push({ level: child, cursor: 0, owner: item });
153
- }
75
+ const child = index.childLevel(level, slot);
154
76
 
155
- function leaveOpenFrame(walk: OpenWalk, frame: OpenFrame): void {
156
- if (frame.owner !== null) {
157
- walk.ancestors.delete(frame.owner);
77
+ if (child !== undefined) {
78
+ paths.add(child.path);
79
+ expandLevel(index, child, ids, paths);
80
+ }
158
81
  }
159
82
  }
160
83
 
@@ -165,19 +88,8 @@ function expandedPathsFor(index: CollectionIndex, ids: Set<string>): Set<string>
165
88
  return paths;
166
89
  }
167
90
 
168
- const frames = index.groups.map((level) => ({ level, cursor: 0, owner: null }));
169
- const walk: OpenWalk = { index, ids, paths, stack: frames.toReversed(), ancestors: new Set() };
170
-
171
- const onSlot = (frame: OpenFrame, slot: number): void => {
172
- openSlot(walk, frame, slot);
173
- };
174
-
175
- const onLeave = (frame: OpenFrame): void => {
176
- leaveOpenFrame(walk, frame);
177
- };
178
-
179
- while (walk.stack.length > 0) {
180
- advanceStack(walk.stack, onSlot, onLeave);
91
+ for (const level of index.groups) {
92
+ expandLevel(index, level, ids, paths);
181
93
  }
182
94
 
183
95
  return paths;
@@ -221,12 +133,4 @@ function findIds(index: CollectionIndex, slots: SlotMap, positions: Set<number>)
221
133
  return found;
222
134
  }
223
135
 
224
- export {
225
- expandedPathsFor,
226
- findIds,
227
- findRows,
228
- walkVisible,
229
- type MatchedRows,
230
- type VisibleOrder,
231
- type WalkOptions,
232
- };
136
+ export { expandedPathsFor, findIds, findRows, walkVisible, type MatchedRows, type VisibleOrder, type WalkOptions };
@@ -1,9 +1,11 @@
1
+ import type * as Gtk from "@gtkx/gi/gtk";
1
2
  import type { ReactElement } from "react";
3
+ import { GtkFlattenListModel } from "@gtkx/jsx/gtk";
2
4
  import { useLayoutEffect, useMemo, useState } from "react";
3
5
  import type { ExpansionProps, ListItem, ListSection, SelectionProps } from "../types.js";
4
6
  import type { Collection } from "./collection.js";
5
7
  import { createCollectionIndex } from "./collection-index.js";
6
- import { createCollectionModel } from "./collection-model.js";
8
+ import { CollectionRoot, type CollectionRootStore, createCollectionModel } from "./collection-model.js";
7
9
  import { createCollection } from "./collection.js";
8
10
  import { useExpansion } from "./expansion.js";
9
11
  import { useSelection } from "./selection.js";
@@ -21,21 +23,39 @@ type CollectionResult = {
21
23
  selection: ReactElement;
22
24
  };
23
25
 
24
- function useCollectionData(options: CollectionDataOptions): Collection {
26
+ type CollectionDataResult = {
27
+ collection: Collection;
28
+ model: ReactElement;
29
+ };
30
+
31
+ function useCollectionData(options: CollectionDataOptions): CollectionDataResult {
25
32
  const { items, sections, isFlat } = options;
26
- const [collectionModel] = useState(createCollectionModel);
33
+ const [gtkModel] = useState(createCollectionModel);
34
+ const [root, setRoot] = useState<CollectionRootStore | null>(null);
35
+ const [model, setModel] = useState<Gtk.FlattenListModel | null>(null);
27
36
  const index = useMemo(() => createCollectionIndex(items, sections, isFlat === true), [items, sections, isFlat]);
28
- const collection = useMemo(() => createCollection(collectionModel, index), [collectionModel, index]);
37
+ const collection = useMemo(() => createCollection(gtkModel, index, model), [gtkModel, index, model]);
29
38
 
30
39
  useLayoutEffect(() => {
31
- collectionModel.sync(index);
32
- }, [collectionModel, index]);
40
+ if (root !== null && model !== null) {
41
+ gtkModel.bind(root, model);
42
+ }
43
+
44
+ return gtkModel.clear;
45
+ }, [gtkModel, root, model]);
33
46
 
34
- return collection;
47
+ useLayoutEffect(() => {
48
+ gtkModel.sync(index);
49
+ }, [gtkModel, index, root, model]);
50
+
51
+ return {
52
+ collection,
53
+ model: <GtkFlattenListModel ref={setModel} model={<CollectionRoot ref={setRoot} />} />,
54
+ };
35
55
  }
36
56
 
37
57
  function useCollection(options: CollectionOptions): CollectionResult {
38
- const collection = useCollectionData(options);
58
+ const { collection, model } = useCollectionData(options);
39
59
 
40
60
  const onItemsChanged = useExpansion({
41
61
  collection,
@@ -45,6 +65,7 @@ function useCollection(options: CollectionOptions): CollectionResult {
45
65
 
46
66
  const selection = useSelection({
47
67
  collection,
68
+ model,
48
69
  selectedIds: options.selectedIds,
49
70
  onSelectionChanged: options.onSelectionChanged,
50
71
  selectionMode: options.selectionMode,
@@ -0,0 +1,27 @@
1
+ import type * as GObject from "@gtkx/gi/gobject";
2
+ import type { ReactNode } from "react";
3
+ import { GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
4
+ import type { ListItemFactoryProps } from "./types.js";
5
+ import { NativeItemPortals, useItemCells } from "./internal/cells.js";
6
+
7
+ /**
8
+ * Creates a Gtk.SignalListItemFactory that renders objects from an existing native model with React.
9
+ * Estimated dimensions keep cells stable until their content mounts.
10
+ */
11
+ function ListItemFactory<T extends GObject.Object>({
12
+ renderItem,
13
+ estimatedItemHeight = -1,
14
+ estimatedItemWidth = -1,
15
+ }: ListItemFactoryProps<T>): ReactNode {
16
+ const size = { width: estimatedItemWidth, height: estimatedItemHeight };
17
+ const cells = useItemCells(size);
18
+
19
+ return (
20
+ <>
21
+ <GtkSignalListItemFactory {...cells.handlers} />
22
+ <NativeItemPortals registry={cells} render={renderItem} size={size} />
23
+ </>
24
+ );
25
+ }
26
+
27
+ export { ListItemFactory };