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

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 (75) hide show
  1. package/README.md +8 -168
  2. package/dist/column-view.d.ts.map +1 -1
  3. package/dist/column-view.js +19 -9
  4. package/dist/column-view.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/internal/cells.d.ts +8 -1
  10. package/dist/internal/cells.d.ts.map +1 -1
  11. package/dist/internal/cells.js +84 -19
  12. package/dist/internal/cells.js.map +1 -1
  13. package/dist/internal/collection-index.d.ts +3 -2
  14. package/dist/internal/collection-index.d.ts.map +1 -1
  15. package/dist/internal/collection-index.js +3 -3
  16. package/dist/internal/collection-index.js.map +1 -1
  17. package/dist/internal/collection-model.d.ts +23 -2
  18. package/dist/internal/collection-model.d.ts.map +1 -1
  19. package/dist/internal/collection-model.js +118 -98
  20. package/dist/internal/collection-model.js.map +1 -1
  21. package/dist/internal/collection.d.ts +5 -4
  22. package/dist/internal/collection.d.ts.map +1 -1
  23. package/dist/internal/collection.js +20 -21
  24. package/dist/internal/collection.js.map +1 -1
  25. package/dist/internal/controlled-sync.d.ts +6 -8
  26. package/dist/internal/controlled-sync.d.ts.map +1 -1
  27. package/dist/internal/controlled-sync.js +3 -3
  28. package/dist/internal/controlled-sync.js.map +1 -1
  29. package/dist/internal/drop-down.d.ts.map +1 -1
  30. package/dist/internal/drop-down.js +43 -44
  31. package/dist/internal/drop-down.js.map +1 -1
  32. package/dist/internal/expansion.js +4 -4
  33. package/dist/internal/expansion.js.map +1 -1
  34. package/dist/internal/selection.d.ts +1 -0
  35. package/dist/internal/selection.d.ts.map +1 -1
  36. package/dist/internal/selection.js +6 -6
  37. package/dist/internal/selection.js.map +1 -1
  38. package/dist/internal/tree-expansion.d.ts.map +1 -1
  39. package/dist/internal/tree-expansion.js +2 -11
  40. package/dist/internal/tree-expansion.js.map +1 -1
  41. package/dist/internal/tree-order.d.ts.map +1 -1
  42. package/dist/internal/tree-order.js +32 -76
  43. package/dist/internal/tree-order.js.map +1 -1
  44. package/dist/internal/use-collection.d.ts +5 -1
  45. package/dist/internal/use-collection.d.ts.map +1 -1
  46. package/dist/internal/use-collection.js +21 -7
  47. package/dist/internal/use-collection.js.map +1 -1
  48. package/dist/list-item-factory.d.ts +10 -0
  49. package/dist/list-item-factory.d.ts.map +1 -0
  50. package/dist/list-item-factory.js +14 -0
  51. package/dist/list-item-factory.js.map +1 -0
  52. package/dist/toast.d.ts +3 -5
  53. package/dist/toast.d.ts.map +1 -1
  54. package/dist/toast.js +3 -12
  55. package/dist/toast.js.map +1 -1
  56. package/dist/types.d.ts +33 -50
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js.map +1 -1
  59. package/package.json +12 -10
  60. package/src/column-view.tsx +23 -12
  61. package/src/index.ts +3 -2
  62. package/src/internal/cells.tsx +137 -20
  63. package/src/internal/collection-index.ts +8 -6
  64. package/src/internal/collection-model.ts +156 -134
  65. package/src/internal/collection.ts +28 -24
  66. package/src/internal/controlled-sync.ts +9 -12
  67. package/src/internal/drop-down.tsx +64 -60
  68. package/src/internal/expansion.ts +4 -4
  69. package/src/internal/selection.tsx +8 -8
  70. package/src/internal/tree-expansion.ts +2 -14
  71. package/src/internal/tree-order.ts +33 -121
  72. package/src/internal/use-collection.tsx +29 -8
  73. package/src/list-item-factory.tsx +27 -0
  74. package/src/toast.tsx +4 -18
  75. package/src/types.ts +35 -51
@@ -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,31 @@ 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
- }
61
-
62
- try {
63
- return JSON.stringify(value);
64
- } catch {
65
- return String(value);
66
- }
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);
58
+ const defaultRenderItem = ({ item }: ListItemRenderArgs<Primitive>): ReactNode =>
59
+ item == null ? null : <GtkLabel>{String(item)}</GtkLabel>;
73
60
  const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
74
61
 
75
62
  const resolvePosition = (
76
63
  widget: SelectableWidget,
77
64
  collection: Collection,
78
- selectedId: string | null | undefined,
65
+ selectedId: string | undefined,
79
66
  ): number => {
80
- const requested = selectedId == null ? -1 : collection.positionFor(selectedId);
67
+ if (selectedId === undefined) {
68
+ return widget.getSelected();
69
+ }
70
+
71
+ const requested = collection.positionFor(selectedId);
81
72
 
82
73
  return requested >= 0 ? requested : widget.getSelected();
83
74
  };
84
75
 
85
76
  const updateKnownSelection = (
86
77
  tracker: KnownSelection,
87
- effectiveId: string,
88
- selectedId: string | null | undefined,
78
+ effectiveId: string | null,
79
+ selectedId: string | undefined,
89
80
  ): void => {
90
81
  const { known, onSelectionChanged } = tracker;
91
82
  const expectedId = selectedId ?? known.current;
@@ -100,7 +91,7 @@ const updateKnownSelection = (
100
91
  const reportKnownSelection = (tracker: KnownSelection, id: string | null): void => {
101
92
  const { known, onSelectionChanged } = tracker;
102
93
 
103
- if (id === null || id === known.current) {
94
+ if (id === known.current) {
104
95
  return;
105
96
  }
106
97
 
@@ -108,14 +99,18 @@ const reportKnownSelection = (tracker: KnownSelection, id: string | null): void
108
99
  onSelectionChanged?.(id);
109
100
  };
110
101
 
111
- const reportSelectedNotify = (options: SelectionOptions, tracker: KnownSelection): void => {
112
- const { widget, collection } = options;
113
-
114
- if (widget === null) {
115
- return;
102
+ const hasSelectionDrifted = (
103
+ widget: SelectableWidget,
104
+ collection: Collection,
105
+ selectedId: string | undefined,
106
+ ): boolean => {
107
+ if (selectedId === undefined) {
108
+ return false;
116
109
  }
117
110
 
118
- reportKnownSelection(tracker, collection.idAt(widget.getSelected()));
111
+ const requested = collection.positionFor(selectedId);
112
+
113
+ return requested >= 0 && widget.getSelected() !== requested;
119
114
  };
120
115
 
121
116
  const applySelectedPosition = (widget: SelectableWidget, position: number, state: ApplyState): void => {
@@ -129,49 +124,58 @@ const applySelectedPosition = (widget: SelectableWidget, position: number, state
129
124
  };
130
125
 
131
126
  const dispatchSelectedNotify = (context: NotifyContext, value: number | null, self: SelectableWidget): void => {
132
- const { options, known, state } = context;
127
+ const { options, known, state, markDrift } = context;
133
128
 
134
129
  if (state.isApplying) {
135
130
  return;
136
131
  }
137
132
 
138
- reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
139
133
  options.props.onNotifySelected?.(value, self);
134
+
135
+ if (!state.isReady || !isCollectionIdle(options.collection)) {
136
+ return;
137
+ }
138
+
139
+ reportKnownSelection(
140
+ { known, onSelectionChanged: options.props.onSelectionChanged },
141
+ options.collection.idAt(self.getSelected()),
142
+ );
143
+
144
+ if (hasSelectionDrifted(self, options.collection, options.props.selectedId)) {
145
+ markDrift();
146
+ }
140
147
  };
141
148
 
142
149
  const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler => {
143
150
  const { widget, collection } = options;
144
151
  const { selectedId } = options.props;
145
152
  const known = useRef<string | null>(null);
146
- const [applyState] = useState<ApplyState>(newApplyState);
153
+ const applyState = useRef<ApplyState>(newApplyState());
147
154
 
148
- const syncKnownSelection = (position: number): void => {
155
+ const syncKnownSelection = (position: number, requestedId: string | undefined): void => {
149
156
  const effectiveId = collection.idAt(position);
150
-
151
- if (effectiveId === null) {
152
- known.current = null;
153
-
154
- return;
155
- }
156
-
157
157
  const { onSelectionChanged } = options.props;
158
- updateKnownSelection({ known, onSelectionChanged }, effectiveId, selectedId);
158
+ updateKnownSelection({ known, onSelectionChanged }, effectiveId, requestedId);
159
+ applyState.current.isReady = true;
159
160
  };
160
161
 
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]);
162
+ const markDrift = useControlledSync({
163
+ value: selectedId,
164
+ source: collection,
165
+ target: widget,
166
+ apply: (value) => {
167
+ if (widget === null) {
168
+ return;
169
+ }
170
+
171
+ const position = resolvePosition(widget, collection, value);
172
+ applySelectedPosition(widget, position, applyState.current);
173
+ syncKnownSelection(position, value);
174
+ },
175
+ });
172
176
 
173
177
  return (value, self) => {
174
- dispatchSelectedNotify({ options, known, state: applyState }, value, self);
178
+ dispatchSelectedNotify({ options, known, state: applyState.current, markDrift }, value, self);
175
179
  };
176
180
  };
177
181
 
@@ -179,7 +183,7 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
179
183
  const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
180
184
  const rest = omit(props, DROP_DOWN_PROPS);
181
185
  const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
182
- const collection = useCollectionData({ items, sections, isFlat: true });
186
+ const { collection, model } = useCollectionData({ items, sections, isFlat: true });
183
187
  const faceCells = useItemCells({ width: -1, height: -1 });
184
188
  const listCells = useItemCells({ width: -1, height: -1 });
185
189
  const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
@@ -189,7 +193,7 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
189
193
  <>
190
194
  <Component
191
195
  ref={refCallback}
192
- model={collection.model}
196
+ model={model}
193
197
  factory={<GtkSignalListItemFactory {...faceCells.handlers} />}
194
198
  {...(renderListItem != null && {
195
199
  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
 
@@ -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
- };
51
-
52
- type OpenWalk = {
53
- index: CollectionIndex;
54
- ids: Set<string>;
55
- paths: Set<string>;
56
- stack: OpenFrame[];
57
- ancestors: Set<ListItem>;
58
- };
59
-
60
- const NO_ITEM: ListItem = { id: "", value: undefined };
61
-
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);
68
-
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
- }
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);
88
39
 
89
- function descend(state: WalkState, level: Level, slot: number): void {
90
- const child = state.options.index.childLevel(level, slot);
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;
91
44
 
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
- }
45
+ if (!isOpen) {
46
+ continue;
47
+ }
100
48
 
101
- function visitSlot(state: WalkState, frame: WalkFrame, slot: number): void {
102
- const item = frame.level.items[slot];
49
+ const child = options.index.childLevel(level, slot);
103
50
 
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 };
125
-
126
- const onSlot = (frame: WalkFrame, slot: number): void => {
127
- visitSlot(state, frame, slot);
128
- };
60
+ const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [] } };
129
61
 
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];
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
+ }
139
74
 
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);
75
+ const child = index.childLevel(level, slot);
145
76
 
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
- }
154
-
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;
@@ -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 };
package/src/toast.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode, RefObject } from "react";
2
2
  import * as Adw from "@gtkx/gi/adw";
3
3
  import { createContext, useContext, useMemo } from "react";
4
- import type { ToastController, ToastOptions, ToastOverlayController, ToastProviderProps } from "./types.js";
4
+ import type { ToastController, ToastOptions, ToastProviderProps } from "./types.js";
5
5
 
6
6
  type OverlayRef = RefObject<Adw.ToastOverlay | null>;
7
7
 
@@ -11,7 +11,7 @@ const useOverlayRef = (): OverlayRef => {
11
11
  const overlayRef = useContext(ToastContext);
12
12
 
13
13
  if (overlayRef === null) {
14
- throw new Error("useToast and useToastOverlay must be used within a ToastProvider");
14
+ throw new Error("useToast must be used within a ToastProvider");
15
15
  }
16
16
 
17
17
  return overlayRef;
@@ -36,7 +36,7 @@ const buildToast = (options: ToastOptions): Adw.Toast => {
36
36
  return toast;
37
37
  };
38
38
 
39
- /** Shares an Adw.ToastOverlay reference with the toast hooks in every descendant. */
39
+ /** Shares an Adw.ToastOverlay reference with useToast in every descendant. */
40
40
  function ToastProvider(props: ToastProviderProps): ReactNode {
41
41
  return <ToastContext.Provider value={props.overlayRef}>{props.children}</ToastContext.Provider>;
42
42
  }
@@ -53,20 +53,6 @@ function useToast(): ToastController {
53
53
 
54
54
  return toast;
55
55
  },
56
- dismiss: (toast: Adw.Toast) => {
57
- toast.dismiss();
58
- },
59
- }),
60
- [overlayRef],
61
- );
62
- }
63
-
64
- /** Returns a {@link ToastOverlayController} for the nearest provider's overlay as a whole. */
65
- function useToastOverlay(): ToastOverlayController {
66
- const overlayRef = useOverlayRef();
67
-
68
- return useMemo<ToastOverlayController>(
69
- () => ({
70
56
  dismissAll: () => {
71
57
  overlayRef.current?.dismissAll();
72
58
  },
@@ -75,4 +61,4 @@ function useToastOverlay(): ToastOverlayController {
75
61
  );
76
62
  }
77
63
 
78
- export { ToastProvider, useToast, useToastOverlay };
64
+ export { ToastProvider, useToast };