@react-stately/layout 4.2.1 → 4.3.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.
package/src/ListLayout.ts CHANGED
@@ -29,7 +29,7 @@ export interface ListLayoutOptions {
29
29
  headingHeight?: number,
30
30
  /** The estimated height of a section header, when the height is variable. */
31
31
  estimatedHeadingHeight?: number,
32
- /**
32
+ /**
33
33
  * The fixed height of a loader element in px. This loader is specifically for
34
34
  * "load more" elements rendered when loading more rows at the root level or inside nested row/sections.
35
35
  * @default 48
@@ -115,12 +115,12 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
115
115
  return this.virtualizer!.collection;
116
116
  }
117
117
 
118
- getLayoutInfo(key: Key) {
118
+ getLayoutInfo(key: Key): LayoutInfo | null {
119
119
  this.ensureLayoutInfo(key);
120
120
  return this.layoutNodes.get(key)?.layoutInfo || null;
121
121
  }
122
122
 
123
- getVisibleLayoutInfos(rect: Rect) {
123
+ getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
124
124
  // Adjust rect to keep number of visible rows consistent.
125
125
  // (only if height > 1 for getDropTargetFromPoint)
126
126
  if (rect.height > 1) {
@@ -151,7 +151,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
151
151
  return res;
152
152
  }
153
153
 
154
- protected layoutIfNeeded(rect: Rect) {
154
+ protected layoutIfNeeded(rect: Rect): void {
155
155
  if (!this.lastCollection) {
156
156
  return;
157
157
  }
@@ -160,7 +160,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
160
160
  this.requestedRect = this.requestedRect.union(rect);
161
161
  this.rootNodes = this.buildCollection();
162
162
  }
163
-
163
+
164
164
  // Ensure all of the persisted keys are available.
165
165
  for (let key of this.virtualizer!.persistedKeys) {
166
166
  if (this.ensureLayoutInfo(key)) {
@@ -183,8 +183,8 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
183
183
  return false;
184
184
  }
185
185
 
186
- protected isVisible(node: LayoutNode, rect: Rect) {
187
- return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || node.layoutInfo.type === 'header' || this.virtualizer!.isPersistedKey(node.layoutInfo.key);
186
+ protected isVisible(node: LayoutNode, rect: Rect): boolean {
187
+ return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || node.layoutInfo.type === 'header' || node.layoutInfo.type === 'loader' || this.virtualizer!.isPersistedKey(node.layoutInfo.key);
188
188
  }
189
189
 
190
190
  protected shouldInvalidateEverything(invalidationContext: InvalidationContext<O>): boolean {
@@ -211,7 +211,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
211
211
  || newOptions.padding !== oldOptions.padding;
212
212
  }
213
213
 
214
- update(invalidationContext: InvalidationContext<O>) {
214
+ update(invalidationContext: InvalidationContext<O>): void {
215
215
  let collection = this.virtualizer!.collection;
216
216
 
217
217
  // Reset valid rect if we will have to invalidate everything.
@@ -255,9 +255,13 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
255
255
  let collection = this.virtualizer!.collection;
256
256
  let skipped = 0;
257
257
  let nodes: LayoutNode[] = [];
258
+ let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
259
+ if (isEmptyOrLoading) {
260
+ y = 0;
261
+ }
262
+
258
263
  for (let node of collection) {
259
264
  let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap;
260
-
261
265
  // Skip rows before the valid rectangle unless they are already cached.
262
266
  if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) {
263
267
  y += rowHeight;
@@ -268,24 +272,37 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
268
272
  let layoutNode = this.buildChild(node, this.padding, y, null);
269
273
  y = layoutNode.layoutInfo.rect.maxY + this.gap;
270
274
  nodes.push(layoutNode);
271
-
272
275
  if (node.type === 'item' && y > this.requestedRect.maxY) {
273
- y += (collection.size - (nodes.length + skipped)) * rowHeight;
276
+ let itemsAfterRect = collection.size - (nodes.length + skipped);
277
+ let lastNode = collection.getItem(collection.getLastKey()!);
278
+ if (lastNode?.type === 'loader') {
279
+ itemsAfterRect--;
280
+ }
281
+
282
+ y += itemsAfterRect * rowHeight;
283
+
284
+ // Always add the loader sentinel if present. This assumes the loader is the last option/row
285
+ // will need to refactor when handling multi section loading
286
+ if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') {
287
+ let loader = this.buildChild(lastNode, this.padding, y, null);
288
+ nodes.push(loader);
289
+ y = loader.layoutInfo.rect.maxY;
290
+ }
274
291
  break;
275
292
  }
276
293
  }
277
294
 
278
295
  y -= this.gap;
279
- y += this.padding;
296
+ y += isEmptyOrLoading ? 0 : this.padding;
280
297
  this.contentSize = new Size(this.virtualizer!.visibleRect.width, y);
281
298
  return nodes;
282
299
  }
283
300
 
284
- protected isValid(node: Node<T>, y: number) {
301
+ protected isValid(node: Node<T>, y: number): boolean {
285
302
  let cached = this.layoutNodes.get(node.key);
286
303
  return (
287
304
  !this.invalidateEverything &&
288
- cached &&
305
+ !!cached &&
289
306
  cached.node === node &&
290
307
  y === cached.layoutInfo.rect.y &&
291
308
  cached.layoutInfo.rect.intersects(this.validRect) &&
@@ -301,6 +318,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
301
318
  let layoutNode = this.buildNode(node, x, y);
302
319
 
303
320
  layoutNode.layoutInfo.parentKey = parentKey ?? null;
321
+ layoutNode.layoutInfo.allowOverflow = true;
304
322
  this.layoutNodes.set(node.key, layoutNode);
305
323
  return layoutNode;
306
324
  }
@@ -315,6 +333,8 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
315
333
  return this.buildSectionHeader(node, x, y);
316
334
  case 'loader':
317
335
  return this.buildLoader(node, x, y);
336
+ case 'separator':
337
+ return this.buildItem(node, x, y);
318
338
  default:
319
339
  throw new Error('Unsupported node type: ' + node.type);
320
340
  }
@@ -324,7 +344,9 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
324
344
  let rect = new Rect(x, y, this.padding, 0);
325
345
  let layoutInfo = new LayoutInfo('loader', node.key, rect);
326
346
  rect.width = this.virtualizer!.contentSize.width - this.padding - x;
327
- rect.height = this.loaderHeight || this.rowHeight || this.estimatedRowHeight || DEFAULT_HEIGHT;
347
+ // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
348
+ // room for the loader alongside rendering the emptyState
349
+ rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0;
328
350
 
329
351
  return {
330
352
  layoutInfo,
@@ -446,7 +468,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
446
468
  };
447
469
  }
448
470
 
449
- updateItemSize(key: Key, size: Size) {
471
+ updateItemSize(key: Key, size: Size): boolean {
450
472
  let layoutNode = this.layoutNodes.get(key);
451
473
  // If no layoutInfo, item has been deleted/removed.
452
474
  if (!layoutNode) {
@@ -497,7 +519,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
497
519
  }
498
520
  }
499
521
 
500
- getContentSize() {
522
+ getContentSize(): Size {
501
523
  return this.contentSize;
502
524
  }
503
525
 
@@ -506,7 +528,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
506
528
  y += this.virtualizer!.visibleRect.y;
507
529
 
508
530
  // Find the closest item within on either side of the point using the gap width.
509
- let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2);
531
+ let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2));
510
532
  let candidates = this.getVisibleLayoutInfos(searchRect);
511
533
  let key: Key | null = null;
512
534
  let minDistance = Infinity;
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {DropTarget, ItemDropTarget, Key} from '@react-types/shared';
14
- import {getChildNodes} from '@react-stately/collections';
14
+ import {getChildNodes, getLastItem} from '@react-stately/collections';
15
15
  import {GridNode} from '@react-types/grid';
16
16
  import {InvalidationContext, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer';
17
17
  import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout';
@@ -85,6 +85,10 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
85
85
  this.stickyColumnIndices = [];
86
86
 
87
87
  let collection = this.virtualizer!.collection as TableCollection<T>;
88
+ if (collection.head?.key === -1) {
89
+ return [];
90
+ }
91
+
88
92
  for (let column of collection.columns) {
89
93
  // The selection cell and any other sticky columns always need to be visible.
90
94
  // In addition, row headers need to be in the DOM for accessibility labeling.
@@ -251,7 +255,8 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
251
255
  let width = 0;
252
256
  let children: LayoutNode[] = [];
253
257
  let rowHeight = this.getEstimatedRowHeight() + this.gap;
254
- for (let node of getChildNodes(collection.body, collection)) {
258
+ let childNodes = getChildNodes(collection.body, collection);
259
+ for (let node of childNodes) {
255
260
  // Skip rows before the valid rectangle unless they are already cached.
256
261
  if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) {
257
262
  y += rowHeight;
@@ -267,13 +272,32 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
267
272
  children.push(layoutNode);
268
273
 
269
274
  if (y > this.requestedRect.maxY) {
275
+ let rowsAfterRect = collection.size - (children.length + skipped);
276
+ let lastNode = getLastItem(childNodes);
277
+ if (lastNode?.type === 'loader') {
278
+ rowsAfterRect--;
279
+ }
280
+
270
281
  // Estimate the remaining height for rows that we don't need to layout right now.
271
- y += (collection.size - (skipped + children.length)) * rowHeight;
282
+ y += rowsAfterRect * rowHeight;
283
+
284
+ // Always add the loader sentinel if present. This assumes the loader is the last row in the body,
285
+ // will need to refactor when handling multi section loading
286
+ if (lastNode?.type === 'loader' && children.at(-1)?.layoutInfo.type !== 'loader') {
287
+ let loader = this.buildChild(lastNode, this.padding, y, layoutInfo.key);
288
+ loader.layoutInfo.parentKey = layoutInfo.key;
289
+ loader.index = collection.size;
290
+ width = Math.max(width, loader.layoutInfo.rect.width);
291
+ children.push(loader);
292
+ y = loader.layoutInfo.rect.maxY;
293
+ }
272
294
  break;
273
295
  }
274
296
  }
275
297
 
276
- if (children.length === 0) {
298
+ // Make sure that the table body gets a height if empty or performing initial load
299
+ let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
300
+ if (isEmptyOrLoading) {
277
301
  y = this.virtualizer!.visibleRect.maxY;
278
302
  } else {
279
303
  y -= this.gap;
@@ -367,7 +391,7 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
367
391
  };
368
392
  }
369
393
 
370
- getVisibleLayoutInfos(rect: Rect) {
394
+ getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
371
395
  // Adjust rect to keep number of visible rows consistent.
372
396
  // (only if height > 1 for getDropTargetFromPoint)
373
397
  if (rect.height > 1) {
@@ -442,6 +466,12 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
442
466
  this.addVisibleLayoutInfos(res, node.children[idx], rect);
443
467
  }
444
468
  }
469
+
470
+ // Always include loading sentinel even when virtualized, we assume it is always the last child for now
471
+ let lastRow = node.children.at(-1);
472
+ if (lastRow?.layoutInfo.type === 'loader') {
473
+ res.push(lastRow.layoutInfo);
474
+ }
445
475
  break;
446
476
  }
447
477
  case 'headerrow':
@@ -543,7 +573,7 @@ export class TableLayout<T, O extends TableLayoutProps = TableLayoutProps> exten
543
573
  y += this.virtualizer!.visibleRect.y;
544
574
 
545
575
  // Find the closest item within on either side of the point using the gap width.
546
- let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2);
576
+ let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2));
547
577
  let candidates = this.getVisibleLayoutInfos(searchRect);
548
578
  let key: Key | null = null;
549
579
  let minDistance = Infinity;
@@ -140,8 +140,9 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
140
140
  columnHeights[column] += layoutInfo.rect.height + minSpace.height;
141
141
  };
142
142
 
143
+ let collection = this.virtualizer!.collection;
143
144
  let skeletonCount = 0;
144
- for (let node of this.virtualizer!.collection) {
145
+ for (let node of collection) {
145
146
  if (node.type === 'skeleton') {
146
147
  // Add skeleton cards until every column has at least one, and we fill the viewport.
147
148
  let startingHeights = [...columnHeights];
@@ -154,13 +155,23 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
154
155
  addNode(key, content);
155
156
  }
156
157
  break;
157
- } else {
158
+ } else if (node.type !== 'loader') {
158
159
  addNode(node.key, node);
159
160
  }
160
161
  }
161
162
 
162
- // Reset all columns to the maximum for the next section
163
- let maxHeight = Math.max(...columnHeights);
163
+ // Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
164
+ // Add it under the first column for simplicity
165
+ let lastNode = collection.getItem(collection.getLastKey()!);
166
+ if (lastNode?.type === 'loader') {
167
+ let rect = new Rect(horizontalSpacing, columnHeights[0], itemWidth, 0);
168
+ let layoutInfo = new LayoutInfo('loader', lastNode.key, rect);
169
+ newLayoutInfos.set(lastNode.key, layoutInfo);
170
+ }
171
+
172
+ // Reset all columns to the maximum for the next section. If loading, set to 0 so virtualizer doesn't render its body since there aren't items to render
173
+ let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
174
+ let maxHeight = isEmptyOrLoading ? 0 : Math.max(...columnHeights);
164
175
  this.contentSize = new Size(this.virtualizer!.visibleRect.width, maxHeight);
165
176
  this.layoutInfos = newLayoutInfos;
166
177
  this.numColumns = numColumns;
@@ -177,14 +188,14 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
177
188
  getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
178
189
  let layoutInfos: LayoutInfo[] = [];
179
190
  for (let layoutInfo of this.layoutInfos.values()) {
180
- if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key)) {
191
+ if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key) || layoutInfo.type === 'loader') {
181
192
  layoutInfos.push(layoutInfo);
182
193
  }
183
194
  }
184
195
  return layoutInfos;
185
196
  }
186
197
 
187
- updateItemSize(key: Key, size: Size) {
198
+ updateItemSize(key: Key, size: Size): boolean {
188
199
  let layoutInfo = this.layoutInfos.get(key);
189
200
  if (!size || !layoutInfo) {
190
201
  return false;