Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K
*/
export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, ref: RefObject<FocusableElement>): MenuItemAria {
let {
id,
key,
closeOnSelect,
isVirtualized,
Expand Down Expand Up @@ -160,6 +161,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
let keyboardId = useSlotId();

let ariaProps = {
id,
'aria-disabled': isDisabled || undefined,
role,
'aria-label': props['aria-label'],
Expand Down
13 changes: 8 additions & 5 deletions packages/@react-aria/menu/src/useSubmenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@
import {AriaMenuItemProps} from './useMenuItem';
import {AriaMenuOptions} from './useMenu';
import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
import {FocusableElement, FocusStrategy, KeyboardEvent, PressEvent, Node as RSNode} from '@react-types/shared';
import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent} from '@react-types/shared';
import {RefObject, useCallback, useRef} from 'react';
import type {SubmenuTriggerState} from '@react-stately/menu';
import {useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';
import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu';

export interface AriaSubmenuTriggerProps {
/** An object representing the submenu trigger menu item. Contains all the relevant information that makes up the menu item. */
node: RSNode<unknown>,
Comment thread
snowystinger marked this conversation as resolved.
/**
* An object representing the submenu trigger menu item. Contains all the relevant information that makes up the menu item.
* @deprecated
*/
node?: Node<unknown>,
/** Whether the submenu trigger is disabled. */
isDisabled?: boolean,
/** The type of the contents that the submenu trigger opens. */
Expand Down Expand Up @@ -64,7 +67,7 @@ export interface SubmenuTriggerAria<T> {
* @param ref - Ref to the submenu trigger element.
*/
export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: SubmenuTriggerState, ref: RefObject<FocusableElement>): SubmenuTriggerAria<T> {
let {parentMenuRef, submenuRef, type = 'menu', isDisabled, node, delay = 200} = props;
let {parentMenuRef, submenuRef, type = 'menu', isDisabled, delay = 200} = props;
let submenuTriggerId = useId();
let overlayId = useId();
let {direction} = useLocale();
Expand Down Expand Up @@ -117,7 +120,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm

let submenuProps = {
id: overlayId,
'aria-label': node.textValue,
'aria-labelledby': submenuTriggerId,
Copy link
Copy Markdown
Member Author

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.

submenuLevel: state.submenuLevel,
...(type === 'menu' && {
onClose: state.closeAll,
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/virtualizer/src/ScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
};

return (
<div {...otherProps} style={style} ref={ref} onScroll={onScroll}>
<div role="presentation" {...otherProps} style={style} ref={ref} onScroll={onScroll}>
<div role="presentation" style={innerStyle}>
{children}
</div>
Expand Down
8 changes: 6 additions & 2 deletions packages/@react-aria/virtualizer/src/Virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {Collection, Key} from '@react-types/shared';
import {getInteractionModality} from '@react-aria/interactions';
import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer';
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
import React, {FocusEvent, HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef} from 'react';
import React, {createContext, FocusEvent, HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef} from 'react';
import {ScrollView} from './ScrollView';
import {VirtualizerItem} from './VirtualizerItem';

Expand All @@ -39,6 +39,8 @@ interface VirtualizerProps<T extends object, V> extends Omit<HTMLAttributes<HTML
autoFocus?: boolean
}

export const VirtualizerContext = createContext<VirtualizerState<any, any, any> | null>(null);

function Virtualizer<T extends object, V extends ReactNode>(props: VirtualizerProps<T, V>, ref: RefObject<HTMLDivElement>) {
let {
children: renderView,
Expand Down Expand Up @@ -90,7 +92,9 @@ function Virtualizer<T extends object, V extends ReactNode>(props: VirtualizerPr
onScrollEnd={state.endScrolling}
sizeToFit={sizeToFit}
scrollDirection={scrollDirection}>
{state.visibleViews}
<VirtualizerContext.Provider value={state}>
{state.visibleViews}
</VirtualizerContext.Provider>
</ScrollView>
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/virtualizer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

export type {RTLOffsetType} from './utils';
export type {VirtualizerItemOptions} from './useVirtualizerItem';
export {useVirtualizer, Virtualizer} from './Virtualizer';
export {useVirtualizer, Virtualizer, VirtualizerContext} from './Virtualizer';
export {useVirtualizerItem} from './useVirtualizerItem';
export {VirtualizerItem, layoutInfoToStyle} from './VirtualizerItem';
export {ScrollView} from './ScrollView';
Expand Down
10 changes: 6 additions & 4 deletions packages/@react-aria/virtualizer/src/useVirtualizerItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ export function useVirtualizerItem(options: VirtualizerItemOptions) {
let {layoutInfo, virtualizer, ref} = options;

let updateSize = useCallback(() => {
let size = getSize(ref.current);
virtualizer.updateItemSize(layoutInfo.key, size);
}, [virtualizer, layoutInfo.key, ref]);
if (layoutInfo) {
let size = getSize(ref.current);
virtualizer.updateItemSize(layoutInfo.key, size);
}
}, [virtualizer, layoutInfo?.key, ref]);

useLayoutEffect(() => {
if (layoutInfo.estimatedSize) {
if (layoutInfo?.estimatedSize) {
updateSize();
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/listbox/src/ListBoxBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function ListBoxBase<T>(props: ListBoxBaseProps<T>, ref: RefObject<HTMLDivElemen
item={reusableView.content}
layoutInfo={reusableView.layoutInfo}
virtualizer={reusableView.virtualizer}
headerLayoutInfo={children.find(c => c.viewType === 'header').layoutInfo}>
headerLayoutInfo={children.find(c => c.viewType === 'header')?.layoutInfo}>
{renderChildren(children.filter(c => c.viewType === 'item'))}
</ListBoxSection>
);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/listbox/src/ListBoxSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function ListBoxSection<T>(props: ListBoxSectionProps<T>) {

return (
<Fragment>
<div role="presentation" ref={headerRef} style={layoutInfoToStyle(headerLayoutInfo, direction)}>
{headerLayoutInfo && <div role="presentation" ref={headerRef} style={layoutInfoToStyle(headerLayoutInfo, direction)}>
{item.key !== state.collection.getFirstKey() &&
<div
role="presentation"
Expand All @@ -67,7 +67,7 @@ export function ListBoxSection<T>(props: ListBoxSectionProps<T>) {
{item.rendered}
</div>
}
</div>
</div>}
<div
{...groupProps}
style={layoutInfoToStyle(layoutInfo, direction)}
Expand Down
2 changes: 0 additions & 2 deletions packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,9 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem
let triggerRef = useRef<HTMLLIElement>(null);
let popoverRef = useRef(null);
let {popoverContainer, trayContainerRef, rootMenuTriggerState, menu: parentMenuRef, state} = useMenuStateContext();
let triggerNode = state.collection.getItem(targetKey);
let submenuTriggerState = useSubmenuTriggerState({triggerKey: targetKey}, {...rootMenuTriggerState, ...state});
let submenuRef = unwrapDOMRef(popoverRef);
let {submenuTriggerProps, popoverProps} = useSubmenuTrigger({
node: triggerNode,
parentMenuRef,
submenuRef,
type: 'dialog',
Expand Down
4 changes: 1 addition & 3 deletions packages/@react-spectrum/menu/src/SubmenuTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,9 @@ function SubmenuTrigger(props: SubmenuTriggerProps) {
} = props;

let [menuTrigger, menu] = React.Children.toArray(children);
let {popoverContainer, trayContainerRef, menu: parentMenuRef, submenu: menuRef, rootMenuTriggerState, state} = useMenuStateContext();
let triggerNode = state.collection.getItem(targetKey);
let {popoverContainer, trayContainerRef, menu: parentMenuRef, submenu: menuRef, rootMenuTriggerState} = useMenuStateContext();
let submenuTriggerState = useSubmenuTriggerState({triggerKey: targetKey}, rootMenuTriggerState);
let {submenuTriggerProps, submenuProps, popoverProps} = useSubmenuTrigger({
node: triggerNode,
parentMenuRef,
submenuRef: menuRef
}, submenuTriggerState, triggerRef);
Expand Down
8 changes: 4 additions & 4 deletions packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('Submenu', function () {
expect(menus).toHaveLength(2);
let submenu1 = menus[1];
expect(document.activeElement).toBe(submenuTrigger1);
expect(submenu1).toHaveAttribute('aria-label', submenuTrigger1.textContent);
expect(submenu1).toHaveAttribute('aria-labelledby', submenuTrigger1.id);
expect(submenuTrigger1).toHaveAttribute('aria-expanded', 'true');
expect(submenuTrigger1).toHaveAttribute('aria-controls', submenu1.id);

Expand All @@ -114,7 +114,7 @@ describe('Submenu', function () {
expect(menus).toHaveLength(3);
let submenu2 = menus[2];
expect(document.activeElement).toBe(submenuTrigger2);
expect(submenu2).toHaveAttribute('aria-label', submenuTrigger2.textContent);
expect(submenu2).toHaveAttribute('aria-labelledby', submenuTrigger2.id);
expect(submenuTrigger2).toHaveAttribute('aria-expanded', 'true');
expect(submenuTrigger2).toHaveAttribute('aria-controls', submenu2.id);

Expand Down Expand Up @@ -828,7 +828,7 @@ describe('Submenu', function () {
let submenu1 = menus[0];
let submenu1Items = within(submenu1).getAllByRole('menuitem');
expect(document.activeElement).toBe(submenu1Items[0]);
expect(submenu1).toHaveAttribute('aria-label', submenuTrigger1.textContent);
expect(submenu1).toHaveAttribute('aria-labelledby', submenuTrigger1.id);
let trayDialog = within(tray).getByRole('dialog');
expect(trayDialog).toBeTruthy();
let backButton = within(trayDialog).getByRole('button');
Expand All @@ -852,7 +852,7 @@ describe('Submenu', function () {
let submenu2 = menus[0];
let submenu2Items = within(submenu2).getAllByRole('menuitem');
expect(document.activeElement).toBe(submenu2Items[0]);
expect(submenu2).toHaveAttribute('aria-label', submenuTrigger2.textContent);
expect(submenu2).toHaveAttribute('aria-labelledby', submenuTrigger2.id);
trayDialog = within(tray).getByRole('dialog');
backButton = within(trayDialog).getByRole('button');
expect(backButton).toHaveAttribute('aria-label', `Return to ${submenuTrigger2.textContent}`);
Expand Down
8 changes: 5 additions & 3 deletions packages/@react-spectrum/tree/src/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ export interface SpectrumTreeViewProps<T> extends Omit<AriaTreeGridListProps<T>,
children?: ReactNode | ((item: T) => ReactNode)
}

export interface SpectrumTreeViewItemProps extends Omit<TreeItemProps, 'className' | 'style' | 'value'> {
export interface SpectrumTreeViewItemProps<T extends object = object> extends Omit<TreeItemProps, 'className' | 'style' | 'value'> {
/** Rendered contents of the tree item or child items. */
children: ReactNode,
/** Whether this item has children, even if not loaded yet. */
hasChildItems?: boolean
hasChildItems?: boolean,
/** A list of child tree item objects used when dynamically rendering the tree item children. */
childItems?: Iterable<T>
Comment on lines +46 to +47
Copy link
Copy Markdown
Member

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: RSP Component Milestones (view)

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

}

interface TreeRendererContextValue {
Expand Down Expand Up @@ -220,7 +222,7 @@ const treeRowOutline = style({
}
});

export const TreeViewItem = (props: SpectrumTreeViewItemProps) => {
export const TreeViewItem = <T extends object>(props: SpectrumTreeViewItemProps<T>) => {
let {
children,
childItems,
Expand Down
76 changes: 47 additions & 29 deletions packages/@react-stately/layout/src/ListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
res = this.layoutInfos.get(key);
}

return res;
return res!;
}

getVisibleLayoutInfos(rect: Rect) {
Expand Down Expand Up @@ -271,41 +271,22 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
return this.buildSection(node, x, y);
case 'item':
return this.buildItem(node, x, y);
case 'header':
return this.buildHeader(node, x, y);
}
}

buildSection(node: Node<T>, x: number, y: number): LayoutNode {
let width = this.virtualizer.visibleRect.width;
let rectHeight = this.headingHeight;
let isEstimated = false;

// If no explicit height is available, use an estimated height.
if (rectHeight == null) {
// If a previous version of this layout info exists, reuse its height.
// Mark as estimated if the size of the overall collection view changed,
// or the content of the item changed.
let previousLayoutNode = this.layoutNodes.get(node.key);
if (previousLayoutNode && previousLayoutNode.header) {
let curNode = this.collection.getItem(node.key);
let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null;
rectHeight = previousLayoutNode.header.rect.height;
isEstimated = width !== this.lastWidth || curNode !== lastNode || previousLayoutNode.header.estimatedSize;
} else {
rectHeight = (node.rendered ? this.estimatedHeadingHeight : 0);
isEstimated = true;
}
}

if (rectHeight == null) {
rectHeight = DEFAULT_HEIGHT;
let header = null;
if (node.rendered) {
let headerNode = this.buildHeader(node, x, y);
header = headerNode.layoutInfo;
header.key += ':header';
header.parentKey = node.key;
y += header.rect.height;
}

let headerRect = new Rect(0, y, width, rectHeight);
let header = new LayoutInfo('header', node.key + ':header', headerRect);
header.estimatedSize = isEstimated;
header.parentKey = node.key;
y += header.rect.height;

let rect = new Rect(0, y, width, 0);
let layoutInfo = new LayoutInfo(node.type, node.key, rect);

Expand Down Expand Up @@ -343,6 +324,43 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
};
}

buildHeader(node: Node<T>, x: number, y: number): LayoutNode {
let width = this.virtualizer.visibleRect.width;
let rectHeight = this.headingHeight;
let isEstimated = false;

// If no explicit height is available, use an estimated height.
if (rectHeight == null) {
// If a previous version of this layout info exists, reuse its height.
// Mark as estimated if the size of the overall collection view changed,
// or the content of the item changed.
let previousLayoutNode = this.layoutNodes.get(node.key);
let previousLayoutInfo = previousLayoutNode?.header || previousLayoutNode?.layoutInfo;
if (previousLayoutInfo) {
let curNode = this.collection.getItem(node.key);
let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null;
rectHeight = previousLayoutInfo.rect.height;
isEstimated = width !== this.lastWidth || curNode !== lastNode || previousLayoutInfo.estimatedSize;
} else {
rectHeight = (node.rendered ? this.estimatedHeadingHeight : 0);
isEstimated = true;
}
}

if (rectHeight == null) {
rectHeight = DEFAULT_HEIGHT;
}

let headerRect = new Rect(0, y, width, rectHeight);
let header = new LayoutInfo('header', node.key, headerRect);
header.estimatedSize = isEstimated;
return {
layoutInfo: header,
children: [],
validRect: header.rect.intersection(this.validRect)
};
}

buildItem(node: Node<T>, x: number, y: number): LayoutNode {
let width = this.virtualizer.visibleRect.width;
let rectHeight = this.rowHeight;
Expand Down
24 changes: 15 additions & 9 deletions packages/@react-stately/virtualizer/src/Virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class Virtualizer<T extends object, V, W> {
private _visibleViews: Map<Key, ReusableView<T, V>>;
private _renderedContent: WeakMap<T, V>;
private _children: Set<ReusableView<T, V>>;
private _viewsByParentKey: Map<Key | null, ReusableView<T, V>[]>;
private _invalidationContext: InvalidationContext<T, V> | null;
private _overscanManager: OverscanManager;
private _persistedKeys: Set<Key>;
Expand Down Expand Up @@ -837,20 +838,25 @@ export class Virtualizer<T extends object, V, W> {
// by referencing a parentKey. Just before rendering the visible views, we rebuild this hierarchy
// by creating a mapping of views by parent key and recursively calling the delegate's renderWrapper
// method to build the final tree.
let viewsByParentKey = new Map([[null, []]]);
this._viewsByParentKey = new Map([[null, []]]);
for (let view of this._children) {
if (view.layoutInfo?.parentKey != null && !viewsByParentKey.has(view.layoutInfo.parentKey)) {
viewsByParentKey.set(view.layoutInfo.parentKey, []);
if (view.layoutInfo?.parentKey != null && !this._viewsByParentKey.has(view.layoutInfo.parentKey)) {
this._viewsByParentKey.set(view.layoutInfo.parentKey, []);
}

viewsByParentKey.get(view.layoutInfo?.parentKey)?.push(view);
if (!viewsByParentKey.has(view.layoutInfo?.key)) {
viewsByParentKey.set(view.layoutInfo?.key, []);
this._viewsByParentKey.get(view.layoutInfo?.parentKey)?.push(view);
if (!this._viewsByParentKey.has(view.layoutInfo?.key)) {
this._viewsByParentKey.set(view.layoutInfo?.key, []);
}
}

let children = this.getChildren(null);
this.delegate.setVisibleViews(children);
}

getChildren(key: Key) {
let buildTree = (parent: ReusableView<T, V>, views: ReusableView<T, V>[]): W[] => views.map(view => {
let children = viewsByParentKey.get(view.layoutInfo.key);
let children = this._viewsByParentKey.get(view.layoutInfo.key);
return this.delegate.renderWrapper(
parent,
view,
Expand All @@ -859,8 +865,8 @@ export class Virtualizer<T extends object, V, W> {
);
});

let children = buildTree(null, viewsByParentKey.get(null));
this.delegate.setVisibleViews(children);
let parent = this._visibleViews.get(key)!;
return buildTree(parent, this._viewsByParentKey.get(key));
}

private _applyLayoutInfo(view: ReusableView<T, V>, layoutInfo: LayoutInfo) {
Expand Down
Loading