@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
@@ -1,104 +1,84 @@
1
- import type * as GObject from "@gtkx/gi/gobject";
2
- import type * as Gtk from "@gtkx/gi/gtk";
3
1
  import type { ListItem } from "../types.js";
4
2
  import type { CollectionIndex } from "./collection-index.js";
5
- import type { CollectionModel } from "./collection-model.js";
6
- import { getId } from "./collection-model.js";
3
+ import type { CollectionModel, SlotRef } from "./collection-model.js";
4
+ import { slotPathAt, slotRefFor } from "./collection-model.js";
5
+ import { findPositions } from "./tree-order.js";
7
6
 
8
- type Collection = {
7
+ type Collection = Pick<CollectionModel, "model" | "expansion" | "rowAt"> & {
9
8
  isTree: boolean;
10
- model: Gtk.FlattenListModel;
11
- treeModel: () => Gtk.TreeListModel | null;
12
- itemFor: (id: string) => ListItem | undefined;
13
- sectionFor: (id: string) => unknown;
9
+ itemAt: (ref: SlotRef) => ListItem | undefined;
10
+ sectionFor: (levelPath: string) => unknown;
14
11
  idAt: (position: number) => string | null;
12
+ pathAt: (position: number) => string | null;
15
13
  positionFor: (id: string) => number;
16
14
  positionsFor: (ids: string[]) => number[];
17
15
  };
18
16
 
19
- const ascending = (a: number, b: number): number => a - b;
17
+ const NO_POSITIONS: number[] = [];
20
18
 
21
- function visitRow(
22
- tree: Gtk.TreeListModel,
23
- position: number,
24
- visit: (row: Gtk.TreeListRow, id: string | null) => void,
25
- ): void {
26
- const row = tree.getRow(position);
27
-
28
- if (row === null) {
29
- return;
19
+ function positionsFor(collectionModel: CollectionModel, ids: string[]): number[] {
20
+ if (ids.length === 0) {
21
+ return NO_POSITIONS;
30
22
  }
31
23
 
32
- visit(row, getId(row.getItem()));
33
- }
24
+ const { expansion } = collectionModel;
34
25
 
35
- function eachRow(tree: Gtk.TreeListModel, visit: (row: Gtk.TreeListRow, id: string | null) => void): void {
36
- for (let position = 0; position < tree.getNItems(); position++) {
37
- visitRow(tree, position, visit);
38
- }
26
+ return findPositions(expansion.index, expansion.slots, new Set(ids));
39
27
  }
40
28
 
41
- function collectWanted(item: GObject.Object | null, position: number, wanted: Set<string>, out: number[]): void {
42
- const id = getId(item);
29
+ function positionFor(collectionModel: CollectionModel, id: string): number {
30
+ const [first = -1] = positionsFor(collectionModel, [id]);
43
31
 
44
- if (id !== null && wanted.has(id)) {
45
- out.push(position);
46
- }
32
+ return first;
47
33
  }
48
34
 
49
- function scanPositions(tree: Gtk.TreeListModel, ids: string[]): number[] {
50
- const wanted = new Set(ids);
51
- const positions: number[] = [];
52
- const count = tree.getNItems();
53
-
54
- for (let position = 0; position < count; position++) {
55
- collectWanted(tree.getItem(position), position, wanted, positions);
35
+ function refAt(collectionModel: CollectionModel, position: number): SlotRef | null {
36
+ if (position < 0) {
37
+ return null;
56
38
  }
57
39
 
58
- return positions;
40
+ return slotRefFor(collectionModel.model.getItem(position));
59
41
  }
60
42
 
61
- function indexPositions(index: CollectionIndex, ids: string[]): number[] {
62
- const positions: number[] = [];
63
-
64
- for (const id of ids) {
65
- const position = index.positionFor(id);
43
+ function itemAt(index: CollectionIndex, ref: SlotRef): ListItem | undefined {
44
+ return index.itemAt(ref.store.level.path, ref.slot);
45
+ }
66
46
 
67
- if (position >= 0) {
68
- positions.push(position);
69
- }
70
- }
47
+ function idAt(collectionModel: CollectionModel, index: CollectionIndex, position: number): string | null {
48
+ const ref = refAt(collectionModel, position);
71
49
 
72
- return positions.toSorted(ascending);
50
+ return ref === null ? null : (itemAt(index, ref)?.id ?? null);
73
51
  }
74
52
 
75
- function positionsFor(gtk: CollectionModel, index: CollectionIndex, ids: string[]): number[] {
76
- if (!index.isTree) {
77
- return indexPositions(index, ids);
78
- }
53
+ function pathAt(collectionModel: CollectionModel, position: number): string | null {
54
+ const ref = refAt(collectionModel, position);
79
55
 
80
- const tree = gtk.treeModel();
56
+ if (ref === null) {
57
+ return null;
58
+ }
81
59
 
82
- return tree === null ? [] : scanPositions(tree, ids);
60
+ return slotPathAt(ref.store, ref.slot);
83
61
  }
84
62
 
85
- function positionFor(gtk: CollectionModel, index: CollectionIndex, id: string): number {
86
- const [first = -1] = positionsFor(gtk, index, [id]);
63
+ function isCollectionIdle(collection: Collection): boolean {
64
+ const { expansion } = collection;
87
65
 
88
- return first;
66
+ return !expansion.isApplying && !expansion.isSyncing;
89
67
  }
90
68
 
91
- function createCollection(gtk: CollectionModel, index: CollectionIndex): Collection {
69
+ function createCollection(collectionModel: CollectionModel, index: CollectionIndex): Collection {
92
70
  return {
71
+ model: collectionModel.model,
72
+ expansion: collectionModel.expansion,
93
73
  isTree: index.isTree,
94
- model: gtk.model,
95
- treeModel: () => (index.isTree ? gtk.treeModel() : null),
96
- itemFor: index.itemFor,
74
+ rowAt: collectionModel.rowAt,
75
+ itemAt: (ref) => itemAt(index, ref),
97
76
  sectionFor: index.sectionFor,
98
- idAt: (position) => getId(gtk.model.getItem(position)),
99
- positionFor: (id) => positionFor(gtk, index, id),
100
- positionsFor: (ids) => positionsFor(gtk, index, ids),
77
+ idAt: (position) => idAt(collectionModel, index, position),
78
+ pathAt: (position) => pathAt(collectionModel, position),
79
+ positionFor: (id) => positionFor(collectionModel, id),
80
+ positionsFor: (ids) => positionsFor(collectionModel, ids),
101
81
  };
102
82
  }
103
83
 
104
- export { createCollection, eachRow, type Collection };
84
+ export { createCollection, isCollectionIdle, type Collection };
@@ -0,0 +1,34 @@
1
+ import { startTransition, useEffectEvent, useLayoutEffect, useState } from "react";
2
+ import type { Collection } from "./collection.js";
3
+
4
+ type ControlledIds = string[] | null | undefined;
5
+
6
+ type ControlledSyncOptions = {
7
+ ids: ControlledIds;
8
+ collection: Collection;
9
+ widget?: object | null | undefined;
10
+ apply: (ids: ControlledIds) => void;
11
+ };
12
+
13
+ function useControlledSync(options: ControlledSyncOptions): () => void {
14
+ const { ids, collection, widget } = options;
15
+ const [drift, setDrift] = useState(0);
16
+
17
+ const markDrift = (): void => {
18
+ startTransition(() => {
19
+ setDrift((count) => count + 1);
20
+ });
21
+ };
22
+
23
+ const apply = useEffectEvent((next: ControlledIds): void => {
24
+ options.apply(next);
25
+ });
26
+
27
+ useLayoutEffect(() => {
28
+ apply(ids);
29
+ }, [widget, collection, ids, drift]);
30
+
31
+ return markDrift;
32
+ }
33
+
34
+ export { useControlledSync };
@@ -1,12 +1,11 @@
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 { applyWrite } from "@gtkx/react/internal";
5
4
  import { omit } from "@gtkx/utils";
6
- import { useEffectEvent, useLayoutEffect, useRef } from "react";
5
+ import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
7
6
  import type { DropDownOwnProps, ListItemRenderArgs, ListItemRenderer } from "../types.js";
8
7
  import type { Collection } from "./collection.js";
9
- import { HeaderPortals, ItemPortals, useHeaderCells, useItemCells } from "./cells.js";
8
+ import { ItemPortals, useItemCells, useSectionHeader } from "./cells.js";
10
9
  import { useCollectionData } from "./use-collection.js";
11
10
  import { useWidgetRef } from "./use-widget-ref.js";
12
11
 
@@ -24,6 +23,13 @@ type SelectionOptions = {
24
23
  };
25
24
 
26
25
  type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
26
+ type ApplyState = { isApplying: boolean };
27
+
28
+ type NotifyContext = {
29
+ options: SelectionOptions;
30
+ known: { current: string | null };
31
+ state: ApplyState;
32
+ };
27
33
 
28
34
  type KnownSelection = {
29
35
  known: { current: string | null };
@@ -43,6 +49,10 @@ const DROP_DOWN_PROPS: string[] = [
43
49
  "ref",
44
50
  ];
45
51
 
52
+ function newApplyState(): ApplyState {
53
+ return { isApplying: false };
54
+ }
55
+
46
56
  const describeValue = (value: unknown): string => {
47
57
  if (typeof value === "string") {
48
58
  return value;
@@ -61,9 +71,6 @@ const defaultItemContent = (value: unknown): ReactNode =>
61
71
  const defaultRenderItem = ({ item }: ListItemRenderArgs<unknown>): ReactNode => defaultItemContent(item);
62
72
  const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
63
73
 
64
- const listRenderer = (props: DropDownBaseProps): ListItemRenderer<never> =>
65
- props.renderListItem ?? props.renderItem ?? defaultRenderItem;
66
-
67
74
  const resolvePosition = (
68
75
  widget: SelectableWidget,
69
76
  collection: Collection,
@@ -110,16 +117,32 @@ const reportSelectedNotify = (options: SelectionOptions, tracker: KnownSelection
110
117
  reportKnownSelection(tracker, collection.idAt(widget.getSelected()));
111
118
  };
112
119
 
113
- const applySelectedPosition = (widget: SelectableWidget, position: number): void => {
114
- applyWrite(() => {
120
+ const applySelectedPosition = (widget: SelectableWidget, position: number, state: ApplyState): void => {
121
+ state.isApplying = true;
122
+
123
+ try {
115
124
  widget.selected = position;
116
- });
125
+ } finally {
126
+ state.isApplying = false;
127
+ }
128
+ };
129
+
130
+ const dispatchSelectedNotify = (context: NotifyContext, value: number | null, self: SelectableWidget): void => {
131
+ const { options, known, state } = context;
132
+
133
+ if (state.isApplying) {
134
+ return;
135
+ }
136
+
137
+ reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
138
+ options.props.onNotifySelected?.(value, self);
117
139
  };
118
140
 
119
141
  const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler => {
120
142
  const { widget, collection } = options;
121
143
  const { selectedId } = options.props;
122
144
  const known = useRef<string | null>(null);
145
+ const [applyState] = useState<ApplyState>(newApplyState);
123
146
 
124
147
  const syncKnownSelection = useEffectEvent((position: number): void => {
125
148
  const effectiveId = collection.idAt(position);
@@ -140,13 +163,12 @@ const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler
140
163
  }
141
164
 
142
165
  const position = resolvePosition(widget, collection, selectedId);
143
- applySelectedPosition(widget, position);
166
+ applySelectedPosition(widget, position, applyState);
144
167
  syncKnownSelection(position);
145
- }, [widget, collection, selectedId]);
168
+ }, [widget, collection, selectedId, applyState]);
146
169
 
147
170
  return (value, self) => {
148
- reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
149
- options.props.onNotifySelected?.(value, self);
171
+ dispatchSelectedNotify({ options, known, state: applyState }, value, self);
150
172
  };
151
173
  };
152
174
 
@@ -154,12 +176,11 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
154
176
  const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
155
177
  const rest = omit(props, DROP_DOWN_PROPS);
156
178
  const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
157
- const collection = useCollectionData({ items, sections });
179
+ const collection = useCollectionData({ items, sections, isFlat: true });
158
180
  const faceCells = useItemCells({ width: -1, height: -1 });
159
181
  const listCells = useItemCells({ width: -1, height: -1 });
160
- const headerCells = useHeaderCells({ width: -1, height: -1 });
182
+ const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
161
183
  const handleNotifySelected = useDropDownSelection({ widget, collection, props });
162
- const hasHeader = typeof renderHeader === "function";
163
184
 
164
185
  return (
165
186
  <>
@@ -170,15 +191,15 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
170
191
  {...(renderListItem != null && {
171
192
  listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
172
193
  })}
173
- {...(hasHeader && { headerFactory: <GtkSignalListItemFactory {...headerCells.handlers} /> })}
194
+ {...header.factoryProps}
174
195
  {...rest}
175
196
  onNotifySelected={handleNotifySelected}
176
197
  />
177
- <ItemPortals store={faceCells} render={faceRenderer(props)} collection={collection} />
198
+ <ItemPortals registry={faceCells} render={faceRenderer(props)} collection={collection} />
178
199
  {renderListItem != null && (
179
- <ItemPortals store={listCells} render={listRenderer(props)} collection={collection} />
200
+ <ItemPortals registry={listCells} render={renderListItem} collection={collection} />
180
201
  )}
181
- {hasHeader && <HeaderPortals store={headerCells} render={renderHeader} collection={collection} />}
202
+ {header.portals}
182
203
  </>
183
204
  );
184
205
  }
@@ -1,8 +1,13 @@
1
- import type * as Gtk from "@gtkx/gi/gtk";
2
- import type { RefObject } from "react";
3
- import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
1
+ import { useState } from "react";
4
2
  import type { Collection } from "./collection.js";
5
- import { eachRow } from "./collection.js";
3
+ import type { TreeExpansion } from "./tree-expansion.js";
4
+ import type { VisibleOrder, WalkOptions } from "./tree-order.js";
5
+ import { isCollectionIdle } from "./collection.js";
6
+ import { useControlledSync } from "./controlled-sync.js";
7
+ import { joinParts } from "./keys.js";
8
+ import { trackPaths } from "./slots.js";
9
+ import { adoptOrder, markExpanded, orderFor } from "./tree-expansion.js";
10
+ import { expandedPathsFor, walkVisible } from "./tree-order.js";
6
11
 
7
12
  type ExpansionOptions = {
8
13
  collection: Collection;
@@ -10,113 +15,133 @@ type ExpansionOptions = {
10
15
  onExpandedChange?: ((ids: string[]) => void) | null | undefined;
11
16
  };
12
17
 
18
+ type ItemsChange = {
19
+ position: number;
20
+ removed: number;
21
+ added: number;
22
+ };
23
+
24
+ type ItemsChangeHandler = (position: number, removed: number, added: number) => void;
13
25
  type LastExpansion = { key: string };
14
26
 
27
+ type ExpansionContext = {
28
+ collection: Collection;
29
+ last: LastExpansion;
30
+ onExpandedChange?: ((ids: string[]) => void) | null | undefined;
31
+ };
32
+
15
33
  function newLastExpansion(): LastExpansion {
16
34
  return { key: "" };
17
35
  }
18
36
 
19
- function collectExpanded(row: Gtk.TreeListRow, id: string | null, ids: string[]): void {
20
- if (id !== null && row.getExpanded()) {
21
- ids.push(id);
22
- }
37
+ function hasSameExpansion(expanded: Set<string>, wanted: Set<string>): boolean {
38
+ return expanded.size === wanted.size && wanted.isSubsetOf(expanded);
23
39
  }
24
40
 
25
- function expandRow(row: Gtk.TreeListRow, id: string | null, wanted: Set<string>): void {
26
- const isDesired = id !== null && wanted.has(id);
27
-
28
- if (row.isExpandable() && row.getExpanded() !== isDesired) {
29
- row.setExpanded(isDesired);
30
- }
41
+ function toggleOptions(collection: Collection, wanted: Set<string>): WalkOptions {
42
+ const { expansion } = collection;
43
+
44
+ return {
45
+ index: expansion.index,
46
+ slots: trackPaths(wanted),
47
+ marks: trackPaths(wanted.symmetricDifference(expansion.expanded)),
48
+ visit: (row) => {
49
+ if (row.isMarked) {
50
+ collection.rowAt(row.position)?.setExpanded(row.isOpen);
51
+ }
52
+ },
53
+ };
31
54
  }
32
55
 
33
- function applyExpansion(tree: Gtk.TreeListModel, expandedIds: string[]): void {
34
- const wanted = new Set(expandedIds);
56
+ function walkApplying(expansion: TreeExpansion, options: WalkOptions): VisibleOrder {
57
+ expansion.isApplying = true;
35
58
 
36
- eachRow(tree, (row, id) => {
37
- expandRow(row, id, wanted);
38
- });
59
+ try {
60
+ return walkVisible(options);
61
+ } finally {
62
+ expansion.isApplying = false;
63
+ }
39
64
  }
40
65
 
41
- function reportExpansion(
42
- collection: Collection,
43
- last: LastExpansion,
44
- onExpandedChange: ((ids: string[]) => void) | null | undefined,
45
- ): void {
46
- const tree = collection.treeModel();
66
+ function applyWanted(collection: Collection, expandedIds: string[]): void {
67
+ const { expansion } = collection;
68
+ const wanted = expandedPathsFor(expansion.index, new Set(expandedIds));
47
69
 
48
- if (tree === null) {
70
+ if (hasSameExpansion(expansion.expanded, wanted)) {
49
71
  return;
50
72
  }
51
73
 
52
- const ids: string[] = [];
53
-
54
- eachRow(tree, (row, id) => {
55
- collectExpanded(row, id, ids);
56
- });
74
+ adoptOrder(expansion, walkApplying(expansion, toggleOptions(collection, wanted)));
75
+ }
57
76
 
58
- const key = ids.join(" ");
77
+ function reportExpansion(context: ExpansionContext): void {
78
+ const { expandedIds, expandedPaths } = orderFor(context.collection.expansion);
79
+ const key = joinParts(expandedPaths);
59
80
 
60
- if (last.key === key) {
81
+ if (context.last.key === key) {
61
82
  return;
62
83
  }
63
84
 
64
- last.key = key;
65
- onExpandedChange?.(ids);
85
+ context.last.key = key;
86
+ context.onExpandedChange?.([...expandedIds]);
66
87
  }
67
88
 
68
- function reportWhenIdle(
69
- collection: Collection,
70
- last: LastExpansion,
71
- expanding: RefObject<boolean>,
72
- onExpandedChange: ((ids: string[]) => void) | null | undefined,
73
- ): void {
74
- if (expanding.current) {
89
+ function applyControlledExpansion(context: ExpansionContext, expandedIds: string[] | null | undefined): void {
90
+ if (!context.collection.isTree) {
75
91
  return;
76
92
  }
77
93
 
78
- reportExpansion(collection, last, onExpandedChange);
94
+ applyWanted(context.collection, expandedIds ?? []);
95
+ reportExpansion(context);
79
96
  }
80
97
 
81
- function runControlledExpansion(
82
- collection: Collection,
83
- expandedIds: string[] | null | undefined,
84
- expanding: RefObject<boolean>,
85
- report: () => void,
86
- ): void {
87
- const tree = collection.treeModel();
98
+ function isExpansionIdle(collection: Collection): boolean {
99
+ return collection.isTree && isCollectionIdle(collection);
100
+ }
88
101
 
89
- if (tree === null || expandedIds == null) {
90
- return;
102
+ function didRowDrift(collection: Collection, change: ItemsChange): boolean {
103
+ const path = collection.pathAt(change.position - 1);
104
+ const isExpanded = change.removed === 0 && change.added > 0;
105
+ const isCollapsed = change.added === 0 && change.removed > 0;
106
+
107
+ if (path === null || isExpanded === isCollapsed) {
108
+ return false;
91
109
  }
92
110
 
93
- expanding.current = true;
111
+ markExpanded(collection.expansion, path, isExpanded);
94
112
 
95
- try {
96
- applyExpansion(tree, expandedIds);
97
- } finally {
98
- expanding.current = false;
113
+ return true;
114
+ }
115
+
116
+ function observeExpansion(context: ExpansionContext, change: ItemsChange, onDrift: () => void): void {
117
+ if (!isExpansionIdle(context.collection)) {
118
+ return;
99
119
  }
100
120
 
101
- report();
121
+ const didDrift = didRowDrift(context.collection, change);
122
+ reportExpansion(context);
123
+
124
+ if (didDrift) {
125
+ onDrift();
126
+ }
102
127
  }
103
128
 
104
- function useExpansion(options: ExpansionOptions): () => void {
129
+ function useExpansion(options: ExpansionOptions): ItemsChangeHandler {
105
130
  const { collection, expandedIds, onExpandedChange } = options;
106
131
  const [last] = useState<LastExpansion>(newLastExpansion);
107
- const expanding = useRef(false);
108
-
109
- const report = useEffectEvent((): void => {
110
- reportWhenIdle(collection, last, expanding, onExpandedChange);
132
+ const context: ExpansionContext = { collection, last, onExpandedChange };
133
+
134
+ const markDrift = useControlledSync({
135
+ ids: expandedIds,
136
+ collection,
137
+ apply: (ids) => {
138
+ applyControlledExpansion(context, ids);
139
+ },
111
140
  });
112
141
 
113
- useLayoutEffect(() => {
114
- runControlledExpansion(collection, expandedIds, expanding, report);
115
- }, [collection, expandedIds]);
116
-
117
- return () => {
118
- reportWhenIdle(collection, last, expanding, onExpandedChange);
142
+ return (position, removed, added) => {
143
+ observeExpansion(context, { position, removed, added }, markDrift);
119
144
  };
120
145
  }
121
146
 
122
- export { useExpansion, type ExpansionOptions };
147
+ export { useExpansion, type ItemsChangeHandler };
@@ -0,0 +1,18 @@
1
+ const LENGTH_SEPARATOR = "\u{1}";
2
+
3
+ const encodePart = (value: string): string => `${String(value.length)}${LENGTH_SEPARATOR}${value}`;
4
+ const joinParts = (values: string[]): string => values.map((value) => encodePart(value)).join("");
5
+
6
+ const decodePartAt = (value: string, offset: number): string | null => {
7
+ const separator = value.indexOf(LENGTH_SEPARATOR, offset);
8
+
9
+ if (separator === -1) {
10
+ return null;
11
+ }
12
+
13
+ const length = Number(value.slice(offset, separator));
14
+
15
+ return value.slice(separator + 1, separator + 1 + length);
16
+ };
17
+
18
+ export { decodePartAt, encodePart, joinParts };