Skip to content

feat: Support keyboard focusing loading spinners in collection components #8326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: multi_loader_support
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/@react-aria/grid/src/GridKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
}

private isDisabled(item: Node<unknown>) {
return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key));
return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key) || (item.type === 'loader' && !item.props.isLoading));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more places where we have to account for the fact that the loading sentinel is in the collection but not actually loading...

}

protected findPreviousKey(fromKey?: Key, pred?: (item: Node<T>) => boolean): Key | null {
Expand Down Expand Up @@ -148,10 +148,10 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
}

// Find the next item
key = this.findNextKey(key, (item => item.type === 'item'));
key = this.findNextKey(key, (item => item.type === 'item' || item.type === 'loader'));
if (key != null) {
// If focus was on a cell, focus the cell with the same index in the next row.
if (this.isCell(startItem)) {
if (this.isCell(startItem) && this.collection.getItem(key)?.type === 'item') {
let startIndex = startItem.colIndex ? startItem.colIndex : startItem.index;
return this.getKeyForItemInRowByIndex(key, startIndex);
}
Expand Down Expand Up @@ -334,7 +334,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
}

// Find the last row
key = this.findPreviousKey(undefined, item => item.type === 'item');
key = this.findPreviousKey(undefined, item => item.type === 'item' || item.type === 'loader');

// If global flag is set (or if focus mode is cell), focus the last cell in the last row.
if (key != null && ((item && this.isCell(item) && global) || this.focusMode === 'cell')) {
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/grid/src/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
import {filterDOMProps, mergeProps, useId} from '@react-aria/utils';
import {getItemCount} from '@react-stately/collections';
import {GridCollection} from '@react-types/grid';
import {GridKeyboardDelegate} from './GridKeyboardDelegate';
import {gridMap} from './utils';
Expand Down Expand Up @@ -175,7 +176,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
);

if (isVirtualized) {
gridProps['aria-rowcount'] = state.collection.size;
gridProps['aria-rowcount'] = getItemCount(state.collection, (node) => node.type === 'item' || (node.type === 'loader' && node.props.isLoading));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, bit unfortunate that the collection's size is not longer reflective of the number of rows since we don't actually want to count the loading sentinel if it isn't actually loading. Shouldn't be that much more expensive hopefully since getItemCount does some caching

gridProps['aria-colcount'] = state.collection.columnCount;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RefObject
} from '@react-types/shared';
import {filterDOMProps, mergeProps, useId} from '@react-aria/utils';
import {getItemCount} from '@react-stately/collections';
import {listMap} from './utils';
import {ListState} from '@react-stately/list';
import {useGridSelectionAnnouncement, useHighlightSelectionDescription} from '@react-aria/grid';
Expand Down Expand Up @@ -167,7 +168,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
);

if (isVirtualized) {
gridProps['aria-rowcount'] = state.collection.size;
gridProps['aria-rowcount'] = getItemCount(state.collection, (node) => node.type === 'item' || (node.type === 'loader' && node.props.isLoading));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line 154 i think has to check this too since the loader is "tabbable"
also line 166?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh that right, didn't noticed since useSelectableCollection actually still ensures we have the right tabindex in this case. Relates to the last comment I made below, ugh

Copy link
Member Author

@LFDanLu LFDanLu Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm, this opened up a can of worms since I had incorrectly assumed that all the other collection.size checks wouldn't be a problem since they all are empty collection checks but we do need the same kind of logic there for correctness. Rather than propagate this getItemCount in all those places perhaps we should update the collection so that collection.size does a similar check to above? Or maybe we should consider trying to pull the loader out of the collection? Poking around, but I'm leaning towards updating collection's .size

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@devongovett any initial opinions on this? We had discussed previously that checks like

let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
were a bit weird and debated whether or not the loaders should be in the collection

gridProps['aria-colcount'] = 1;
}

Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/listbox/src/useOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export function useOption<T>(props: AriaOptionProps, state: ListState<T>, ref: R
if (isVirtualized) {
let index = Number(item?.index);
optionProps['aria-posinset'] = Number.isNaN(index) ? undefined : index + 1;
optionProps['aria-setsize'] = getItemCount(state.collection);
// TODO: this is not great, but the loader sentinel is always in the collection even when loading isn't currently in progress.
// This same issue applies to the other collection elements, namely the row counts calculated for the top level parent element
optionProps['aria-setsize'] = getItemCount(state.collection, (node) => node.type === 'item' || (node.type === 'loader' && node.props.isLoading));
Comment on lines 123 to +126
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are kinda weird in RAC/S2 when there are sections, the setsize is still based on the whole collection but the posinset is based off the index within the section since the collection calculated index is different in new collections

}

let onAction = data?.onAction ? () => data?.onAction?.(key) : undefined;
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re

if (isVirtualized) {
ariaProps['aria-posinset'] = item?.index;
ariaProps['aria-setsize'] = getItemCount(state.collection);
ariaProps['aria-setsize'] = getItemCount(state.collection, (node) => node.type === 'item' || (node.type === 'loader' && node.props.isLoading));
}

let onPressStart = (e: PressEvent) => {
Expand Down Expand Up @@ -307,8 +307,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
...mergeProps(
domProps,
linkProps,
isTrigger
? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
isTrigger
? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
: itemProps,
pressProps,
hoverProps,
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/selection/src/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

private isDisabled(item: Node<unknown>) {
return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key));
return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key) || (item.type === 'loader' && !item.props.isLoading));
}

private findNextNonDisabled(key: Key | null, getNext: (key: Key) => Key | null): Key | null {
let nextKey = key;
while (nextKey != null) {
let item = this.collection.getItem(nextKey);
if (item?.type === 'loader' || (item?.type === 'item' && !this.isDisabled(item))) {
if ((item?.type === 'loader' || (item?.type === 'item')) && !this.isDisabled(item)) {
return nextKey;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/table/src/useTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function useTable<T>(props: AriaTableProps, state: TableState<T> | TreeGr

// Override to include header rows
if (isVirtualized) {
gridProps['aria-rowcount'] = state.collection.size + state.collection.headerRows.length;
gridProps['aria-rowcount'] = gridProps['aria-rowcount']! + state.collection.headerRows.length;
}

if (tableNestedRows() && 'expandedKeys' in state) {
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-spectrum/s2/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,13 @@ const iconStyles = style({
});

const loadingWrapperStyles = style({
...focusRing(),
...control({shape: 'default'}),
gridColumnStart: '1',
gridColumnEnd: '-1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginY: 8
justifyContent: 'center'
});

const progressCircleStyles = style({
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-spectrum/s2/src/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,13 @@ const iconStyles = style({
});

const loadingWrapperStyles = style({
...focusRing(),
...control({shape: 'default'}),
gridColumnStart: '1',
gridColumnEnd: '-1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginY: 8
justifyContent: 'center'
});

const progressCircleStyles = style({
Expand Down
21 changes: 20 additions & 1 deletion packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,22 @@ const centeredWrapper = style({
height: 'full'
});

const loadingWrapperStyles = style({
width: 'full',
height: 'full',
boxSizing: 'border-box',
outlineStyle: {
default: 'none',
isFocusVisible: 'solid'
},
outlineWidth: 2,
outlineOffset: -2,
outlineColor: {
default: 'focus-ring',
forcedColors: 'Highlight'
}
});

export interface TableBodyProps<T> extends Omit<RACTableBodyProps<T>, 'style' | 'className'> {}

/**
Expand All @@ -390,7 +406,10 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T
// This is because we don't distinguish between loadingMore and loading in the layout, resulting in a different rect being used to build the body. Perhaps can be considered as a user error
// if they pass loadingMore without having any other items in the table. Alternatively, could update the layout so it knows the current loading state.
let loadMoreSpinner = (
<UNSTABLE_TableLoadingSentinel isLoading={loadingState === 'loadingMore'} onLoadMore={onLoadMore} className={style({height: 'full', width: 'full'})}>
<UNSTABLE_TableLoadingSentinel
isLoading={loadingState === 'loadingMore'}
onLoadMore={onLoadMore}
className={loadingWrapperStyles}>
<div className={centeredWrapper}>
<ProgressCircle
isIndeterminate
Expand Down
13 changes: 9 additions & 4 deletions packages/@react-stately/collections/src/getItemCount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {getChildNodes} from './getChildNodes';

const cache = new WeakMap<Iterable<unknown>, number>();

export function getItemCount<T>(collection: Collection<Node<T>>): number {
export function getItemCount<T>(collection: Collection<Node<T>>, isValidItem: ((node: Node<T>) => boolean) = (node) => node.type === 'item'): number {
let count = cache.get(collection);
if (count != null) {
return count;
Expand All @@ -25,11 +25,16 @@ export function getItemCount<T>(collection: Collection<Node<T>>): number {
let counter = 0;
let countItems = (items: Iterable<Node<T>>) => {
for (let item of items) {
if (item.type === 'section') {
countItems(getChildNodes(item, collection));
} else if (item.type === 'item') {
if (isValidItem(item)) {
counter++;
}

// TODO: New collections vs old collection is different here. New collections for table will only return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still a TODO here? or are things working and you just want alternatives?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter, feels ok for now but a bit wary about how the loading spinners being included in the collections now means we can't lean on collection.size anymore

// the header and body when the collection is iterated on, but old collections will return the body rows...
// I could forgo using getItemCount, but the collection.rows is different between old and new collection too... (old includes header row)
if (item.type === 'section' || item.type === 'tablebody') {
countItems(getChildNodes(item, collection));
}
}
};

Expand Down
97 changes: 51 additions & 46 deletions packages/@react-stately/grid/src/useGridState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,55 +59,60 @@ export function useGridState<T extends object, C extends GridCollection<T>>(prop
// Reset focused key if that item is deleted from the collection.
const cachedCollection = useRef<C | null>(null);
useEffect(() => {
if (selectionState.focusedKey != null && cachedCollection.current && !collection.getItem(selectionState.focusedKey)) {
const node = cachedCollection.current.getItem(selectionState.focusedKey);
const parentNode =
node?.parentKey != null && (node.type === 'cell' || node.type === 'rowheader' || node.type === 'column') ?
cachedCollection.current.getItem(node.parentKey) :
node;
if (!parentNode) {
selectionState.setFocusedKey(null);
return;
}
const cachedRows = cachedCollection.current.rows;
const rows = collection.rows;
const diff = cachedRows.length - rows.length;
let index = Math.min(
(
diff > 1 ?
Math.max(parentNode.index - diff + 1, 0) :
parentNode.index
),
rows.length - 1);
let newRow: GridNode<T> | null = null;
while (index >= 0) {
if (!selectionManager.isDisabled(rows[index].key) && rows[index].type !== 'headerrow') {
newRow = rows[index];
break;
if (selectionState.focusedKey != null && cachedCollection.current) {
let item = collection.getItem(selectionState.focusedKey);
if (!item || (item.type === 'loader' && !item.props.isLoading)) {
const node = cachedCollection.current.getItem(selectionState.focusedKey);
const parentNode =
node?.parentKey != null && (node.type === 'cell' || node.type === 'rowheader' || node.type === 'column') ?
cachedCollection.current.getItem(node.parentKey) :
node;
if (!parentNode) {
selectionState.setFocusedKey(null);
return;
}
// Find next, not disabled row.
if (index < rows.length - 1) {
index++;
// Otherwise, find previous, not disabled row.
} else {
if (index > parentNode.index) {
index = parentNode.index;
const cachedRows = cachedCollection.current.rows;
const rows = collection.rows;
const diff = cachedRows.length - rows.length;
let index = Math.min(
(
diff > 1 ?
Math.max(parentNode.index - diff + 1, 0) :
parentNode.index
),
rows.length - 1);
let newRow: GridNode<T> | null = null;
while (index >= 0) {
// Find a row that is not disabled, nor is a header row, nor is a loader sentinel that isn't loading
if (!selectionManager.isDisabled(rows[index].key) && rows[index].type !== 'headerrow' && !(rows[index].type === 'loader' && !rows[index].props.isLoading)) {
newRow = rows[index];
break;
}
// Find next, not disabled row.
if (index < rows.length - 1) {
index++;
// Otherwise, find previous, not disabled row.
} else {
// eslint-disable-next-line max-depth
if (index > parentNode.index) {
index = parentNode.index;
}
index--;
}
index--;
}
}
if (newRow) {
const childNodes = newRow.hasChildNodes ? [...getChildNodes(newRow, collection)] : [];
const keyToFocus =
newRow.hasChildNodes &&
parentNode !== node &&
node &&
node.index < childNodes.length ?
childNodes[node.index].key :
newRow.key;
selectionState.setFocusedKey(keyToFocus);
} else {
selectionState.setFocusedKey(null);
if (newRow) {
const childNodes = newRow.hasChildNodes ? [...getChildNodes(newRow, collection)] : [];
const keyToFocus =
newRow.hasChildNodes &&
parentNode !== node &&
node &&
node.index < childNodes.length ?
childNodes[node.index].key :
newRow.key;
selectionState.setFocusedKey(keyToFocus);
} else {
selectionState.setFocusedKey(null);
}
}
}
cachedCollection.current = collection;
Expand Down
9 changes: 5 additions & 4 deletions packages/@react-stately/layout/src/ListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
y = 0;
}

for (let node of collection) {
for (let node of collectionNodes) {
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap;
// Skip rows before the valid rectangle unless they are already cached.
if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) {
Expand All @@ -279,12 +279,13 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
}

// Build each loader that exists in the collection that is outside the visible rect so that they are persisted
// at the proper estimated location
if (y > this.requestedRect.maxY) {
// at the proper estimated location. If the node.type is "section" then we don't do this shortcut since we have to
// build the sections to see how tall they are.
if ((node.type === 'item' || node.type === 'loader') && y > this.requestedRect.maxY) {
Comment on lines +282 to +284
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still basically how it worked previously,

if (node.type === 'item' && y > this.requestedRect.maxY) {

We only used this shortcut when processing items, if there are sections then iterating through the collection will only return sections since we don't support sections and sectionless items at the same time

let lastProcessedIndex = collectionNodes.indexOf(node);
for (let loaderNode of loaderNodes) {
let loaderNodeIndex = collectionNodes.indexOf(loaderNode);
// Subtract by an addition 1 since we've already added the current item's height to y
// Subtract by an additional 1 since we've already added the current item's height to y
y += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight;
let loader = this.buildChild(loaderNode, this.padding, y, null);
nodes.push(loader);
Expand Down
Loading