From b07954adb712b0132ce34d216054693e86c2e82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sat, 12 Jul 2025 00:57:17 +0200 Subject: [PATCH 1/5] feat: add orientation to layout --- .../grid/src/GridKeyboardDelegate.ts | 6 +- .../selection/src/DOMLayoutDelegate.ts | 10 +- .../selection/src/ListKeyboardDelegate.ts | 4 +- .../@react-stately/layout/src/ListLayout.ts | 179 +++++++++--------- .../@react-stately/virtualizer/src/Layout.ts | 19 +- .../virtualizer/src/OverscanManager.ts | 15 +- .../virtualizer/src/Virtualizer.ts | 2 +- .../@react-types/shared/src/collections.d.ts | 4 +- .../react-aria-components/src/GridList.tsx | 10 +- .../stories/ListBox.stories.tsx | 34 +++- .../react-aria-components/stories/utils.tsx | 10 +- 11 files changed, 180 insertions(+), 113 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index b1fceaf3cb3..d0c889aba0a 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -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'; @@ -470,6 +470,10 @@ class DeprecatedLayoutDelegate implements LayoutDelegate { this.layout = layout; } + getOrientation(): Orientation { + return 'vertical'; + } + getContentSize(): Size { return this.layout.getContentSize(); } diff --git a/packages/@react-aria/selection/src/DOMLayoutDelegate.ts b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts index 8b427d3fa01..18f4845536e 100644 --- a/packages/@react-aria/selection/src/DOMLayoutDelegate.ts +++ b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts @@ -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; + private orientation: Orientation; - constructor(ref: RefObject) { + constructor(ref: RefObject, orientation?: Orientation) { this.ref = ref; + this.orientation = orientation ?? 'vertical'; + } + + getOrientation(): Orientation { + return this.orientation; } getItemRect(key: Key): Rect | null { diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index f76d740f5af..999bbe6b5e6 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -50,7 +50,7 @@ export class ListKeyboardDelegate 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]; @@ -59,7 +59,7 @@ export class ListKeyboardDelegate 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 diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6dc9259388c..065b3e8df98 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -10,11 +10,16 @@ * governing permissions and limitations under the License. */ -import {Collection, DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared'; +import {Collection, DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node, Orientation} from '@react-types/shared'; import {getChildNodes} from '@react-stately/collections'; import {InvalidationContext, Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; export interface ListLayoutOptions { + /** + * The orientation of the list. + * @default 'vertical' + */ + orientation?: Orientation, /** * The fixed height of a row in px. * @default 48 @@ -92,7 +97,7 @@ export class ListLayout exte * of the options that can be provided. */ constructor(options: ListLayoutOptions = {}) { - super(); + super(options); this.rowHeight = options.rowHeight ?? null; this.estimatedRowHeight = options.estimatedRowHeight ?? null; this.headingHeight = options.headingHeight ?? null; @@ -121,23 +126,27 @@ export class ListLayout exte } getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let visibleRect = rect.copy(); + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + // Adjust rect to keep number of visible rows consistent. - // (only if height > 1 for getDropTargetFromPoint) - if (rect.height > 1) { + // (only if height > 1 or width > 1 for getDropTargetFromPoint) + if (visibleRect[heightProperty] > 1) { let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; - rect.y = Math.floor(rect.y / rowHeight) * rowHeight; - rect.height = Math.ceil(rect.height / rowHeight) * rowHeight; + visibleRect[offsetProperty] = Math.floor(visibleRect[offsetProperty] / rowHeight) * rowHeight; + visibleRect[heightProperty] = Math.ceil(visibleRect[heightProperty] / rowHeight) * rowHeight; } // If layout hasn't yet been done for the requested rect, union the // new rect with the existing valid rect, and recompute. - this.layoutIfNeeded(rect); + this.layoutIfNeeded(visibleRect); let res: LayoutInfo[] = []; let addNodes = (nodes: LayoutNode[]) => { for (let node of nodes) { - if (this.isVisible(node, rect)) { + if (this.isVisible(node, visibleRect)) { res.push(node.layoutInfo); if (node.children) { @@ -194,6 +203,7 @@ export class ListLayout exte let options = invalidationContext.layoutOptions; return invalidationContext.sizeChanged || this.rowHeight !== (options?.rowHeight ?? this.rowHeight) + || this.orientation !== (options?.orientation ?? this.orientation) || this.headingHeight !== (options?.headingHeight ?? this.headingHeight) || this.loaderHeight !== (options?.loaderHeight ?? this.loaderHeight) || this.gap !== (options?.gap ?? this.gap) @@ -202,6 +212,7 @@ export class ListLayout exte shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean { return newOptions.rowHeight !== oldOptions.rowHeight + || newOptions.orientation !== oldOptions.orientation || newOptions.estimatedRowHeight !== oldOptions.estimatedRowHeight || newOptions.headingHeight !== oldOptions.headingHeight || newOptions.estimatedHeadingHeight !== oldOptions.estimatedHeadingHeight @@ -224,6 +235,7 @@ export class ListLayout exte let options = invalidationContext.layoutOptions; this.rowHeight = options?.rowHeight ?? this.rowHeight; + this.orientation = options?.orientation ?? this.orientation; this.estimatedRowHeight = options?.estimatedRowHeight ?? this.estimatedRowHeight; this.headingHeight = options?.headingHeight ?? this.headingHeight; this.estimatedHeadingHeight = options?.estimatedHeadingHeight ?? this.estimatedHeadingHeight; @@ -251,26 +263,28 @@ export class ListLayout exte this.validRect = this.requestedRect.copy(); } - protected buildCollection(y: number = this.padding): LayoutNode[] { + protected buildCollection(offset: number = this.padding): LayoutNode[] { let collection = this.virtualizer!.collection; let collectionNodes = [...collection]; let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); let nodes: LayoutNode[] = []; let isEmptyOrLoading = collection?.size === 0; if (isEmptyOrLoading) { - y = 0; + offset = 0; } - for (let node of collectionNodes) { + for (let node of collection) { + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; // Skip rows before the valid rectangle unless they are already cached. - if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { - y += rowHeight; + if (node.type === 'item' && offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { + offset += rowHeight; continue; } - let layoutNode = this.buildChild(node, this.padding, y, null); - y = layoutNode.layoutInfo.rect.maxY + this.gap; + let layoutNode = this.orientation === 'horizontal' ? this.buildChild(node, offset, this.padding, null) : this.buildChild(node, this.padding, offset, null); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; nodes.push(layoutNode); if (node.type === 'loader') { let index = loaderNodes.indexOf(node); @@ -280,44 +294,45 @@ export class ListLayout exte // Build each loader that exists in the collection that is outside the visible rect so that they are persisted // at the proper estimated location. If the node.type is "section" then we don't do this shortcut since we have to // build the sections to see how tall they are. - if ((node.type === 'item' || node.type === 'loader') && y > this.requestedRect.maxY) { + if ((node.type === 'item' || node.type === 'loader') && offset > this.requestedRect[maxOffsetProperty]) { let lastProcessedIndex = collectionNodes.indexOf(node); for (let loaderNode of loaderNodes) { let loaderNodeIndex = collectionNodes.indexOf(loaderNode); // Subtract by an additional 1 since we've already added the current item's height to y - y += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight; - let loader = this.buildChild(loaderNode, this.padding, y, null); + offset += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight; + let loader = this.orientation === 'horizontal' ? this.buildChild(loaderNode, offset, this.padding, null) : this.buildChild(loaderNode, this.padding, offset, null); nodes.push(loader); - y = loader.layoutInfo.rect.maxY; + offset = loader.layoutInfo.rect[maxOffsetProperty]; lastProcessedIndex = loaderNodeIndex; } // Account for the rest of the items after the last loader spinner, subtract by 1 since we've processed the current node's height already - y += (collectionNodes.length - lastProcessedIndex - 1) * rowHeight; + offset += (collectionNodes.length - lastProcessedIndex - 1) * rowHeight; break; } } - y -= this.gap; - y += isEmptyOrLoading ? 0 : this.padding; - this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); + offset = Math.max(offset - this.gap, 0); + offset += isEmptyOrLoading ? 0 : this.padding; + this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.visibleRect.height) : new Size(this.virtualizer!.visibleRect.width, offset); return nodes; } - protected isValid(node: Node, y: number): boolean { + protected isValid(node: Node, offset: number): boolean { let cached = this.layoutNodes.get(node.key); + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; return ( !this.invalidateEverything && !!cached && cached.node === node && - y === cached.layoutInfo.rect.y && + offset === cached.layoutInfo.rect[offsetProperty] && cached.layoutInfo.rect.intersects(this.validRect) && cached.validRect.containsRect(cached.layoutInfo.rect.intersection(this.requestedRect)) ); } protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { - if (this.isValid(node, y)) { + if (this.isValid(node, this.orientation === 'horizontal' ? x : y)) { return this.layoutNodes.get(node.key)!; } @@ -348,11 +363,17 @@ export class ListLayout exte protected buildLoader(node: Node, x: number, y: number): LayoutNode { let rect = new Rect(x, y, this.padding, 0); - let layoutInfo = new LayoutInfo('loader', node.key, rect); - rect.width = this.virtualizer!.contentSize.width - this.padding - x; + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve // room for the loader alongside rendering the emptyState - rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + if (this.orientation === 'horizontal') { + rect.height = this.virtualizer!.contentSize.height - this.padding - y; + rect.width = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + } else { + rect.width = this.virtualizer!.contentSize.width - this.padding - x; + rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + } return { layoutInfo, @@ -362,36 +383,41 @@ export class ListLayout exte protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; - let width = this.virtualizer!.visibleRect.width - this.padding; - let rect = new Rect(x, y, width - x, 0); + let width = this.virtualizer!.visibleRect.width - this.padding - x; + let height = this.virtualizer!.visibleRect.height - this.padding - y; + let rect = this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); let layoutInfo = new LayoutInfo(node.type, node.key, rect); - let startY = y; + let offset = this.orientation === 'horizontal' ? x : y; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + let skipped = 0; let children: LayoutNode[] = []; for (let child of getChildNodes(node, collection)) { let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; // Skip rows before the valid rectangle unless they are already cached. - if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { - y += rowHeight; + if (offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { + offset += rowHeight; skipped++; continue; } - let layoutNode = this.buildChild(child, x, y, layoutInfo.key); - y = layoutNode.layoutInfo.rect.maxY + this.gap; + let layoutNode = this.orientation === 'horizontal' ? this.buildChild(child, offset, y, layoutInfo.key) : this.buildChild(child, x, offset, layoutInfo.key); + offset = layoutNode.layoutInfo.rect[maxOffsetProperty] + this.gap; children.push(layoutNode); - if (y > this.requestedRect.maxY) { + if (offset > this.requestedRect[maxOffsetProperty]) { // Estimate the remaining height for rows that we don't need to layout right now. - y += ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; + offset += ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; break; } } - y -= this.gap; - rect.height = y - startY; + offset -= this.gap; + rect[heightProperty] = offset - (this.orientation === 'horizontal' ? x : y); return { layoutInfo, @@ -402,45 +428,14 @@ export class ListLayout exte } protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { - let width = this.virtualizer!.visibleRect.width - this.padding; - 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 virtualizer changed, - // or the content of the item changed. - let previousLayoutNode = this.layoutNodes.get(node.key); - let previousLayoutInfo = previousLayoutNode?.layoutInfo; - if (previousLayoutInfo) { - let curNode = this.virtualizer!.collection.getItem(node.key); - let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null; - rectHeight = previousLayoutInfo.rect.height; - isEstimated = width !== previousLayoutInfo.rect.width || curNode !== lastNode || previousLayoutInfo.estimatedSize; - } else { - rectHeight = (node.rendered ? this.estimatedHeadingHeight : 0); - isEstimated = true; - } - } - - if (rectHeight == null) { - rectHeight = DEFAULT_HEIGHT; - } - - let headerRect = new Rect(x, y, width - x, rectHeight); - let header = new LayoutInfo('header', node.key, headerRect); - header.estimatedSize = isEstimated; - return { - layoutInfo: header, - children: [], - validRect: header.rect.intersection(this.requestedRect), - node - }; + return this.buildItem(node, x, y); } protected buildItem(node: Node, x: number, y: number): LayoutNode { - let width = this.virtualizer!.visibleRect.width - this.padding - x; + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; + + let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); let rectHeight = this.rowHeight; let isEstimated = false; @@ -449,12 +444,12 @@ export class ListLayout exte // If a previous version of this layout info exists, reuse its height. // Mark as estimated if the size of the overall virtualizer changed, // or the content of the item changed. - let previousLayoutNode = this.layoutNodes.get(node.key); - if (previousLayoutNode) { - rectHeight = previousLayoutNode.layoutInfo.rect.height; - isEstimated = width !== previousLayoutNode.layoutInfo.rect.width || node !== previousLayoutNode.node || previousLayoutNode.layoutInfo.estimatedSize; + let previous = this.layoutNodes.get(node.key); + if (previous) { + rectHeight = previous.layoutInfo.rect[heightProperty]; + isEstimated = width !== previous.layoutInfo.rect[widthProperty] || node !== previous.node || previous.layoutInfo.estimatedSize; } else { - rectHeight = this.estimatedRowHeight; + rectHeight = node.type === 'item' || node.rendered ? this.estimatedRowHeight : 0; isEstimated = true; } } @@ -463,13 +458,13 @@ export class ListLayout exte rectHeight = DEFAULT_HEIGHT; } - let rect = new Rect(x, y, width, rectHeight); + let rect = this.orientation === 'horizontal' ? new Rect(x, y, rectHeight, width) : new Rect(x, y, width, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; return { layoutInfo, children: [], - validRect: layoutInfo.rect, + validRect: layoutInfo.rect.intersection(this.requestedRect), node }; } @@ -483,19 +478,21 @@ export class ListLayout exte let collection = this.virtualizer!.collection; let layoutInfo = layoutNode.layoutInfo; + let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; + let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; layoutInfo.estimatedSize = false; - if (layoutInfo.rect.height !== size.height) { + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { // Copy layout info rather than mutating so that later caches are invalidated. let newLayoutInfo = layoutInfo.copy(); - newLayoutInfo.rect.height = size.height; + newLayoutInfo.rect[heightProperty] = size[heightProperty]; layoutNode.layoutInfo = newLayoutInfo; // Items after this layoutInfo will need to be repositioned to account for the new height. // Adjust the validRect so that only items above remain valid. - this.validRect.height = Math.min(this.validRect.height, layoutInfo.rect.y - this.validRect.y); + this.validRect[heightProperty] = Math.min(this.validRect[heightProperty], layoutInfo.rect[offsetProperty] - this.validRect[offsetProperty]); // The requestedRect also needs to be adjusted to account for the height difference. - this.requestedRect.height += newLayoutInfo.rect.height - layoutInfo.rect.height; + this.requestedRect[heightProperty] += newLayoutInfo.rect[heightProperty] - layoutInfo.rect[heightProperty]; // Invalidate layout for this layout node and all parents this.updateLayoutNode(key, layoutInfo, newLayoutInfo); @@ -591,7 +588,9 @@ export class ListLayout exte let layoutInfo = this.getLayoutInfo(target.key)!; let rect: Rect; if (target.dropPosition === 'before') { - rect = new Rect(layoutInfo.rect.x, layoutInfo.rect.y - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); + rect = this.orientation === 'horizontal' ? + new Rect(layoutInfo.rect.x - this.dropIndicatorThickness / 2, layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) + : new Rect(layoutInfo.rect.x, layoutInfo.rect.y - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); } else if (target.dropPosition === 'after') { // Render after last visible descendant of the drop target. let targetNode = this.collection.getItem(target.key); @@ -609,7 +608,9 @@ export class ListLayout exte currentKey = this.collection.getKeyAfter(currentKey); } } - rect = new Rect(layoutInfo.rect.x, layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); + rect = this.orientation === 'horizontal' ? + new Rect(layoutInfo.rect.maxX - this.dropIndicatorThickness / 2, layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) + : new Rect(layoutInfo.rect.x, layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); } else { rect = layoutInfo.rect; } diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index bc40f16bb99..f446ca28e32 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -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, @@ -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, O = any> implements LayoutDelegate { + protected orientation: Orientation; + /** The Virtualizer the layout is currently attached to. */ virtualizer: Virtualizer | null = null; @@ -50,6 +56,17 @@ export abstract class Layout, 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 diff --git a/packages/@react-stately/virtualizer/src/OverscanManager.ts b/packages/@react-stately/virtualizer/src/OverscanManager.ts index 9794da1592d..7329432eb2d 100644 --- a/packages/@react-stately/virtualizer/src/OverscanManager.ts +++ b/packages/@react-stately/virtualizer/src/OverscanManager.ts @@ -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'; @@ -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) { diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index f768f344e01..57fdc377c51 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -191,7 +191,7 @@ export class Virtualizer { 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; diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index dfdc0f23bff..92cb55f783d 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -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'; @@ -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. */ diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index c77794e9827..ffb67e62bc2 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -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'; @@ -75,7 +75,13 @@ export interface GridListProps extends Omit, '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 } diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 97b7e43c7a1..b4b5866db04 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -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'; @@ -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}[] = []; @@ -407,15 +409,16 @@ export function VirtualizedListBox(args) { return ( - + {section => ( -
{section.name}
+ {section.name} {item => {item.name}} @@ -430,8 +433,15 @@ export function VirtualizedListBox(args) { VirtualizedListBox.story = { args: { + orientation: 'vertical', variableHeight: false, isLoading: false + }, + argTypes: { + orientation: { + control: 'radio', + options: ['vertical', 'horizontal'] + } } }; @@ -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}`}); @@ -481,13 +491,15 @@ export function VirtualizedListBoxDnd() { ) => { + return
; +}; + export const MyListBoxItem = (props: ListBoxItemProps) => { return ( classNames(styles, 'item', { focused: isFocused, selected: isSelected, From 36ad8c4e2c16fdab5edc13c7c50d8b61a26b971d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sat, 12 Jul 2025 01:41:03 +0200 Subject: [PATCH 2/5] feat: forward orientation to gridlist keyboard delegate --- packages/react-aria-components/src/GridList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index ffb67e62bc2..f4defe2451a 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -109,7 +109,7 @@ interface GridListInnerProps { } function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { - 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, @@ -189,6 +189,7 @@ function GridListInner({props, collection, gridListRef: ref}: let keyboardDelegate = new ListKeyboardDelegate({ collection, + orientation, disabledKeys: selectionManager.disabledKeys, disabledBehavior: selectionManager.disabledBehavior, ref From e92c7dba14ba7db7587876a2921fac24342040ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sat, 12 Jul 2025 17:51:07 +0200 Subject: [PATCH 3/5] fix: remove duplicate keyboard delegate in gridlist --- packages/react-aria-components/src/GridList.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index f4defe2451a..5e667c5b768 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -130,9 +130,10 @@ function GridListInner({props, collection, gridListRef: ref}: disabledBehavior, layoutDelegate, layout, - direction + direction, + orientation }) - ), [collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]); + ), [collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction, orientation]); let {gridProps} = useGridList({ ...props, @@ -187,13 +188,6 @@ function GridListInner({props, collection, gridListRef: ref}: selectionManager }); - let keyboardDelegate = new ListKeyboardDelegate({ - collection, - orientation, - disabledKeys: selectionManager.disabledKeys, - disabledBehavior: selectionManager.disabledBehavior, - ref - }); let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction}); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, From 58ad7a280dc9c6a7db9754734ceef29a401ac169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 14 Jul 2025 22:29:44 +0200 Subject: [PATCH 4/5] Feat: Add support for `orientation` to `KeyboardDelegate` interface --- .../@react-aria/combobox/src/useComboBox.ts | 4 +- .../grid/src/GridKeyboardDelegate.ts | 4 ++ .../@react-aria/listbox/src/useListBox.ts | 11 ++++- packages/@react-aria/menu/src/useMenu.ts | 1 + .../selection/src/DOMLayoutDelegate.ts | 28 +++++++++-- .../selection/src/ListKeyboardDelegate.ts | 46 ++++++++++++------- .../selection/src/useSelectableList.ts | 17 +++++-- .../tabs/src/TabsKeyboardDelegate.ts | 12 +++-- packages/@react-aria/tabs/src/useTabList.ts | 2 +- .../@react-spectrum/tabs/test/Tabs.test.js | 31 ++++++++----- .../@react-types/shared/src/collections.d.ts | 9 ++-- packages/react-aria-components/docs/Tabs.mdx | 2 +- .../react-aria-components/src/GridList.tsx | 10 +++- .../react-aria-components/src/ListBox.tsx | 9 +++- packages/react-aria-components/src/Table.tsx | 1 + packages/react-aria-components/src/Tree.tsx | 1 + 16 files changed, 138 insertions(+), 50 deletions(-) diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 6c7deae42ba..19b382994b1 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -107,7 +107,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta collection, disabledKeys, ref: listBoxRef, - layoutDelegate + layoutDelegate, + orientation: 'vertical' }) ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]); @@ -380,6 +381,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta shouldUseVirtualFocus: true, shouldSelectOnPressUp: true, shouldFocusOnHover: true, + orientation: 'vertical' as const, linkBehavior: 'selection' as const }), descriptionProps, diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index d0c889aba0a..6357cb90bc3 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -50,6 +50,10 @@ export class GridKeyboardDelegate> implements Key this.focusMode = options.focusMode ?? 'row'; } + getOrientation(): Orientation | null { + return this.layoutDelegate.getOrientation?.() || 'vertical'; + } + protected isCell(node: Node): boolean { return node.type === 'cell'; } diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts index 8774c71bab1..9708f625928 100644 --- a/packages/@react-aria/listbox/src/useListBox.ts +++ b/packages/@react-aria/listbox/src/useListBox.ts @@ -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'; @@ -55,7 +55,12 @@ export interface AriaListBoxOptions extends Omit, '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 } /** @@ -68,6 +73,7 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, 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 @@ -117,6 +123,7 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, 'aria-multiselectable': 'true' } : {}, { role: 'listbox', + 'aria-orientation': orientation === 'horizontal' ? orientation : undefined, ...mergeProps(fieldProps, listProps) }) }; diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index efcb39784e7..4092c8b6b62 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -62,6 +62,7 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: collection: state.collection, disabledKeys: state.disabledKeys, shouldFocusWrap, + orientation: 'vertical', linkBehavior: 'override' }); diff --git a/packages/@react-aria/selection/src/DOMLayoutDelegate.ts b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts index 18f4845536e..e34fa6fab64 100644 --- a/packages/@react-aria/selection/src/DOMLayoutDelegate.ts +++ b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts @@ -15,15 +15,35 @@ import {Key, LayoutDelegate, Orientation, Rect, RefObject, Size} from '@react-ty export class DOMLayoutDelegate implements LayoutDelegate { private ref: RefObject; - private orientation: Orientation; + private orientation?: Orientation; constructor(ref: RefObject, orientation?: Orientation) { this.ref = ref; - this.orientation = orientation ?? 'vertical'; + this.orientation = orientation; } - getOrientation(): Orientation { - return this.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 { diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 999bbe6b5e6..f5f9b7a4b40 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -47,7 +47,7 @@ export class ListKeyboardDelegate 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.orientation); @@ -57,17 +57,31 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.ref = args[2]; this.collator = args[3]; this.layout = 'stack'; - this.orientation = 'vertical'; this.disabledBehavior = 'all'; - this.layoutDelegate = new DOMLayoutDelegate(this.ref, this.orientation); + 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) { @@ -133,7 +147,7 @@ export class ListKeyboardDelegate 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); @@ -141,7 +155,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } 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); @@ -162,12 +176,12 @@ export class ListKeyboardDelegate 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'); } @@ -182,12 +196,12 @@ export class ListKeyboardDelegate 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'); } @@ -216,7 +230,7 @@ export class ListKeyboardDelegate 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) { @@ -247,7 +261,7 @@ export class ListKeyboardDelegate 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) { diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index 98072b7c3ee..38ad37ce0b4 100644 --- a/packages/@react-aria/selection/src/useSelectableList.ts +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -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'; @@ -34,7 +34,12 @@ export interface AriaSelectableListOptions extends Omit + disabledKeys: Set, + /** + * The primary orientation of the items. Usually this is the + * direction that the collection scrolls. + */ + orientation?: Orientation } export interface SelectableListAria { @@ -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). @@ -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, diff --git a/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts b/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts index 5b6962efc02..d95ecd5e32e 100644 --- a/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts +++ b/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts @@ -16,13 +16,17 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { private collection: Collection>; private flipDirection: boolean; private disabledKeys: Set; - private tabDirection: boolean; + private orientation: Orientation; constructor(collection: Collection>, direction: Direction, orientation: Orientation, disabledKeys: Set = 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 { @@ -61,14 +65,14 @@ export class TabsKeyboardDelegate 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); diff --git a/packages/@react-aria/tabs/src/useTabList.ts b/packages/@react-aria/tabs/src/useTabList.ts index 04a4b52c8ab..0c6bf1917c8 100644 --- a/packages/@react-aria/tabs/src/useTabList.ts +++ b/packages/@react-aria/tabs/src/useTabList.ts @@ -68,7 +68,7 @@ export function useTabList(props: AriaTabListOptions, state: TabListState< tabListProps: { ...mergeProps(collectionProps, tabListLabelProps), role: 'tablist', - 'aria-orientation': orientation, + 'aria-orientation': orientation === 'vertical' ? orientation : undefined, tabIndex: undefined } }; diff --git a/packages/@react-spectrum/tabs/test/Tabs.test.js b/packages/@react-spectrum/tabs/test/Tabs.test.js index 802c7701e79..431aea9fd85 100644 --- a/packages/@react-spectrum/tabs/test/Tabs.test.js +++ b/packages/@react-spectrum/tabs/test/Tabs.test.js @@ -80,7 +80,7 @@ describe('Tabs', function () { let tablist = tabsTester.tablist; expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = tabsTester.tabs; expect(tabs.length).toBe(3); @@ -116,7 +116,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); let tabs = within(tablist).getAllByRole('tab'); let selectedItem = tabs[0]; - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); expect(selectedItem).toHaveAttribute('aria-selected', 'true'); act(() => {selectedItem.focus();}); @@ -199,7 +199,7 @@ describe('Tabs', function () { let firstItem = tabs[0]; act(() => {firstItem.focus();}); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); expect(firstItem).toHaveAttribute('aria-selected', 'true'); fireEvent.keyDown(firstItem, {key: 'ArrowLeft', code: 37, charCode: 37}); @@ -932,6 +932,15 @@ describe('Tabs', function () { expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); }); + it('should have aria-orientation when orientation is explicit', function () { + let container = renderComponent({orientation: 'vertical'}); + let tabsTester = testUtilUser.createTester('Tabs', {root: container.getByRole('tablist')}); + + let tablist = tabsTester.tablist; + expect(tablist).toBeTruthy(); + expect(tablist).toHaveAttribute('aria-orientation', 'vertical'); + }); + describe('when using fragments', function () { it('renders fragment with children properly', function () { let container = render( @@ -960,7 +969,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(2); @@ -1007,7 +1016,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(2); @@ -1058,7 +1067,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(3); @@ -1105,7 +1114,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(2); @@ -1152,7 +1161,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(2); @@ -1199,7 +1208,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(2); @@ -1246,7 +1255,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(3); @@ -1293,7 +1302,7 @@ describe('Tabs', function () { let tablist = container.getByRole('tablist'); expect(tablist).toBeTruthy(); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + expect(tablist).not.toHaveAttribute('aria-orientation'); let tabs = within(tablist).getAllByRole('tab'); expect(tabs.length).toBe(3); diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 92cb55f783d..6e566725ded 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -95,6 +95,9 @@ export interface SortDescriptor { export type SortDirection = 'ascending' | 'descending'; export interface KeyboardDelegate { + /** Returns the orientation of the keyboard delegate. */ + getOrientation?(): Orientation | null, + /** Returns the key visually below the given one, or `null` for none. */ getKeyBelow?(key: Key): Key | null, @@ -137,8 +140,6 @@ 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. */ @@ -146,7 +147,9 @@ export interface LayoutDelegate { /** Returns the size of the scrollable content in the collection. */ getContentSize(): Size, /** Returns a list of keys between `from` and `to`. */ - getKeyRange?(from: Key, to: Key): Key[] + getKeyRange?(from: Key, to: Key): Key[], + /** Returns the orientation of the layout. */ + getOrientation?(): Orientation | null } /** diff --git a/packages/react-aria-components/docs/Tabs.mdx b/packages/react-aria-components/docs/Tabs.mdx index f9001f0dc19..a1044069eb3 100644 --- a/packages/react-aria-components/docs/Tabs.mdx +++ b/packages/react-aria-components/docs/Tabs.mdx @@ -371,7 +371,7 @@ function Example() { ## Orientation -By default, tabs are horizontally oriented. The `orientation` prop can be set to `vertical` to change this. This does not affect keyboard navigation. You are responsible for styling your tabs accordingly. +By default, tabs are horizontally oriented. The `orientation` prop can be set to `vertical` to change this. You are responsible for styling your tabs accordingly. ```tsx example diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 5e667c5b768..b8170d501a2 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -50,6 +50,12 @@ export interface GridListRenderProps { * @selector [data-layout="stack | grid"] */ layout: 'stack' | 'grid', + /** + * The primary orientation of the items. Usually this is the + * direction that the collection scrolls. + * @selector [data-orientation="vertical | horizontal"] + */ + orientation: Orientation, /** * State of the grid list. */ @@ -201,6 +207,7 @@ function GridListInner({props, collection, gridListRef: ref}: let isEmpty = state.collection.size === 0; let renderValues = { isDropTarget: isRootDropTarget, + orientation: keyboardDelegate.getOrientation(), isEmpty, isFocused, isFocusVisible, @@ -241,7 +248,8 @@ function GridListInner({props, collection, gridListRef: ref}: data-empty={isEmpty || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} - data-layout={layout}> + data-layout={layout} + data-orientation={renderValues.orientation}> ({state: inputState, props, listBoxRef}: isFocused, isFocusVisible, layout: props.layout || 'stack', + orientation: keyboardDelegate.getOrientation?.() || 'vertical', state }; let renderProps = useRenderProps({ @@ -247,7 +254,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-layout={props.layout || 'stack'} - data-orientation={props.orientation || 'vertical'}> + data-orientation={renderValues.orientation}> ({props, collection, treeRef: ref}: TreeInne disabledKeys: state.selectionManager.disabledKeys, disabledBehavior: state.selectionManager.disabledBehavior, direction, + orientation: 'vertical', layoutDelegate }); droppableCollection = dragAndDropHooks.useDroppableCollection!( From 22ddea9e918d63927d06bf23bcbc420e45393b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 14 Jul 2025 22:57:20 +0200 Subject: [PATCH 5/5] fix: implement layout delegate interface --- packages/@react-stately/virtualizer/src/Layout.ts | 2 +- packages/@react-stately/virtualizer/src/Virtualizer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index f446ca28e32..996aa778675 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -63,7 +63,7 @@ export abstract class Layout, O = any> implements L /** * Returns the orientation of the layout. */ - getOrientation(): Orientation { + getOrientation?(): Orientation | null { return this.orientation; } diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 57fdc377c51..3780ac78424 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -191,7 +191,7 @@ export class Virtualizer { if (isTestEnv && !(isClientWidthMocked && isClientHeightMocked)) { rect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); } else { - rect = this._overscanManager.getOverscannedRect(this.layout.getOrientation()); + rect = this._overscanManager.getOverscannedRect(this.layout.getOrientation!()!); } let layoutInfos = this.layout.getVisibleLayoutInfos(rect); let map = new Map;