@react-stately/layout 4.1.1 → 4.2.1

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.
package/src/GridLayout.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared';
14
- import {Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer';
14
+ import {InvalidationContext, Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer';
15
15
 
16
16
  export interface GridLayoutOptions {
17
17
  /**
@@ -24,6 +24,13 @@ export interface GridLayoutOptions {
24
24
  * @default Infinity
25
25
  */
26
26
  maxItemSize?: Size,
27
+ /**
28
+ * Whether to preserve the aspect ratio of the `minItemSize`.
29
+ * By default, grid rows may have variable heights. When `preserveAspectRatio`
30
+ * is true, all rows will have equal heights.
31
+ * @default false
32
+ */
33
+ preserveAspectRatio?: boolean,
27
34
  /**
28
35
  * The minimum space required between items.
29
36
  * @default 18 x 18
@@ -41,129 +48,227 @@ export interface GridLayoutOptions {
41
48
  dropIndicatorThickness?: number
42
49
  }
43
50
 
44
- export class GridLayout<T, O = any> extends Layout<Node<T>, O> implements DropTargetDelegate {
45
- protected minItemSize: Size;
46
- protected maxItemSize: Size;
47
- protected minSpace: Size;
48
- protected maxColumns: number;
49
- protected dropIndicatorThickness: number;
50
- protected itemSize: Size = new Size();
51
+ const DEFAULT_OPTIONS = {
52
+ minItemSize: new Size(200, 200),
53
+ maxItemSize: new Size(Infinity, Infinity),
54
+ preserveAspectRatio: false,
55
+ minSpace: new Size(18, 18),
56
+ maxColumns: Infinity,
57
+ dropIndicatorThickness: 2
58
+ };
59
+
60
+ /**
61
+ * GridLayout is a virtualizer Layout implementation
62
+ * that arranges its items in a grid.
63
+ * The items are sized between a minimum and maximum size
64
+ * depending on the width of the container.
65
+ */
66
+ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> extends Layout<Node<T>, O> implements DropTargetDelegate {
67
+ protected gap: Size = DEFAULT_OPTIONS.minSpace;
68
+ protected dropIndicatorThickness = 2;
51
69
  protected numColumns: number = 0;
52
- protected horizontalSpacing: number = 0;
53
- protected layoutInfos: LayoutInfo[] = [];
54
-
55
- constructor(options: GridLayoutOptions) {
56
- super();
57
- this.minItemSize = options.minItemSize || new Size(200, 200);
58
- this.maxItemSize = options.maxItemSize || new Size(Infinity, Infinity);
59
- this.minSpace = options.minSpace || new Size(18, 18);
60
- this.maxColumns = options.maxColumns || Infinity;
61
- this.dropIndicatorThickness = options.dropIndicatorThickness || 2;
70
+ private contentSize: Size = new Size();
71
+ private layoutInfos: Map<Key, LayoutInfo> = new Map();
72
+
73
+ shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean {
74
+ return newOptions.maxColumns !== oldOptions.maxColumns
75
+ || newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness
76
+ || newOptions.preserveAspectRatio !== oldOptions.preserveAspectRatio
77
+ || (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
78
+ || (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
79
+ || (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace));
62
80
  }
63
81
 
64
- update(): void {
82
+ update(invalidationContext: InvalidationContext<O>): void {
83
+ let {
84
+ minItemSize = DEFAULT_OPTIONS.minItemSize,
85
+ maxItemSize = DEFAULT_OPTIONS.maxItemSize,
86
+ preserveAspectRatio = DEFAULT_OPTIONS.preserveAspectRatio,
87
+ minSpace = DEFAULT_OPTIONS.minSpace,
88
+ maxColumns = DEFAULT_OPTIONS.maxColumns,
89
+ dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
90
+ } = invalidationContext.layoutOptions || {};
91
+ this.dropIndicatorThickness = dropIndicatorThickness;
92
+
65
93
  let visibleWidth = this.virtualizer!.visibleRect.width;
66
94
 
67
95
  // The max item width is always the entire viewport.
68
96
  // If the max item height is infinity, scale in proportion to the max width.
69
- let maxItemWidth = Math.min(this.maxItemSize.width, visibleWidth);
70
- let maxItemHeight = Number.isFinite(this.maxItemSize.height)
71
- ? this.maxItemSize.height
72
- : Math.floor((this.minItemSize.height / this.minItemSize.width) * maxItemWidth);
97
+ let maxItemWidth = Math.min(maxItemSize.width, visibleWidth);
98
+ let maxItemHeight = Number.isFinite(maxItemSize.height)
99
+ ? maxItemSize.height
100
+ : Math.floor((minItemSize.height / minItemSize.width) * maxItemWidth);
73
101
 
74
102
  // Compute the number of rows and columns needed to display the content
75
- let columns = Math.floor(visibleWidth / (this.minItemSize.width + this.minSpace.width));
76
- this.numColumns = Math.max(1, Math.min(this.maxColumns, columns));
103
+ let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width));
104
+ let numColumns = Math.max(1, Math.min(maxColumns, columns));
105
+ this.numColumns = numColumns;
77
106
 
78
107
  // Compute the available width (minus the space between items)
79
- let width = visibleWidth - (this.minSpace.width * Math.max(0, this.numColumns));
108
+ let width = visibleWidth - (minSpace.width * Math.max(0, numColumns));
80
109
 
81
110
  // Compute the item width based on the space available
82
- let itemWidth = Math.floor(width / this.numColumns);
83
- itemWidth = Math.max(this.minItemSize.width, Math.min(maxItemWidth, itemWidth));
111
+ let itemWidth = Math.floor(width / numColumns);
112
+ itemWidth = Math.max(minItemSize.width, Math.min(maxItemWidth, itemWidth));
84
113
 
85
114
  // Compute the item height, which is proportional to the item width
86
- let t = ((itemWidth - this.minItemSize.width) / Math.max(1, maxItemWidth - this.minItemSize.width));
87
- let itemHeight = this.minItemSize.height + Math.floor((maxItemHeight - this.minItemSize.height) * t);
88
- itemHeight = Math.max(this.minItemSize.height, Math.min(maxItemHeight, itemHeight));
89
- this.itemSize = new Size(itemWidth, itemHeight);
115
+ let t = ((itemWidth - minItemSize.width) / Math.max(1, maxItemWidth - minItemSize.width));
116
+ let itemHeight = minItemSize.height + Math.floor((maxItemHeight - minItemSize.height) * t);
117
+ itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight));
90
118
 
91
119
  // Compute the horizontal spacing and content height
92
- this.horizontalSpacing = Math.floor((visibleWidth - this.numColumns * this.itemSize.width) / (this.numColumns + 1));
120
+ let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1));
121
+ this.gap = new Size(horizontalSpacing, minSpace.height);
93
122
 
94
- this.layoutInfos = [];
95
- for (let node of this.virtualizer!.collection) {
96
- this.layoutInfos.push(this.getLayoutInfoForNode(node));
97
- }
98
- }
123
+ let rows = Math.ceil(this.virtualizer!.collection.size / numColumns);
124
+ let iterator = this.virtualizer!.collection[Symbol.iterator]();
125
+ let y = rows > 0 ? minSpace.height : 0;
126
+ let newLayoutInfos = new Map();
127
+ let skeleton: Node<T> | null = null;
128
+ let skeletonCount = 0;
129
+ for (let row = 0; row < rows; row++) {
130
+ let maxHeight = 0;
131
+ let rowLayoutInfos: LayoutInfo[] = [];
132
+ for (let col = 0; col < numColumns; col++) {
133
+ // Repeat skeleton until the end of the current row.
134
+ let node = skeleton || iterator.next().value;
135
+ if (!node) {
136
+ break;
137
+ }
99
138
 
100
- getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
101
- let firstVisibleItem = this.getIndexAtPoint(rect.x, rect.y);
102
- let lastVisibleItem = this.getIndexAtPoint(rect.maxX, rect.maxY);
103
- let result = this.layoutInfos.slice(firstVisibleItem, lastVisibleItem + 1);
104
- let persistedIndices: number[] = [];
105
- for (let key of this.virtualizer!.persistedKeys) {
106
- let item = this.virtualizer!.collection.getItem(key);
107
- if (item?.index != null) {
108
- persistedIndices.push(item.index);
139
+ if (node.type === 'skeleton') {
140
+ skeleton = node;
141
+ }
142
+
143
+ let key = skeleton ? `${skeleton.key}-${skeletonCount++}` : node.key;
144
+ let oldLayoutInfo = this.layoutInfos.get(key);
145
+ let content = node;
146
+ if (skeleton) {
147
+ content = oldLayoutInfo && oldLayoutInfo.content.key === key ? oldLayoutInfo.content : {...skeleton, key};
148
+ }
149
+ let x = horizontalSpacing + col * (itemWidth + horizontalSpacing);
150
+ let height = itemHeight;
151
+ let estimatedSize = !preserveAspectRatio;
152
+ if (oldLayoutInfo && estimatedSize) {
153
+ height = oldLayoutInfo.rect.height;
154
+ estimatedSize = invalidationContext.layoutOptionsChanged || invalidationContext.sizeChanged || oldLayoutInfo.estimatedSize || (oldLayoutInfo.content !== content);
155
+ }
156
+
157
+ let rect = new Rect(x, y, itemWidth, height);
158
+ let layoutInfo = new LayoutInfo(node.type, key, rect);
159
+ layoutInfo.estimatedSize = estimatedSize;
160
+ layoutInfo.allowOverflow = true;
161
+ layoutInfo.content = content;
162
+ newLayoutInfos.set(key, layoutInfo);
163
+ rowLayoutInfos.push(layoutInfo);
164
+
165
+ maxHeight = Math.max(maxHeight, layoutInfo.rect.height);
109
166
  }
110
- }
111
- persistedIndices.sort((a, b) => a - b);
112
-
113
- let persistedBefore: LayoutInfo[] = [];
114
- for (let index of persistedIndices) {
115
- if (index < firstVisibleItem) {
116
- persistedBefore.push(this.layoutInfos[index]);
117
- } else if (index > lastVisibleItem) {
118
- result.push(this.layoutInfos[index]);
167
+
168
+ for (let layoutInfo of rowLayoutInfos) {
169
+ layoutInfo.rect.height = maxHeight;
170
+ }
171
+
172
+ y += maxHeight + minSpace.height;
173
+
174
+ // Keep adding skeleton rows until we fill the viewport
175
+ if (skeleton && row === rows - 1 && y < this.virtualizer!.visibleRect.height) {
176
+ rows++;
119
177
  }
120
178
  }
121
- result.unshift(...persistedBefore);
122
- return result;
179
+
180
+ this.layoutInfos = newLayoutInfos;
181
+ this.contentSize = new Size(this.virtualizer!.visibleRect.width, y);
123
182
  }
124
183
 
125
- protected getIndexAtPoint(x: number, y: number) {
126
- let itemHeight = this.itemSize.height + this.minSpace.height;
127
- let itemWidth = this.itemSize.width + this.horizontalSpacing;
128
- return Math.max(0,
129
- Math.min(
130
- this.virtualizer!.collection.size - 1,
131
- Math.floor(y / itemHeight) * this.numColumns + Math.floor((x - this.horizontalSpacing) / itemWidth)
132
- )
133
- );
184
+ getLayoutInfo(key: Key): LayoutInfo {
185
+ return this.layoutInfos.get(key)!;
134
186
  }
135
187
 
136
- getLayoutInfo(key: Key): LayoutInfo | null {
137
- let node = this.virtualizer!.collection.getItem(key);
138
- return node ? this.layoutInfos[node.index] : null;
188
+ getContentSize(): Size {
189
+ return this.contentSize;
139
190
  }
140
191
 
141
- protected getLayoutInfoForNode(node: Node<T>): LayoutInfo {
142
- let idx = node.index;
143
- let row = Math.floor(idx / this.numColumns);
144
- let column = idx % this.numColumns;
145
- let x = this.horizontalSpacing + column * (this.itemSize.width + this.horizontalSpacing);
146
- let y = this.minSpace.height + row * (this.itemSize.height + this.minSpace.height);
147
- let rect = new Rect(x, y, this.itemSize.width, this.itemSize.height);
148
- return new LayoutInfo(node.type, node.key, rect);
192
+ getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
193
+ let layoutInfos: LayoutInfo[] = [];
194
+ for (let layoutInfo of this.layoutInfos.values()) {
195
+ if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key)) {
196
+ layoutInfos.push(layoutInfo);
197
+ }
198
+ }
199
+ return layoutInfos;
149
200
  }
150
201
 
151
- getContentSize(): Size {
152
- let numRows = Math.ceil(this.virtualizer!.collection.size / this.numColumns);
153
- let contentHeight = this.minSpace.height + numRows * (this.itemSize.height + this.minSpace.height);
154
- return new Size(this.virtualizer!.visibleRect.width, contentHeight);
202
+ updateItemSize(key: Key, size: Size) {
203
+ let layoutInfo = this.layoutInfos.get(key);
204
+ if (!size || !layoutInfo) {
205
+ return false;
206
+ }
207
+
208
+ if (size.height !== layoutInfo.rect.height) {
209
+ let newLayoutInfo = layoutInfo.copy();
210
+ newLayoutInfo.rect.height = size.height;
211
+ newLayoutInfo.estimatedSize = false;
212
+ this.layoutInfos.set(key, newLayoutInfo);
213
+ return true;
214
+ }
215
+
216
+ return false;
155
217
  }
156
218
 
157
219
  getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget {
158
- if (this.layoutInfos.length === 0) {
220
+ if (this.layoutInfos.size === 0) {
159
221
  return {type: 'root'};
160
222
  }
161
223
 
162
224
  x += this.virtualizer!.visibleRect.x;
163
225
  y += this.virtualizer!.visibleRect.y;
164
- let index = this.getIndexAtPoint(x, y);
165
226
 
166
- let layoutInfo = this.layoutInfos[index];
227
+ // Find the closest item within on either side of the point using the gap width.
228
+ let key: Key | null = null;
229
+ if (this.numColumns === 1) {
230
+ let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, this.gap.height * 2);
231
+ let candidates = this.getVisibleLayoutInfos(searchRect);
232
+ let minDistance = Infinity;
233
+ for (let candidate of candidates) {
234
+ // Ignore items outside the search rect, e.g. persisted keys.
235
+ if (!candidate.rect.intersects(searchRect)) {
236
+ continue;
237
+ }
238
+
239
+ let yDist = Math.abs(candidate.rect.y - y);
240
+ let maxYDist = Math.abs(candidate.rect.maxY - y);
241
+ let dist = Math.min(yDist, maxYDist);
242
+ if (dist < minDistance) {
243
+ minDistance = dist;
244
+ key = candidate.key;
245
+ }
246
+ }
247
+ } else {
248
+ let searchRect = new Rect(Math.max(0, x - this.gap.width), y, this.gap.width * 2, 1);
249
+ let candidates = this.getVisibleLayoutInfos(searchRect);
250
+ let minDistance = Infinity;
251
+ for (let candidate of candidates) {
252
+ // Ignore items outside the search rect, e.g. persisted keys.
253
+ if (!candidate.rect.intersects(searchRect)) {
254
+ continue;
255
+ }
256
+
257
+ let xDist = Math.abs(candidate.rect.x - x);
258
+ let maxXDist = Math.abs(candidate.rect.maxX - x);
259
+ let dist = Math.min(xDist, maxXDist);
260
+ if (dist < minDistance) {
261
+ minDistance = dist;
262
+ key = candidate.key;
263
+ }
264
+ }
265
+ }
266
+
267
+ let layoutInfo = key != null ? this.getLayoutInfo(key) : null;
268
+ if (!layoutInfo) {
269
+ return {type: 'root'};
270
+ }
271
+
167
272
  let target: DropTarget = {
168
273
  type: 'item',
169
274
  key: layoutInfo.key,
@@ -202,16 +307,16 @@ export class GridLayout<T, O = any> extends Layout<Node<T>, O> implements DropTa
202
307
  rect = new Rect(
203
308
  layoutInfo.rect.x,
204
309
  target.dropPosition === 'before'
205
- ? layoutInfo.rect.y - this.minSpace.height / 2 - this.dropIndicatorThickness / 2
206
- : layoutInfo.rect.maxY + this.minSpace.height / 2 - this.dropIndicatorThickness / 2,
310
+ ? layoutInfo.rect.y - this.gap.height / 2 - this.dropIndicatorThickness / 2
311
+ : layoutInfo.rect.maxY + this.gap.height / 2 - this.dropIndicatorThickness / 2,
207
312
  layoutInfo.rect.width,
208
313
  this.dropIndicatorThickness
209
314
  );
210
315
  } else {
211
316
  rect = new Rect(
212
317
  target.dropPosition === 'before'
213
- ? layoutInfo.rect.x - this.horizontalSpacing / 2 - this.dropIndicatorThickness / 2
214
- : layoutInfo.rect.maxX + this.horizontalSpacing / 2 - this.dropIndicatorThickness / 2,
318
+ ? layoutInfo.rect.x - this.gap.width / 2 - this.dropIndicatorThickness / 2
319
+ : layoutInfo.rect.maxX + this.gap.width / 2 - this.dropIndicatorThickness / 2,
215
320
  layoutInfo.rect.y,
216
321
  this.dropIndicatorThickness,
217
322
  layoutInfo.rect.height