-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Add support for custom collection renderers (e.g. virtualization) #5912
Conversation
Build successful! 🎉 |
|
||
function SubmenuTriggerInner(props) { | ||
let {item, parentMenuRef} = props; | ||
export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigger', (props: SubmenuTriggerProps, ref: ForwardedRef<HTMLDivElement>, item) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SubmenuTrigger changed so that the state/hooks for submenus live at the trigger level and get passed down to the MenuItem via context rather than having two separate MenuItem implementations. This works due to ref merging by useContextProps.
@@ -117,7 +115,7 @@ export function UNSTABLE_useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, st | |||
|
|||
let submenuProps = { | |||
id: overlayId, | |||
'aria-label': node.textValue, | |||
'aria-labelledby': submenuTriggerId, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This avoids needing to pass in the menu item node. In RAC's case, the node is the submenutrigger
node instead.
With virtualization, rendered Also, when the heading for a group is scrolled out of view, the group will no longer have an accessibility name, which differs from the behavior in React-Spectrum ListBox. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just some initial things found from testing, will edit the comment as I continue my review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Haven't looked at the internals yet, will revisit then. Jotting down issues found during a testing):
- Noticed that PageUp/PageDown only advance focus by one item after the first initial "page" jump
- Home/End don't seem to cause scroll position to move the the first/last item
- focus is lost to the body of the page when you scroll the focused item out of view
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah in a real implementation the keyboard delegate would need to be overridden on the listbox and get set to the layout. I can probably do that in the story.
For reviewers, keep in mind that the demo virtualizer is not a full implementation yet, it's just a simple demo in the story. The PR is mainly about setting up the infrastructure to support custom collection renderers. More work will be needed for a full virtualizer implementation. But feel free to post the issues you run into! |
layout={layout} | ||
style={{height: 'inherit'}} | ||
collection={collection} | ||
shouldUseVirtualFocus> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this really have shouldUseVirtualFocus
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used that so that we don't apply a tabIndex
to the virtualizer element. In RSP, the virtualizer is the outer most element (the one with role="listbox"
) so this made sense. But here, the virtualizer is rendered inside the listbox (it has role="presentation"
), so it should not also have a tabIndex. Really virtualizer is duplicating the logic already handled by the listbox/selection hooks, so it isn't really necessary.
} | ||
} | ||
}); | ||
let renderer = useContext(CollectionRendererContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is grabbing from the CollectionRendererContext, what happens if you have nested components but the inner component shouldn't be virtualized? For instance, maybe a virtualized Table with non-virtualized Menus within?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could clear the context in "leaves"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a good point. another idea would be to change the API to provide collection renderers as a prop instead of context. The component would then have to propagate it to children via its own context, but that wouldn't apply to other components. I sort of liked the idea that virtualizer could just be a wrapper though. Not sure...
return <>{useCollectionChildren(props)}</>; | ||
} | ||
|
||
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => JSX.Element) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mentioned you weren't happy about the api here for these, did you have any other specific concerns other than possibly combining them more? To me, using these for custom collections already feels like a pretty advanced use case that needs a deeper understanding of how the RAC collections work so not sure how to simplify them more tbh
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the input, we'd still like to eliminate the useShallowComponent since that name doesn't make a ton of sense. We'd discussed something like this in order to make a meaningfully named option, though haven't quite gotten the types worked out for it.
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => JSX.Element, allowsOutsideCollection?: false);
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: Node<T>) => JSX.Element, allowsOutsideCollection: true);
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: Node<T>) => JSX.Element, allowsOutsideCollection: boolean = false) {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, I understand the desire to remove createShallowComponent()
👍.
Personally i am not a big fan of hiding things behind boolean flags, since they are hard to read and discover, but I also don’t have a better suggestion in mind at current.
PS: I must have accidentally deleted my previous comment, so excuse the messed up timeline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, we're not totally enamored with it, either. But we think it's better than createShallowComponent
, it's descriptive and shows it's only allowed for a leaf. If anyone has a better idea, we're all ears.
No worries about the deleted comment.
} | ||
|
||
function MenuSection<T>({section, className, style, parentMenuRef, ...otherProps}: MenuSectionProps<T>) { | ||
function MenuSection<T>(section: Node<T>, props: SectionProps<T>, ref: ForwardedRef<HTMLElement>) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick to be consistent with argument order, the createBranch etc all do
props, ref, node
whereas this is node, props, ref
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yeah I changed this at the last minute, good point
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we align these? or does this just need to be resolved now?
Build successful! 🎉 |
@@ -77,6 +78,7 @@ export class NodeValue<T> implements Node<T> { | |||
node.firstChildKey = this.firstChildKey; | |||
node.lastChildKey = this.lastChildKey; | |||
node.props = this.props; | |||
node.render = this.render; | |||
return node; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: This whole block could be replaced.
return node; | |
return Object.assign(node, this); |
…n-renderers # Conflicts: # packages/@react-aria/menu/src/useSubmenuTrigger.ts # packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx # packages/@react-spectrum/menu/src/SubmenuTrigger.tsx # packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx # packages/react-aria-components/src/Breadcrumbs.tsx # packages/react-aria-components/src/Menu.tsx # packages/react-aria-components/src/Table.tsx # packages/react-aria-components/stories/ListBox.stories.tsx
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couple questions from just looking at the code, I'll go test it out now
}); | ||
}; | ||
|
||
export const CollectionRendererContext = createContext<CollectionRenderer>(useDefaultCollectionRenderer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is the renderer all we'll ever need back from this?
or do we want to make it an object in case we decide we want to send more information through this context?
I don't have an example of anything yet
} | ||
|
||
function MenuSection<T>({section, className, style, parentMenuRef, ...otherProps}: MenuSectionProps<T>) { | ||
function MenuSection<T>(section: Node<T>, props: SectionProps<T>, ref: ForwardedRef<HTMLElement>) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we align these? or does this just need to be resolved now?
Initial testing looks good! |
When the virtualizer updates the visible views, it triggers a re-render of the parent component (e.g. section) whose children changed. But we don't need to re-render the wrapper element, just the children. By splitting this into a separate component, we avoid re-rendering the parent when only its children changed.
…n-renderers # Conflicts: # packages/react-aria-components/src/Breadcrumbs.tsx
I realized I will need to do some significant refactoring to Virtualizer to support RAC, which is somewhat separate from this work to support custom collection renderers. I would like to land this change initially and follow up with that additional work next. Though there might be some additional changes to the collection renderer API, this is already useful on its own for other non-virtualizer cases (e.g. collapsing breadcrumbs). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, did a quick sweep and the collection components seem to be working as expected. Also verified locally that leaf components properly error if rendered outside a collection except for Header. Definitely like how this has simplified the internals of many of the RAC components now that the collection nodes know how to render themselves
/** A list of child tree item objects used when dynamically rendering the tree item children. */ | ||
childItems?: Iterable<T> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For posterity in case we need to find the related ticket/discussion: https://github.com/orgs/adobe/projects/19/views/4?filterQuery=rac+colle&pane=issue&itemId=24923448
We will want to possibly change TreeView to a more RAC Tree like api instead of mimicking the same api of other current RSP collection components (e.g. using TreeItemContent
instead of <TreeViewItem childItems={item.childItems} ...>
) which will let us get rid of this prop
…n-renderers # Conflicts: # packages/react-aria-components/src/Breadcrumbs.tsx
## API Changes
unknown top level export { type: 'any' } @react-aria/menuAriaSubmenuTriggerProps AriaSubmenuTriggerProps {
delay?: number = 200
isDisabled?: boolean
- node: RSNode<unknown>
parentMenuRef: RefObject<HTMLElement>
submenuRef: RefObject<HTMLElement>
type?: 'dialog' | 'menu'
} it changed:
@react-spectrum/treeTreeViewItem-TreeViewItem {
- TreeViewItem?: boolean
-}
+ SpectrumTreeViewItemProps-SpectrumTreeViewItemProps {
- children: ReactNode
- hasChildItems?: boolean
-}
+ undefined-
+TreeViewItem<T extends {}> {
+ TreeViewItem?: boolean
+} undefined-
+SpectrumTreeViewItemProps<T extends {} = {}> {
+ childItems?: Iterable<{}>
+ children: ReactNode
+ hasChildItems?: boolean
+} @react-stately/layoutListLayout ListLayout<T> {
allowDisabledKeyFocus: boolean
buildChild: (Node<T>, number, number) => LayoutNode
buildCollection: () => Array<LayoutNode>
+ buildHeader: (Node<T>, number, number) => LayoutNode
buildItem: (Node<T>, number, number) => LayoutNode
buildNode: (Node<T>, number, number) => LayoutNode
buildSection: (Node<T>, number, number) => LayoutNode
collection: Collection<Node<T>>
disabledKeys: Set<Key>
getContentSize: () => void
getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
getFinalLayoutInfo: (LayoutInfo) => void
getFirstKey: () => Key | null
getInitialLayoutInfo: (LayoutInfo) => void
getKeyAbove: (Key) => Key | null
getKeyBelow: (Key) => Key | null
getKeyForSearch: (string, Key) => Key | null
getKeyPageAbove: (Key) => Key | null
getKeyPageBelow: (Key) => Key | null
getLastKey: () => Key | null
getLayoutInfo: (Key) => void
getVisibleLayoutInfos: (Rect) => void
isLoading: boolean
isValid: (Node<T>, number) => void
isVisible: (LayoutNode, Rect) => void
updateItemSize: (Key, Size) => void
updateLayoutNode: (Key, LayoutInfo, LayoutInfo) => void
validate: (InvalidationContext<Node<T>, unknown>) => void
}
|
This adds support for custom collection renderers to React Aria Components, which enables features like virtualized scrolling where a subset of the collection is rendered to the DOM. Other features like breadcrumb and action group collapsing can also be built as collection renderers.
A collection renderer is a function that is provided to a component via context, which means it wraps around a collection component. For example, to make a
ListBox
virtualized, you would wrap it in a<Virtualizer>
:The
Virtualizer
component provides theCollectionRendererContext
to theListBox
, which uses it to render the collection to DOM nodes. By default,CollectionRendererContext
is set to a function that renders all items to the DOM (the same way as currently). This function accepts two arguments: the collection itself, and the parent node whose items should be rendered (e.g. in the case of sections). A virtualizer would then filter the list of items to include only visible items.To enable this in a generic way, collection nodes now know how to render themselves to the DOM. This means that a CollectionRenderer does not need to know how to render specific nodes, it delegates back to the nodes themselves. This also means that custom implementations of collection components such as
ListBoxItem
can be created and work within an existingListBox
– the item components are no longer coupled to the parent components.This works by creating two new APIs for building collection components:
createLeafComponent
– creates a collection component that doesn't accept child nodes. You must specify a node type (e.g.item
), and a function to render that node.createBranchComponent
– creates a collection component that expects children. It also accepts a node type and render function, but also accepts a function that returns the collection children (defaulting touseCollectionChildren
).The plan would be to eventually expose these APIs publicly so custom collection components can be built, but I'm not totally happy with these APIs yet. Feels like there could be something that might be able to combine them a bit more. Suggestions welcome.