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

Closed
wants to merge 10 commits into from

Conversation

LFDanLu
Copy link
Member

@LFDanLu LFDanLu commented May 30, 2025

Closes

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

RSP

@rspbot
Copy link

rspbot commented May 30, 2025

Build successful! 🎉

Comment on lines 123 to +126
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));
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

@rspbot
Copy link

rspbot commented May 30, 2025

Build successful! 🎉

@rspbot
Copy link

rspbot commented May 30, 2025

Build successful! 🎉

@LFDanLu LFDanLu marked this pull request as ready for review May 30, 2025 22:27
Comment on lines +107 to +115
const diff: number = (cachedItemNodes?.length ?? 0) - (itemNodes?.length ?? 0);
// Look up the start item's index in the cached node array. We can't rely on the node's index
// because it maybe in a section and thus have a index relative to that section
// TODO: the collection keys/item are not guarenteed to be in order
// (aka the loader will be the first node in the keymap if the collection was empty and then loaded items)
// this means we should ideally traverse through the key's prevKey until we find a focusable node but
// that is a bigger refactor, will handle later. For now continue assuming that the loader is the last element
let startItemIndex = 0;
if (startItem) {
Copy link
Member Author

Choose a reason for hiding this comment

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

To avoid further bloat, will be handling this refactor later. Perhaps something can be done in the RAC collection level but otherwise we should maybe consider refactoring this to use the old focused key's prevKey instead of index since the collection's keymap order is not necessarily in index order.

Will need to discuss where exactly we want to restore focus (in roughly the same place? or should it prefer the closest contextually matching row regardless of where that maybe in the collection?)

@rspbot
Copy link

rspbot commented May 30, 2025

Build successful! 🎉

@@ -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...

@@ -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

Comment on lines 282 to 284
// 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) {
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

snowystinger

This comment was marked as off-topic.

@@ -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

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

LFDanLu added a commit that referenced this pull request Jun 2, 2025
github-merge-queue bot pushed a commit that referenced this pull request Jun 4, 2025
* Update tree and listlayout to handle multi loaders

* adapting other stories to new loader api and adding useAsync example story

* add tests

* fix story for correctness, should only need to provide a dependecy at the top most collection

* restoring focus back to the tree if the user was focused on the loader

* fixing estimated loader position if sections exist

taken from #8326

* skip test for now
Base automatically changed from multi_loader_support to main June 4, 2025 17:27
@LFDanLu LFDanLu changed the base branch from main to multi_loader_support June 4, 2025 22:20
@rspbot
Copy link

rspbot commented Jun 5, 2025

Build successful! 🎉

@rspbot
Copy link

rspbot commented Jun 5, 2025

## API Changes

react-aria-components

/react-aria-components:UNSTABLE_GridListLoadingSentinel

 UNSTABLE_GridListLoadingSentinel <T extends {}> {
   children?: ReactNode
-  className?: string
+  className?: string | ((GridListLoadingSentinelRenderProps & {
+    defaultClassName: string | undefined
+})) => string
   isLoading?: boolean
   onLoadMore?: () => any
   scrollOffset?: number = 1
-  style?: CSSProperties
+  style?: CSSProperties | ((GridListLoadingSentinelRenderProps & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
 }

/react-aria-components:UNSTABLE_ListBoxLoadingSentinel

 UNSTABLE_ListBoxLoadingSentinel <T extends {}> {
   children?: ReactNode
-  className?: string
+  className?: string | ((ListBoxLoadingSentinelRenderProps & {
+    defaultClassName: string | undefined
+})) => string
   isLoading?: boolean
   onLoadMore?: () => any
   scrollOffset?: number = 1
-  style?: CSSProperties
+  style?: CSSProperties | ((ListBoxLoadingSentinelRenderProps & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
 }

/react-aria-components:UNSTABLE_TableLoadingSentinel

 UNSTABLE_TableLoadingSentinel <T extends {}> {
   children?: ReactNode
-  className?: string
+  className?: string | ((TableLoadingSentinelRenderProps & {
+    defaultClassName: string | undefined
+})) => string
   isLoading?: boolean
   onLoadMore?: () => any
   scrollOffset?: number = 1
-  style?: CSSProperties
+  style?: CSSProperties | ((TableLoadingSentinelRenderProps & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
 }

/react-aria-components:UNSTABLE_TreeLoadingIndicator

-UNSTABLE_TreeLoadingIndicator <T extends {}> {
-  children?: ReactNode | ((UNSTABLE_TreeLoadingIndicatorRenderProps & {
-    defaultChildren: ReactNode | undefined
-})) => ReactNode
-  className?: string | ((UNSTABLE_TreeLoadingIndicatorRenderProps & {
-    defaultClassName: string | undefined
-})) => string
-  style?: CSSProperties | ((UNSTABLE_TreeLoadingIndicatorRenderProps & {
-    defaultStyle: CSSProperties
-})) => CSSProperties | undefined
-}

/react-aria-components:UNSTABLE_TreeLoadingSentinel

+UNSTABLE_TreeLoadingSentinel <T extends {}> {
+  children?: ReactNode | ((TreeLoadingSentinelRenderProps & {
+    defaultChildren: ReactNode | undefined
+})) => ReactNode
+  className?: string | ((TreeLoadingSentinelRenderProps & {
+    defaultClassName: string | undefined
+})) => string
+  isLoading?: boolean
+  onLoadMore?: () => any
+  scrollOffset?: number = 1
+  style?: CSSProperties | ((TreeLoadingSentinelRenderProps & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}

@react-stately/collections

/@react-stately/collections:getItemCount

 getItemCount <T> {
   collection: Collection<Node<T>>
+  isValidItem: (Node<T>) => boolean
   returnVal: undefined
 }

Copy link
Member Author

Choose a reason for hiding this comment

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

Note: will need to handle the case where the user is using Left/Right to change the selected key when the listbox is closed and focus is on the Picker. If the loading spinner is present, it will just show an empty option, should prevent the loader from being selectable in this case

@LFDanLu
Copy link
Member Author

LFDanLu commented Jun 17, 2025

closing for now, will require a larger refactor than expected since ideally we'd have each node contain information about it's own focusability/selectability

@LFDanLu LFDanLu closed this Jun 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants