diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts index c5ac0984bbf..4fdbdffbceb 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,13 @@ 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 primary orientation of the items. Usually this is the direction that the collection scrolls. + * @default 'vertical' + */ + orientation?: Orientation } /** @@ -68,6 +74,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 || 'vertical'; 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 @@ -119,6 +126,7 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, 'aria-multiselectable': 'true' } : {}, { role: 'listbox', + 'aria-orientation': orientation, ...mergeProps(fieldProps, listProps) }) }; diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 21239d23dad..eb223deae50 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -248,7 +248,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { let nextKey: Key | null = key; if (this.orientation === 'horizontal') { - let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width); + let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.x - itemRect.width + this.layoutDelegate.getVisibleRect().width); while (itemRect && itemRect.x < pageX && nextKey != null) { nextKey = this.getKeyBelow(nextKey); diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index 98072b7c3ee..0e319623423 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. + * @default 'vertical' + */ + 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-spectrum/table/test/TableTests.js b/packages/@react-spectrum/table/test/TableTests.js index c2f21cc6fd5..cef92442a0c 100644 --- a/packages/@react-spectrum/table/test/TableTests.js +++ b/packages/@react-spectrum/table/test/TableTests.js @@ -1990,7 +1990,7 @@ export let tableTests = () => { let row = cell.closest('[role=row]'); let cells = within(row).getAllByRole('gridcell'); let rowHeaders = within(row).getAllByRole('rowheader'); - expect(cells).toHaveLength(9); + expect(cells).toHaveLength(10); expect(rowHeaders).toHaveLength(1); expect(cells[0]).toHaveAttribute('aria-colindex', '1'); // checkbox expect(rowHeaders[0]).toHaveAttribute('aria-colindex', '2'); // rowheader diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 7060ad08edc..7014381e9ec 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 primary orientation of the items. Usually this is the direction that the collection scrolls. + * @default 'vertical' + */ + orientation?: Orientation, /** * The fixed height of a row in px. * @default 48 @@ -70,6 +75,7 @@ const DEFAULT_HEIGHT = 48; */ export class ListLayout extends Layout, O> implements DropTargetDelegate { protected rowHeight: number | null; + protected orientation: Orientation; protected estimatedRowHeight: number | null; protected headingHeight: number | null; protected estimatedHeadingHeight: number | null; @@ -94,6 +100,7 @@ export class ListLayout exte constructor(options: ListLayoutOptions = {}) { super(); this.rowHeight = options.rowHeight ?? null; + this.orientation = options.orientation ?? 'vertical'; this.estimatedRowHeight = options.estimatedRowHeight ?? null; this.headingHeight = options.headingHeight ?? null; this.estimatedHeadingHeight = options.estimatedHeadingHeight ?? null; @@ -121,23 +128,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 +205,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 +214,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 +237,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,7 +265,7 @@ 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; // filter out content nodes since we don't want them to affect the height // Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this @@ -260,19 +274,21 @@ export class ListLayout exte let nodes: LayoutNode[] = []; let isEmptyOrLoading = collection?.size === 0; if (isEmptyOrLoading) { - y = 0; + offset = 0; } for (let node of collectionNodes) { + 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); @@ -282,44 +298,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)!; } @@ -350,11 +367,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, @@ -364,11 +387,16 @@ 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)) { @@ -380,25 +408,25 @@ export class ListLayout exte 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, @@ -409,45 +437,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; @@ -458,10 +455,10 @@ export class ListLayout exte // 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; + rectHeight = previousLayoutNode.layoutInfo.rect[heightProperty]; + isEstimated = width !== previousLayoutNode.layoutInfo.rect[widthProperty] || node !== previousLayoutNode.node || previousLayoutNode.layoutInfo.estimatedSize; } else { - rectHeight = this.estimatedRowHeight; + rectHeight = node.type === 'item' || node.rendered ? this.estimatedRowHeight : 0; isEstimated = true; } } @@ -470,13 +467,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 }; } @@ -490,19 +487,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); @@ -598,7 +597,9 @@ export class ListLayout exte let layoutInfo = this.getLayoutInfo(target.key)!; let rect: Rect; if (target.dropPosition === 'before') { - rect = new Rect(layoutInfo.rect.x, Math.max(0, layoutInfo.rect.y - this.dropIndicatorThickness / 2), layoutInfo.rect.width, this.dropIndicatorThickness); + rect = this.orientation === 'horizontal' ? + new Rect(Math.max(0, layoutInfo.rect.x - this.dropIndicatorThickness / 2), layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) + : new Rect(layoutInfo.rect.x, Math.max(0, 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); @@ -616,7 +617,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/OverscanManager.ts b/packages/@react-stately/virtualizer/src/OverscanManager.ts index 9794da1592d..cc25053ba25 100644 --- a/packages/@react-stately/virtualizer/src/OverscanManager.ts +++ b/packages/@react-stately/virtualizer/src/OverscanManager.ts @@ -43,12 +43,10 @@ export class OverscanManager { overscanned.y -= overscanY; } - if (this.velocity.x !== 0) { - let overscanX = this.visibleRect.width / 3; - overscanned.width += overscanX; - if (this.velocity.x < 0) { - overscanned.x -= overscanX; - } + let overscanX = this.visibleRect.width / 3; + overscanned.width += overscanX; + if (this.velocity.x < 0) { + overscanned.x -= overscanX; } return overscanned; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 15af9e858c7..6e0bc341d96 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -33,7 +33,7 @@ import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPers import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, UNSTABLE_useFilteredListState, 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 {SelectionIndicatorContext} from './SelectionIndicator'; @@ -66,6 +66,11 @@ export interface GridListRenderProps { * @selector [data-layout="stack | grid"] */ layout: 'stack' | 'grid', + /** + * The primary orientation of the items. + * @selector [data-orientation="vertical | horizontal"] + */ + orientation: Orientation, /** * State of the grid list. */ @@ -101,7 +106,7 @@ export interface GridListProps extends Omit, 'children'> * The primary orientation of the items. Usually this is the direction that the collection scrolls. * @default 'vertical' */ - orientation?: 'horizontal' | 'vertical' + orientation?: Orientation } @@ -213,14 +218,6 @@ function GridListInner({props, collection, gridListRef: ref}: selectionManager }); - let keyboardDelegate = new ListKeyboardDelegate({ - collection: filteredState.collection, - disabledKeys: selectionManager.disabledKeys, - disabledBehavior: selectionManager.disabledBehavior, - ref, - orientation, - direction - }); let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction, orientation}); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, @@ -234,6 +231,7 @@ function GridListInner({props, collection, gridListRef: ref}: let isEmpty = filteredState.collection.size === 0; let renderValues = { isDropTarget: isRootDropTarget, + orientation, isEmpty, isFocused, isFocusVisible, @@ -274,7 +272,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={orientation}> ({state: inputState, props, listBoxRef}: isFocused, isFocusVisible, layout: props.layout || 'stack', + orientation, state }; let renderProps = useRenderProps({ @@ -266,7 +272,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={orientation}> - + {section => ( -
{section.name}
+ {section.name} {item => {item.name}} @@ -384,6 +388,12 @@ export const VirtualizedListBox: StoryObj = { args: { variableHeight: false, isLoading: false + }, + argTypes: { + orientation: { + control: 'radio', + options: ['vertical', 'horizontal'] + } } }; @@ -402,7 +412,8 @@ export let VirtualizedListBoxEmpty: ListBoxStoryObj = { ) }; -export let VirtualizedListBoxDnd: ListBoxStory = () => { +function VirtualizedListBoxDndRender(args): JSX.Element { + let {orientation} = args; let items: {id: number, name: string}[] = []; for (let i = 0; i < 10000; i++) { items.push({id: i, name: `Item ${i}`}); @@ -433,10 +444,12 @@ export let VirtualizedListBoxDnd: ListBoxStory = () => { { ); }; +export const VirtualizedListBoxDnd: StoryObj = { + render: (args) => , + args: { + orientation: 'vertical' + }, + argTypes: { + orientation: { + control: 'radio', + options: ['vertical', 'horizontal'] + } + } +}; + function VirtualizedListBoxGridExample({minSize = 80, maxSize = 100, preserveAspectRatio = false}: {minSize: number, maxSize: number, preserveAspectRatio: boolean}): JSX.Element { let items: {id: number, name: string}[] = []; for (let i = 0; i < 10000; i++) { @@ -749,11 +775,11 @@ export const ListBoxScrollMargin: ListBoxStory = (args) => { items.push({id: i, name: `Item ${i}`, description: `Description ${i}`}); } return ( - {item => ( @@ -771,12 +797,12 @@ export const ListBoxSmoothScroll: ListBoxStory = (args) => { items.push({id: i, name: `Item ${i}`}); } return ( - {item => {item.name}} diff --git a/packages/react-aria-components/stories/utils.tsx b/packages/react-aria-components/stories/utils.tsx index d5a7439afa9..86dfc3db21a 100644 --- a/packages/react-aria-components/stories/utils.tsx +++ b/packages/react-aria-components/stories/utils.tsx @@ -1,13 +1,17 @@ import {classNames} from '@react-spectrum/utils'; -import {ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps, ProgressBar} from 'react-aria-components'; -import React, {JSX} from 'react'; +import {Header, ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps, ProgressBar} from 'react-aria-components'; +import React, {HTMLAttributes, JSX} from 'react'; import styles from '../example/index.css'; -export const MyListBoxItem = (props: ListBoxItemProps): JSX.Element => { +export const MyHeader = (props: HTMLAttributes) => { + return
; +}; + +export const MyListBoxItem = (props: ListBoxItemProps) => { return ( classNames(styles, 'item', { focused: isFocused, selected: isSelected,