Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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,
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) {
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