Skip to content

Feat: Add support for horizontal orientation to GridList & ListBox #8533

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 10 commits into
base: main
Choose a base branch
from
Open
6 changes: 5 additions & 1 deletion packages/@react-aria/grid/src/GridKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Rect, RefObject, Size} from '@react-types/shared';
import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Orientation, Rect, RefObject, Size} from '@react-types/shared';
import {DOMLayoutDelegate} from '@react-aria/selection';
import {getChildNodes, getFirstItem, getLastItem, getNthItem} from '@react-stately/collections';
import {GridCollection, GridNode} from '@react-types/grid';
Expand Down Expand Up @@ -470,6 +470,10 @@ class DeprecatedLayoutDelegate implements LayoutDelegate {
this.layout = layout;
}

getOrientation(): Orientation {
return 'vertical';
}

getContentSize(): Size {
return this.layout.getContentSize();
}
Expand Down
10 changes: 8 additions & 2 deletions packages/@react-aria/selection/src/DOMLayoutDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
*/

import {getItemElement} from './utils';
import {Key, LayoutDelegate, Rect, RefObject, Size} from '@react-types/shared';
import {Key, LayoutDelegate, Orientation, Rect, RefObject, Size} from '@react-types/shared';

export class DOMLayoutDelegate implements LayoutDelegate {
private ref: RefObject<HTMLElement | null>;
private orientation: Orientation;

constructor(ref: RefObject<HTMLElement | null>) {
constructor(ref: RefObject<HTMLElement | null>, orientation?: Orientation) {
Copy link
Contributor Author

@nwidynski nwidynski Jul 12, 2025

Choose a reason for hiding this comment

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

Not entirely happy with having to pass this here, but I couldn't think of any reliable alternative. I guess we could do something similar to the drop target delegate and place this information in a data attribute, but is this really preferable?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I'd rather not rely on a data attribute for this, this is fine for now IMO. Having the aria roles and their orientations here in the delegate is interesting, wonder if it would make sense to instead only rely on the higher level delegate (and by extension the hook that creates the keyboard delegate) to pass the appropriate orientation. I guess this makes for a good fallback as is though

Copy link
Member

@LFDanLu LFDanLu Jul 24, 2025

Choose a reason for hiding this comment

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

Chatted with the team some more about this, and we wondered if we really need have the orientation information here in the DOMLayoutDelegate since this delegate is mainly meant to inform about stuff like itemRects and sizes. What was the need for this information to be placed here since the keyboard delegates already were passed orientation via their options?

Copy link
Contributor Author

@nwidynski nwidynski Jul 24, 2025

Choose a reason for hiding this comment

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

Are you asking about why the LayoutDelegate interface in general requires an orientation or rather about DOMLayoutDelegate specifically? If the later, then as you speculated it's just meant to provide reasonable fallback in case the keyboard delegate is not passed an orientation - can remove if you prefer.

In case you are wondering why we expose a getOrientation() method instead of keeping this option protected in the layout, its because we can utilize this method to provide a fallback in the keyboard delegates and therefore make the orientation prop on the hook optional. This way a user doesn't have to set orientation=horizontal both in the layout options and on the component.

Copy link
Member

Choose a reason for hiding this comment

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

mainly was concerned about the DOMLayoutDelegate, but was wondering if a fallback was really needed. I was imagining that the orientation would be passed from the component level if customizable or have its default set by the respective hook and passed to the delegate, though I do see your point about then needing the user to provide it to the Virtualizer's layout as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I felt like it would be fairly easy for a user to miss the orientation on the component and just set it in the layout since it will already look right, just wont be working correctly for keyboard users. Having it that way ensures we are in sync, especially with the discussed warning message.

this.ref = ref;
this.orientation = orientation ?? 'vertical';
}

getOrientation(): Orientation {
return this.orientation;
}

getItemRect(key: Key): Rect | null {
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 @@ -50,7 +50,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.orientation = opts.orientation || 'vertical';
this.direction = opts.direction;
this.layout = opts.layout || 'stack';
this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref);
this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref, this.orientation);
} else {
this.collection = args[0];
this.disabledKeys = args[1];
Expand All @@ -59,7 +59,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.layout = 'stack';
this.orientation = 'vertical';
this.disabledBehavior = 'all';
this.layoutDelegate = new DOMLayoutDelegate(this.ref);
this.layoutDelegate = new DOMLayoutDelegate(this.ref, this.orientation);
}

// If this is a vertical stack, remove the left/right methods completely
Expand Down
179 changes: 90 additions & 89 deletions packages/@react-stately/layout/src/ListLayout.ts

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion packages/@react-stately/virtualizer/src/Layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
*/

import {InvalidationContext} from './types';
import {ItemDropTarget, Key, LayoutDelegate, Node} from '@react-types/shared';
import {ItemDropTarget, Key, LayoutDelegate, Node, Orientation} from '@react-types/shared';
import {LayoutInfo} from './LayoutInfo';
import {Rect} from './Rect';
import {Size} from './Size';
import {Virtualizer} from './Virtualizer';

export interface LayoutOptions {
orientation?: Orientation
}

/**
* Virtualizer supports arbitrary layout objects, which compute what items are visible, and how
* to position and style them. However, layouts do not render items directly. Instead,
Expand All @@ -28,6 +32,8 @@ import {Virtualizer} from './Virtualizer';
* `getLayoutInfo`, and `getContentSize` methods. All other methods can be optionally overridden to implement custom behavior.
*/
export abstract class Layout<T extends object = Node<any>, O = any> implements LayoutDelegate {
protected orientation: Orientation;

/** The Virtualizer the layout is currently attached to. */
virtualizer: Virtualizer<T, any> | null = null;

Expand All @@ -50,6 +56,17 @@ export abstract class Layout<T extends object = Node<any>, O = any> implements L
*/
abstract getContentSize(): Size;

constructor(options: LayoutOptions = {}) {
this.orientation = options.orientation ?? 'vertical';
}

/**
* Returns the orientation of the layout.
*/
getOrientation(): Orientation {
return this.orientation;
}

/**
* Returns whether the layout should invalidate in response to
* visible rectangle changes. By default, it only invalidates
Expand Down
15 changes: 9 additions & 6 deletions packages/@react-stately/virtualizer/src/OverscanManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import {Orientation} from '@react-types/shared';
import {Point} from './Point';
import {Rect} from './Rect';

Expand All @@ -34,16 +35,18 @@ export class OverscanManager {
this.visibleRect = rect;
}

getOverscannedRect(): Rect {
getOverscannedRect(orientation: Orientation): Rect {
let overscanned = this.visibleRect.copy();

let overscanY = this.visibleRect.height / 3;
overscanned.height += overscanY;
if (this.velocity.y < 0) {
overscanned.y -= overscanY;
if (orientation === 'vertical' || this.velocity.y !== 0) {
let overscanY = this.visibleRect.height / 3;
overscanned.height += overscanY;
if (this.velocity.y < 0) {
overscanned.y -= overscanY;
}
}

if (this.velocity.x !== 0) {
if (orientation === 'horizontal' || this.velocity.x !== 0) {
let overscanX = this.visibleRect.width / 3;
overscanned.width += overscanX;
if (this.velocity.x < 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/virtualizer/src/Virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export class Virtualizer<T extends object, V> {
if (isTestEnv && !(isClientWidthMocked && isClientHeightMocked)) {
rect = new Rect(0, 0, this.contentSize.width, this.contentSize.height);
} else {
rect = this._overscanManager.getOverscannedRect();
rect = this._overscanManager.getOverscannedRect(this.layout.getOrientation());
}
let layoutInfos = this.layout.getVisibleLayoutInfos(rect);
let map = new Map;
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-types/shared/src/collections.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {Key} from '@react-types/shared';
import {Key, Orientation} from '@react-types/shared';
import {LinkDOMProps} from './dom';
import {ReactElement, ReactNode} from 'react';

Expand Down Expand Up @@ -137,6 +137,8 @@ export interface Size {

/** A LayoutDelegate provides layout information for collection items. */
export interface LayoutDelegate {
/** Returns the orientation of the layout. */
getOrientation(): Orientation,
/** Returns a rectangle for the item with the given key. */
getItemRect(key: Key): Rect | null,
/** Returns the visible rectangle of the collection. */
Expand Down
13 changes: 10 additions & 3 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPers
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, Orientation, PressEvents, RefObject} from '@react-types/shared';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';
Expand Down Expand Up @@ -75,7 +75,13 @@ export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>
* Whether the items are arranged in a stack or grid.
* @default 'stack'
*/
layout?: 'stack' | 'grid'
layout?: 'stack' | 'grid',
/**
* The primary orientation of the items. Usually this is the
* direction that the collection scrolls.
* @default 'vertical'
*/
orientation?: Orientation
}


Expand Down Expand Up @@ -103,7 +109,7 @@ interface GridListInnerProps<T extends object> {
}

function GridListInner<T extends object>({props, collection, gridListRef: ref}: GridListInnerProps<T>) {
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack', orientation = 'vertical'} = props;
let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext);
let state = useListState({
...props,
Expand Down Expand Up @@ -183,6 +189,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:

let keyboardDelegate = new ListKeyboardDelegate({
collection,
orientation,
disabledKeys: selectionManager.disabledKeys,
disabledBehavior: selectionManager.disabledBehavior,
ref
Expand Down
34 changes: 29 additions & 5 deletions packages/react-aria-components/stories/ListBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import {action} from '@storybook/addon-actions';
import {Collection, DropIndicator, GridLayout, Header, ListBox, ListBoxItem, ListBoxProps, ListBoxSection, ListLayout, Separator, Text, useDragAndDrop, Virtualizer, WaterfallLayout} from 'react-aria-components';
import {ListBoxLoadMoreItem} from '../src/ListBox';
import {LoadingSpinner, MyListBoxItem} from './utils';
import {LoadingSpinner, MyHeader, MyListBoxItem} from './utils';
import React from 'react';
import {Size} from '@react-stately/virtualizer';
import styles from '../example/index.css';
Expand Down Expand Up @@ -394,6 +394,8 @@ function generateRandomString(minLength: number, maxLength: number): string {
}

export function VirtualizedListBox(args) {
let heightProperty = args.orientation === 'horizontal' ? 'width' : 'height';
let widthProperty = args.orientation === 'horizontal' ? 'height' : 'width';
let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = [];
for (let s = 0; s < 10; s++) {
let items: {id: string, name: string}[] = [];
Expand All @@ -407,15 +409,16 @@ export function VirtualizedListBox(args) {
return (
<Virtualizer
layout={new ListLayout({
orientation: args.orientation,
estimatedRowHeight: 25,
estimatedHeadingHeight: 26,
loaderHeight: 30
})}>
<ListBox className={styles.menu} style={{height: 400}} aria-label="virtualized listbox">
<ListBox orientation={args.orientation} className={styles.menu} style={{[heightProperty]: 400, [widthProperty]: 200}} aria-label="virtualized listbox">
<Collection items={sections}>
{section => (
<ListBoxSection className={styles.group}>
<Header style={{fontSize: '1.2em'}}>{section.name}</Header>
<MyHeader style={{fontSize: '1.2em'}}>{section.name}</MyHeader>
<Collection items={section.children}>
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
</Collection>
Expand All @@ -430,8 +433,15 @@ export function VirtualizedListBox(args) {

VirtualizedListBox.story = {
args: {
orientation: 'vertical',
variableHeight: false,
isLoading: false
},
argTypes: {
orientation: {
control: 'radio',
options: ['vertical', 'horizontal']
}
}
};

Expand All @@ -450,7 +460,7 @@ export function VirtualizedListBoxEmpty() {
);
}

export function VirtualizedListBoxDnd() {
export function VirtualizedListBoxDnd(args) {
let items: {id: number, name: string}[] = [];
for (let i = 0; i < 10000; i++) {
items.push({id: i, name: `Item ${i}`});
Expand Down Expand Up @@ -481,13 +491,15 @@ export function VirtualizedListBoxDnd() {
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 25,
orientation: args.orientation,
rowHeight: args.orientation === 'horizontal' ? 45 : 25,
gap: 8
}}>
<ListBox
className={styles.menu}
selectionMode="multiple"
selectionBehavior="replace"
orientation={args.orientation}
style={{width: '100%', height: '100%'}}
aria-label="virtualized listbox"
items={list.items}
Expand All @@ -499,6 +511,18 @@ export function VirtualizedListBoxDnd() {
);
}

VirtualizedListBoxDnd.story = {
args: {
orientation: 'vertical'
},
argTypes: {
orientation: {
control: 'radio',
options: ['vertical', 'horizontal']
}
}
};

function VirtualizedListBoxGridExample({minSize = 80, maxSize = 100, preserveAspectRatio = false}) {
let items: {id: number, name: string}[] = [];
for (let i = 0; i < 10000; i++) {
Expand Down
10 changes: 7 additions & 3 deletions packages/react-aria-components/stories/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import {classNames} from '@react-spectrum/utils';
import {ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps, ProgressBar} from 'react-aria-components';
import React from 'react';
import {Header, ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps, ProgressBar} from 'react-aria-components';
import React, {HTMLAttributes} from 'react';
import styles from '../example/index.css';

export const MyHeader = (props: HTMLAttributes<HTMLElement>) => {
return <Header {...props} style={{width: 'max-content', ...props.style}} />;
};

export const MyListBoxItem = (props: ListBoxItemProps) => {
return (
<ListBoxItem
{...props}
style={{wordBreak: 'break-word', ...props.style}}
style={{wordBreak: 'break-word', width: 'max-content', ...props.style}}
className={({isFocused, isSelected, isHovered, isFocusVisible}) => classNames(styles, 'item', {
focused: isFocused,
selected: isSelected,
Expand Down