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 7 commits into
base: main
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
4 changes: 3 additions & 1 deletion packages/@react-aria/combobox/src/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
collection,
disabledKeys,
ref: listBoxRef,
layoutDelegate
layoutDelegate,
orientation: 'vertical'
})
), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]);

Expand Down Expand Up @@ -380,6 +381,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
shouldUseVirtualFocus: true,
shouldSelectOnPressUp: true,
shouldFocusOnHover: true,
orientation: 'vertical' as const,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should explicitly set orientation whenever known to avoid queries to the DOMLayoutDelegate internally.

linkBehavior: 'selection' as const
}),
descriptionProps,
Expand Down
10 changes: 9 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 @@ -50,6 +50,10 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
this.focusMode = options.focusMode ?? 'row';
}

getOrientation(): Orientation | null {
return this.layoutDelegate.getOrientation?.() || 'vertical';
}

protected isCell(node: Node<T>): boolean {
return node.type === 'cell';
}
Expand Down Expand Up @@ -470,6 +474,10 @@ class DeprecatedLayoutDelegate implements LayoutDelegate {
this.layout = layout;
}

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

getContentSize(): Size {
return this.layout.getContentSize();
}
Expand Down
11 changes: 9 additions & 2 deletions packages/@react-aria/listbox/src/useListBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {AriaListBoxProps} from '@react-types/listbox';
import {DOMAttributes, KeyboardDelegate, LayoutDelegate, RefObject} from '@react-types/shared';
import {DOMAttributes, KeyboardDelegate, LayoutDelegate, Orientation, RefObject} from '@react-types/shared';
import {filterDOMProps, mergeProps, useId} from '@react-aria/utils';
import {listData} from './utils';
import {ListState} from '@react-stately/list';
Expand Down Expand Up @@ -55,7 +55,12 @@ export interface AriaListBoxOptions<T> extends Omit<AriaListBoxProps<T>, 'childr
* - 'override': links override all other interactions (link items are not selectable).
* @default 'override'
*/
linkBehavior?: 'action' | 'selection' | 'override'
linkBehavior?: 'action' | 'selection' | 'override',

/**
* The orientation of the listbox.
*/
orientation?: Orientation
}

/**
Expand All @@ -68,6 +73,7 @@ export function useListBox<T>(props: AriaListBoxOptions<T>, state: ListState<T>,
let domProps = filterDOMProps(props, {labelable: true});
// Use props instead of state here. We don't want this to change due to long press.
let selectionBehavior = props.selectionBehavior || 'toggle';
let orientation = props.orientation || props.keyboardDelegate?.getOrientation?.();
let linkBehavior = props.linkBehavior || (selectionBehavior === 'replace' ? 'action' : 'override');
if (selectionBehavior === 'toggle' && linkBehavior === 'action') {
// linkBehavior="action" does not work with selectionBehavior="toggle" because there is no way
Expand Down Expand Up @@ -117,6 +123,7 @@ export function useListBox<T>(props: AriaListBoxOptions<T>, state: ListState<T>,
'aria-multiselectable': 'true'
} : {}, {
role: 'listbox',
'aria-orientation': orientation === 'horizontal' ? orientation : undefined,
Copy link
Contributor Author

@nwidynski nwidynski Jul 14, 2025

Choose a reason for hiding this comment

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

@majornista Should we set aria-orientation only if deviating from the implicit orientation as defined in each role spec? I adjusted useTabList as well to match, let me know what you think 🙏

...mergeProps(fieldProps, listProps)
})
};
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/menu/src/useMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function useMenu<T>(props: AriaMenuOptions<T>, state: TreeState<T>, ref:
collection: state.collection,
disabledKeys: state.disabledKeys,
shouldFocusWrap,
orientation: 'vertical',
linkBehavior: 'override'
});

Expand Down
30 changes: 28 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,39 @@
*/

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?

this.ref = ref;
this.orientation = orientation;
}

getOrientation(): Orientation | null {
let container = this.ref.current;
if (this.orientation) {
return this.orientation;
}

// https://w3c.github.io/aria/#aria-orientation
switch (container?.role) {
case 'menubar':
case 'slider':
case 'separator':
case 'tablist':
case 'toolbar':
return 'horizontal';
case 'listbox':
case 'menu':
case 'scrollbar':
case 'tree':
return 'vertical';
default:
return null;
}
}

getItemRect(key: Key): Rect | null {
Expand Down
46 changes: 30 additions & 16 deletions packages/@react-aria/selection/src/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,27 +47,41 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.collator = opts.collator;
this.disabledKeys = opts.disabledKeys || new Set();
this.disabledBehavior = opts.disabledBehavior || 'all';
this.orientation = opts.orientation || 'vertical';
this.orientation = opts.orientation;
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];
this.ref = args[2];
this.collator = args[3];
this.layout = 'stack';
this.orientation = 'vertical';
this.disabledBehavior = 'all';
this.layoutDelegate = new DOMLayoutDelegate(this.ref);
}

// If this is a vertical stack, remove the left/right methods completely
// so they aren't called by useDroppableCollection.
if (this.layout === 'stack' && this.orientation === 'vertical') {
this.getKeyLeftOf = undefined;
this.getKeyRightOf = undefined;
}
// so they aren't called by useDroppableCollection or useAutocomplete.
let getKeyRightOf = this.getKeyRightOf;
let getKeyLeftOf = this.getKeyLeftOf;

Object.defineProperty(this, 'getKeyRightOf', {
get() { return this.layout === 'stack' && this.getOrientation() === 'vertical' ? undefined : getKeyRightOf; },
configurable: true,
enumerable: false
});

Object.defineProperty(this, 'getKeyLeftOf', {
get() { return this.layout === 'stack' && this.getOrientation() === 'vertical' ? undefined : getKeyLeftOf; },
configurable: true,
enumerable: false
});
}

getOrientation(): Orientation {
// TODO: Should we log a warning if keyboard and layout delegate mismatch in orientation?
return this.orientation || this.layoutDelegate.getOrientation?.() || 'vertical';
}

private isDisabled(item: Node<unknown>) {
Expand Down Expand Up @@ -133,15 +147,15 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

getKeyBelow(key: Key): Key | null {
if (this.layout === 'grid' && this.orientation === 'vertical') {
if (this.layout === 'grid' && this.getOrientation() === 'vertical') {
return this.findKey(key, (key) => this.getNextKey(key), this.isSameRow);
} else {
return this.getNextKey(key);
}
}

getKeyAbove(key: Key): Key | null {
if (this.layout === 'grid' && this.orientation === 'vertical') {
if (this.layout === 'grid' && this.getOrientation() === 'vertical') {
return this.findKey(key, (key) => this.getPreviousKey(key), this.isSameRow);
} else {
return this.getPreviousKey(key);
Expand All @@ -162,12 +176,12 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

if (this.layout === 'grid') {
if (this.orientation === 'vertical') {
if (this.getOrientation() === 'vertical') {
return this.getNextColumn(key, this.direction === 'rtl');
} else {
return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'rtl'), this.isSameColumn);
}
} else if (this.orientation === 'horizontal') {
} else if (this.getOrientation() === 'horizontal') {
return this.getNextColumn(key, this.direction === 'rtl');
}

Expand All @@ -182,12 +196,12 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

if (this.layout === 'grid') {
if (this.orientation === 'vertical') {
if (this.getOrientation() === 'vertical') {
return this.getNextColumn(key, this.direction === 'ltr');
} else {
return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'ltr'), this.isSameColumn);
}
} else if (this.orientation === 'horizontal') {
} else if (this.getOrientation() === 'horizontal') {
return this.getNextColumn(key, this.direction === 'ltr');
}

Expand Down Expand Up @@ -216,7 +230,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

let nextKey: Key | null = key;
if (this.orientation === 'horizontal') {
if (this.getOrientation() === 'horizontal') {
let pageX = Math.max(0, itemRect.x + itemRect.width - this.layoutDelegate.getVisibleRect().width);

while (itemRect && itemRect.x > pageX && nextKey != null) {
Expand Down Expand Up @@ -247,7 +261,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
}

let nextKey: Key | null = key;
if (this.orientation === 'horizontal') {
if (this.getOrientation() === 'horizontal') {
let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width);

while (itemRect && itemRect.x < pageX && nextKey != null) {
Expand Down
17 changes: 12 additions & 5 deletions packages/@react-aria/selection/src/useSelectableList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {AriaSelectableCollectionOptions, useSelectableCollection} from './useSelectableCollection';
import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared';
import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node, Orientation} from '@react-types/shared';
import {ListKeyboardDelegate} from './ListKeyboardDelegate';
import {useCollator} from '@react-aria/i18n';
import {useMemo} from 'react';
Expand All @@ -34,7 +34,12 @@ export interface AriaSelectableListOptions extends Omit<AriaSelectableCollection
/**
* The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with.
*/
disabledKeys: Set<Key>
disabledKeys: Set<Key>,
/**
* The primary orientation of the items. Usually this is the
* direction that the collection scrolls.
*/
orientation?: Orientation
}

export interface SelectableListAria {
Expand All @@ -54,7 +59,8 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL
disabledKeys,
ref,
keyboardDelegate,
layoutDelegate
layoutDelegate,
orientation
} = props;

// By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down).
Expand All @@ -68,9 +74,10 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL
disabledBehavior,
ref,
collator,
layoutDelegate
layoutDelegate,
orientation
})
), [keyboardDelegate, layoutDelegate, collection, disabledKeys, ref, collator, disabledBehavior]);
), [keyboardDelegate, layoutDelegate, collection, disabledKeys, ref, collator, disabledBehavior, orientation]);

let {collectionProps} = useSelectableCollection({
...props,
Expand Down
12 changes: 8 additions & 4 deletions packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ export class TabsKeyboardDelegate<T> implements KeyboardDelegate {
private collection: Collection<Node<T>>;
private flipDirection: boolean;
private disabledKeys: Set<Key>;
private tabDirection: boolean;
private orientation: Orientation;

constructor(collection: Collection<Node<T>>, direction: Direction, orientation: Orientation, disabledKeys: Set<Key> = new Set()) {
this.collection = collection;
this.flipDirection = direction === 'rtl' && orientation === 'horizontal';
this.disabledKeys = disabledKeys;
this.tabDirection = orientation === 'horizontal';
this.orientation = orientation;
}

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

getKeyLeftOf(key: Key): Key | null {
Expand Down Expand Up @@ -61,14 +65,14 @@ export class TabsKeyboardDelegate<T> implements KeyboardDelegate {
}

getKeyAbove(key: Key): Key | null {
if (this.tabDirection) {
if (this.getOrientation() === 'horizontal') {
return null;
}
return this.getPreviousKey(key);
}

getKeyBelow(key: Key): Key | null {
if (this.tabDirection) {
if (this.getOrientation() === 'horizontal') {
return null;
}
return this.getNextKey(key);
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/tabs/src/useTabList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function useTabList<T>(props: AriaTabListOptions<T>, state: TabListState<
tabListProps: {
...mergeProps(collectionProps, tabListLabelProps),
role: 'tablist',
'aria-orientation': orientation,
'aria-orientation': orientation === 'vertical' ? orientation : undefined,
tabIndex: undefined
}
};
Expand Down
Loading