diff --git a/packages/@adobe/spectrum-css-temp/components/table/index.css b/packages/@adobe/spectrum-css-temp/components/table/index.css index be5390ec83a..835545a6ae9 100644 --- a/packages/@adobe/spectrum-css-temp/components/table/index.css +++ b/packages/@adobe/spectrum-css-temp/components/table/index.css @@ -219,27 +219,29 @@ svg.spectrum-Table-sortedIcon { } } -.spectrum-Table-cell { - box-sizing: border-box; - font-size: var(--spectrum-table-cell-text-size); - font-weight: var(--spectrum-table-cell-text-font-weight); - /* need to subtract 1px here because 14px * 1.5 + 14px + 14px = 49px, and we want 48px */ - line-height: calc(calc(var(--spectrum-table-cell-text-size) * var(--spectrum-table-cell-text-line-height)) - 1px); - padding: var(--spectrum-table-cell-regular-padding-y) var(--spectrum-table-cell-padding-x); +.spectrum-Table--regular { + --spectrum-table-cell-padding-y: var(--spectrum-table-cell-regular-padding-y); } -.spectrum-Table--regular {} .spectrum-Table--compact .spectrum-Table-cell { - padding-top: var(--spectrum-table-cell-compact-padding-y); - padding-bottom: var(--spectrum-table-cell-compact-padding-y); + --spectrum-table-cell-padding-y: var(--spectrum-table-cell-compact-padding-y); } .spectrum-Table--spacious .spectrum-Table-cell { - padding-top: var(--spectrum-table-cell-spacious-padding-y); - padding-bottom: var(--spectrum-table-cell-spacious-padding-y); + --spectrum-table-cell-padding-y: var(--spectrum-table-cell-spacious-padding-y); +} + +.spectrum-Table-cell { + box-sizing: border-box; + font-size: var(--spectrum-table-cell-text-size); + font-weight: var(--spectrum-table-cell-text-font-weight); + /* need to subtract 1px here because 14px * 1.5 + 14px + 14px = 49px, and we want 48px */ + line-height: calc(calc(var(--spectrum-table-cell-text-size) * var(--spectrum-table-cell-text-line-height)) - 1px); + padding: var(--spectrum-table-cell-padding-y) var(--spectrum-table-cell-padding-x); } .spectrum-Table-cellContents { + grid-area: contents; flex: 1 1 0%; /* To ensure the flex child only takes up available width, see https://makandracards.com/makandra/66994-css-flex-and-min-width */ min-width: 0px; @@ -254,6 +256,26 @@ svg.spectrum-Table-sortedIcon { margin: -4px; } +.spectrum-Table-expandButton { + grid-area: expand-button; + height: var(--spectrum-global-dimension-size-400); + margin: calc(var(--spectrum-table-cell-padding-y) * -1) 0; + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: center; + outline: none; + transition: transform ease var(--spectrum-global-animation-duration-100); + &.is-open { + [dir='ltr'] & { + transform: rotate(90deg); + } + [dir='rtl'] & { + transform: rotate(-90deg); + } + } +} + .spectrum-Table-cell--hideHeader { padding: 0; justify-content: center; diff --git a/packages/@react-aria/grid/src/useGridRow.ts b/packages/@react-aria/grid/src/useGridRow.ts index 6d892287d2e..1bbe3ecf139 100644 --- a/packages/@react-aria/grid/src/useGridRow.ts +++ b/packages/@react-aria/grid/src/useGridRow.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, FocusableElement, Node} from '@react-types/shared'; -import {GridCollection} from '@react-types/grid'; +import {DOMAttributes, FocusableElement} from '@react-types/shared'; +import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; import {RefObject} from 'react'; @@ -19,7 +19,7 @@ import {SelectableItemStates, useSelectableItem} from '@react-aria/selection'; export interface GridRowProps { /** An object representing the grid row. Contains all the relevant information that makes up the grid row. */ - node: Node, + node: GridNode, /** Whether the grid row is contained in a virtual scroller. */ isVirtualized?: boolean, /** Whether selection should occur on press up instead of press down. */ diff --git a/packages/@react-aria/table/src/useTable.ts b/packages/@react-aria/table/src/useTable.ts index 393c17ef1cf..def462b1faa 100644 --- a/packages/@react-aria/table/src/useTable.ts +++ b/packages/@react-aria/table/src/useTable.ts @@ -20,7 +20,7 @@ import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/ut import {Node} from '@react-types/shared'; import {RefObject, useMemo} from 'react'; import {TableKeyboardDelegate} from './TableKeyboardDelegate'; -import {TableState} from '@react-stately/table'; +import {TableState, TreeGridState} from '@react-stately/table'; import {useCollator, useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; export interface AriaTableProps extends GridProps { @@ -36,7 +36,7 @@ export interface AriaTableProps extends GridProps { * @param state - State for the table, as returned by `useTableState`. * @param ref - The ref attached to the table element. */ -export function useTable(props: AriaTableProps, state: TableState, ref: RefObject): GridAria { +export function useTable(props: AriaTableProps, state: TableState | TreeGridState, ref: RefObject): GridAria { let { keyboardDelegate, isVirtualized, @@ -70,6 +70,10 @@ export function useTable(props: AriaTableProps, state: TableState, ref: gridProps['aria-rowcount'] = state.collection.size + state.collection.headerRows.length; } + if ('expandedKeys' in state) { + gridProps.role = 'treegrid'; + } + let {column, direction: sortDirection} = state.sortDescriptor || {}; let stringFormatter = useLocalizedStringFormatter(intlMessages); let sortDescription = useMemo(() => { diff --git a/packages/@react-aria/table/src/useTableHeaderRow.ts b/packages/@react-aria/table/src/useTableHeaderRow.ts index 1643b640f29..420d7e43596 100644 --- a/packages/@react-aria/table/src/useTableHeaderRow.ts +++ b/packages/@react-aria/table/src/useTableHeaderRow.ts @@ -32,7 +32,7 @@ export function useTableHeaderRow(props: GridRowProps, state: TableState(props: GridRowProps, state: TableState, ref: RefObject): GridRowAria { - let {node} = props; +export function useTableRow(props: GridRowProps, state: TableState | TreeGridState, ref: RefObject): GridRowAria { + let {node, isVirtualized} = props; let {rowProps, ...states} = useGridRow, TableState>(props, state, ref); + let {direction} = useLocale(); + + if (isVirtualized && !('expandedKeys' in state)) { + rowProps['aria-rowindex'] = node.index + 1 + state.collection.headerRows.length; // aria-rowindex is 1 based + } else { + delete rowProps['aria-rowindex']; + } + + let treeGridRowProps: HTMLAttributes = {}; + if ('expandedKeys' in state) { + let treeNode = state.keyMap.get(node.key); + if (treeNode != null) { + let hasChildRows = treeNode.props?.UNSTABLE_childItems || treeNode.props?.children?.length > state.userColumnCount; + treeGridRowProps = { + onKeyDown: (e) => { + if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === treeNode.key && hasChildRows && state.expandedKeys !== 'all' && !state.expandedKeys.has(treeNode.key)) { + state.toggleKey(treeNode.key); + e.stopPropagation(); + } else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === treeNode.key && hasChildRows && (state.expandedKeys === 'all' || state.expandedKeys.has(treeNode.key))) { + state.toggleKey(treeNode.key); + e.stopPropagation(); + } + }, + 'aria-expanded': hasChildRows ? state.expandedKeys === 'all' || state.expandedKeys.has(node.key) : undefined, + 'aria-level': treeNode.level, + 'aria-posinset': treeNode.indexOfType + 1, + 'aria-setsize': treeNode.level > 1 ? + (getLastItem(state.keyMap.get(treeNode?.parentKey).childNodes) as GridNode).indexOfType + 1 : + (getLastItem(state.keyMap.get(state.collection.body.key).childNodes) as GridNode).indexOfType + 1 + }; + } + } + return { rowProps: { - ...rowProps, + ...mergeProps(rowProps, treeGridRowProps), 'aria-labelledby': getRowLabelledBy(state, node.key) }, ...states diff --git a/packages/@react-spectrum/table/chromatic/TreeGridTable.chromatic.tsx b/packages/@react-spectrum/table/chromatic/TreeGridTable.chromatic.tsx new file mode 100644 index 00000000000..a7efcce091b --- /dev/null +++ b/packages/@react-spectrum/table/chromatic/TreeGridTable.chromatic.tsx @@ -0,0 +1,234 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButton} from '@react-spectrum/button'; +import {Cell, Column, Row, SpectrumTableProps, TableBody, TableHeader, TableView} from '../'; +import {Content, View} from '@react-spectrum/view'; +import Delete from '@spectrum-icons/workflow/Delete'; +import {enableTableNestedRows} from '@react-stately/flags'; +import {generatePowerset} from '@react-spectrum/story-utils'; +import {Grid, repeat} from '@react-spectrum/layout'; +import {Heading} from '@react-spectrum/text'; +import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; +import {Meta} from '@storybook/react'; +import React from 'react'; + +enableTableNestedRows(); + +let states = [ + {isQuiet: true}, + {overflowMode: 'wrap'}, + {selectionMode: ['multiple', 'single']}, + {density: ['compact', 'spacious']}, + {defaultExpandedKeys: [[], 'all', ['Lvl 1 Foo 1', 'Lvl 2 Foo 1']]} +]; + +let combinations = generatePowerset(states); + +function shortName(key, value) { + let returnVal = ''; + switch (key) { + case 'isQuiet': + returnVal = 'quiet'; + break; + case 'overflowMode': + returnVal = 'wrap'; + break; + case 'selectionMode': + returnVal = `sm: ${value === undefined ? 'none' : value}`; + break; + case 'density': + returnVal = `den: ${value === undefined ? 'regular' : value}`; + break; + } + return returnVal; +} + +const meta: Meta> = { + title: 'TableView/Expandable rows', + component: TableView, + parameters: { + chromaticProvider: {colorSchemes: ['light'], locales: ['en-US'], scales: ['medium'], disableAnimations: true}, + // large delay with the layout since there are so many tables + chromatic: {delay: 4000} + } +}; + +export default meta; + +let columns = [ + {name: 'Foo', key: 'foo'}, + {name: 'Bar', key: 'bar'}, + {name: 'Baz', key: 'baz'} +]; + +let alignColumns = [ + {name: 'Foo', key: 'foo', align: 'end'}, + {name: 'Bar', key: 'bar', align: 'center'}, + {name: 'Baz', key: 'baz', align: 'start'} +]; + +let dividerColumns = [ + {name: 'Foo', key: 'foo', showDivider: true}, + {name: 'Bar', key: 'bar', showDivider: true}, + {name: 'Baz', key: 'baz', showDivider: true} +]; + +let customWidth = [ + {name: 'Foo', key: 'foo', width: 150}, + {name: 'Bar', key: 'bar', width: 100}, + {name: 'Baz', key: 'baz'} +]; + +let hiddenColumns = [ + {name: 'Foo', key: 'foo'}, + {name: 'Bar', key: 'bar'}, + {name: 'Baz', key: 'baz', hideHeader: true} +]; + +let nestedColumns = [ + {name: 'Tiered One Header', key: 'tier1', children: [ + {name: 'Tier Two Header A', key: 'tier2a', children: [ + {name: 'Foo', key: 'foo'}, + {name: 'Bar', key: 'bar'} + ]}, + {name: 'Tier Two Header B', key: 'tier2b', children: [ + {name: 'Baz', key: 'baz'} + ]} + ]} +]; + +let nestedItems = [ + {foo: 'Lvl 1 Foo 1', bar: 'Lvl 1 Bar 1', baz: 'Lvl 1 Baz 1', childRows: [ + {foo: 'Lvl 2 Foo 1', bar: 'Lvl 2 Bar 1', baz: 'Lvl 2 Baz 1', childRows: [ + {foo: 'Lvl 3 Foo 1', bar: 'Lvl 3 Bar 1', baz: 'Lvl 3 Baz 1'} + ]}, + {foo: 'Lvl 2 Foo 2', bar: 'Lvl 2 Bar 2', baz: 'Lvl 2 Baz 2'} + ]} +]; + +const Template = ({columns, items, ...args}) => ( + + {combinations.map(c => { + let key = Object.keys(c).map(k => shortName(k, c[k])).join(' '); + if (!key) { + key = 'empty'; + } + return ( + + + + {(column: any) => ( + + {column.name} + + )} + + + {(item: any) => + ( + {key => { + let button = ; + return {key === 'baz' ? button : item[key]}; + }} + ) + } + + + + ); + })} + +); + +function renderEmptyState() { + return ( + + + + + No results + No results found + + ); +} + +const EmptyTemplate = (args) => + ( + + + {(column: any) => ( + + {column.name} + + )} + + {[]} + + ); + +export const Default = { + render: Template, + name: 'default items and columns', + args: {columns, items: nestedItems} +}; + +export const ColumnAlign = { + render: Template, + name: 'column alignment', + args: {columns: alignColumns, items: nestedItems} +}; + +export const ColumnDividers = { + render: Template, + name: 'columns dividers', + args: {columns: dividerColumns, items: nestedItems} +}; + +export const ColumnWidth = { + render: Template, + name: 'columns widths', + args: {columns: customWidth, items: nestedItems} +}; + +export const HiddenColumns = { + render: Template, + name: 'hidden columns', + args: {columns: hiddenColumns, items: nestedItems} +}; + +export const NestedColumns = { + render: Template, + name: 'nested columns', + args: {columns: nestedColumns, items: nestedItems} +}; + +export const MaxHeight = () => ( + + + {(column: any) => {column.name}} + + + {(item: any) => {(key) => {item[key]}}} + + +); + +export const Empty = { + render: EmptyTemplate, + name: 'empty table', + args: {} +}; diff --git a/packages/@react-spectrum/table/chromatic/TreeGridTableRTL.chromatic.tsx b/packages/@react-spectrum/table/chromatic/TreeGridTableRTL.chromatic.tsx new file mode 100644 index 00000000000..8338685c60b --- /dev/null +++ b/packages/@react-spectrum/table/chromatic/TreeGridTableRTL.chromatic.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Meta} from '@storybook/react'; + +// Original Table story wasn't performant with so many tables, so split off RTL into its own story +const meta: Meta = { + title: 'TableViewRTL/Expandable rows', + parameters: { + chromaticProvider: {colorSchemes: ['light'], locales: ['ar-AE'], scales: ['medium'], disableAnimations: true}, + // large delay with the layout since there are so many tables + chromatic: {delay: 4000} + } +}; + +export default meta; + +export { + Default, + ColumnAlign, + ColumnDividers, + ColumnWidth, + HiddenColumns, + NestedColumns, + Empty +} from './TableView.chromatic'; diff --git a/packages/@react-spectrum/table/intl/ar-AE.json b/packages/@react-spectrum/table/intl/ar-AE.json index 936072fd080..731b07dbc64 100644 --- a/packages/@react-spectrum/table/intl/ar-AE.json +++ b/packages/@react-spectrum/table/intl/ar-AE.json @@ -5,5 +5,7 @@ "loadingMore": "جارٍ تحميل المزيد...", "resizeColumn": "تغيير حجم العمود", "sortAscending": "فرز بترتيب تصاعدي", - "sortDescending": "فرز بترتيب تنازلي" + "sortDescending": "فرز بترتيب تنازلي", + "expand": "Expand", + "collapse": "Collapse" } diff --git a/packages/@react-spectrum/table/intl/en-US.json b/packages/@react-spectrum/table/intl/en-US.json index 4db3656ac0d..b7aa755fa58 100644 --- a/packages/@react-spectrum/table/intl/en-US.json +++ b/packages/@react-spectrum/table/intl/en-US.json @@ -5,5 +5,7 @@ "sortDescending": "Sort Descending", "resizeColumn": "Resize column", "columnResizer": "Column resizer", - "drag": "Drag" + "drag": "Drag", + "expand": "Expand", + "collapse": "Collapse" } diff --git a/packages/@react-spectrum/table/package.json b/packages/@react-spectrum/table/package.json index 7182b0bcc9d..67ea07625af 100644 --- a/packages/@react-spectrum/table/package.json +++ b/packages/@react-spectrum/table/package.json @@ -54,6 +54,7 @@ "@react-spectrum/tooltip": "^3.5.2", "@react-spectrum/utils": "^3.10.0", "@react-stately/collections": "^3.9.0", + "@react-stately/flags": "3.0.0-alpha.0", "@react-stately/grid": "^3.7.0", "@react-stately/layout": "^3.12.2", "@react-stately/table": "^3.10.0", diff --git a/packages/@react-spectrum/table/src/InsertionIndicator.tsx b/packages/@react-spectrum/table/src/InsertionIndicator.tsx index e29962bdeda..6c6626dc846 100644 --- a/packages/@react-spectrum/table/src/InsertionIndicator.tsx +++ b/packages/@react-spectrum/table/src/InsertionIndicator.tsx @@ -14,7 +14,7 @@ import {classNames} from '@react-spectrum/utils'; import {FocusableElement, ItemDropTarget} from '@react-types/shared'; import React, {DOMAttributes, HTMLAttributes, useRef} from 'react'; import styles from './table.css'; -import {useTableContext} from './TableView'; +import {useTableContext} from './TableViewBase'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; interface InsertionIndicatorProps { diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 4d6a345c994..86fb6feb1d2 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -15,7 +15,7 @@ import ReactDOM from 'react-dom'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; -import {useTableContext, useVirtualizerContext} from './TableView'; +import {useTableContext, useVirtualizerContext} from './TableViewBase'; // @ts-ignore import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg'; diff --git a/packages/@react-spectrum/table/src/RootDropIndicator.tsx b/packages/@react-spectrum/table/src/RootDropIndicator.tsx index 898ad5e2cfe..5a09e74e721 100644 --- a/packages/@react-spectrum/table/src/RootDropIndicator.tsx +++ b/packages/@react-spectrum/table/src/RootDropIndicator.tsx @@ -11,7 +11,7 @@ */ import React, {useRef} from 'react'; -import {useTableContext} from './TableView'; +import {useTableContext} from './TableViewBase'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; export function RootDropIndicator() { diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index ffdc74af1c4..4eb17a19ae4 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Adobe. All rights reserved. + * Copyright 2023 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -10,221 +10,21 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMProps, DOMRef, DropTarget, FocusableElement, FocusableRef, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; -import ArrowDownSmall from '@spectrum-icons/ui/ArrowDownSmall'; -import {chain, mergeProps, scrollIntoView, scrollIntoViewport} from '@react-aria/utils'; -import {Checkbox} from '@react-spectrum/checkbox'; -import ChevronDownMedium from '@spectrum-icons/ui/ChevronDownMedium'; -import { - classNames, - useDOMRef, - useFocusableRef, - useIsMobileDevice, - useStyleProps, - useUnwrapDOMRef -} from '@react-spectrum/utils'; -import {ColumnSize, SpectrumColumnProps, TableProps} from '@react-types/table'; -import type {DragAndDropHooks} from '@react-spectrum/dnd'; -import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; -import type {DraggableItemResult, DropIndicatorAria, DroppableCollectionResult, DroppableItemResult} from '@react-aria/dnd'; -import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus'; -import {getInteractionModality, useHover, usePress} from '@react-aria/interactions'; -import {GridNode} from '@react-types/grid'; -import {InsertionIndicator} from './InsertionIndicator'; -// @ts-ignore -import intlMessages from '../intl/*.json'; -import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; -import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; -import ListGripper from '@spectrum-icons/ui/ListGripper'; -import {Nubbin} from './Nubbin'; -import {ProgressCircle} from '@react-spectrum/progress'; -import React, {DOMAttributes, HTMLAttributes, Key, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {Resizer} from './Resizer'; -import {ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; -import {RootDropIndicator} from './RootDropIndicator'; -import {DragPreview as SpectrumDragPreview} from './DragPreview'; -import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; -import stylesOverrides from './table.css'; -import {TableColumnLayout, TableState, useTableState} from '@react-stately/table'; -import {TableLayout} from '@react-stately/layout'; -import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; -import {useButton} from '@react-aria/button'; -import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useProvider, useProviderProps} from '@react-spectrum/provider'; -import { - useTable, - useTableCell, - useTableColumnHeader, - useTableHeaderRow, - useTableRow, - useTableRowGroup, - useTableSelectAllCheckbox, - useTableSelectionCheckbox -} from '@react-aria/table'; -import {useVisuallyHidden, VisuallyHidden} from '@react-aria/visually-hidden'; +import {DOMRef} from '@react-types/shared'; +import React, {ReactElement, useState} from 'react'; +import {SpectrumTableProps} from './TableViewWrapper'; +import {TableViewBase} from './TableViewBase'; +import {useTableState} from '@react-stately/table'; -const DEFAULT_HEADER_HEIGHT = { - medium: 34, - large: 40 -}; +interface TableProps extends Omit, 'UNSTABLE_allowsExpandableRows'> {} -const DEFAULT_HIDE_HEADER_CELL_WIDTH = { - medium: 38, - large: 46 -}; - -const ROW_HEIGHTS = { - compact: { - medium: 32, - large: 40 - }, - regular: { - medium: 40, - large: 50 - }, - spacious: { - medium: 48, - large: 60 - } -}; - -const SELECTION_CELL_DEFAULT_WIDTH = { - medium: 38, - large: 48 -}; - -const DRAG_BUTTON_CELL_DEFAULT_WIDTH = { - medium: 16, - large: 20 -}; - -interface TableContextValue { - state: TableState, - dragState: DraggableCollectionState, - dropState: DroppableCollectionState, - dragAndDropHooks: DragAndDropHooks['dragAndDropHooks'], - isTableDraggable: boolean, - isTableDroppable: boolean, - shouldShowCheckboxes: boolean, - layout: TableLayout & {tableState: TableState}, - headerRowHovered: boolean, - isInResizeMode: boolean, - setIsInResizeMode: (val: boolean) => void, - isEmpty: boolean, - onFocusedResizer: () => void, - onResizeStart: (widths: Map) => void, - onResize: (widths: Map) => void, - onResizeEnd: (widths: Map) => void, - headerMenuOpen: boolean, - setHeaderMenuOpen: (val: boolean) => void -} - -const TableContext = React.createContext>(null); -export function useTableContext() { - return useContext(TableContext); -} - -const VirtualizerContext = React.createContext(null); -export function useVirtualizerContext() { - return useContext(VirtualizerContext); -} - -export interface SpectrumTableProps extends TableProps, SpectrumSelectionProps, DOMProps, AriaLabelingProps, StyleProps { - /** - * Sets the amount of vertical padding within each cell. - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious', - /** - * Sets the overflow behavior for the cell contents. - * @default 'truncate' - */ - overflowMode?: 'wrap' | 'truncate', - /** Whether the TableView should be displayed with a quiet style. */ - isQuiet?: boolean, - /** Sets what the TableView should render when there is no content to display. */ - renderEmptyState?: () => JSX.Element, - /** Handler that is called when a user performs an action on a row. */ - onAction?: (key: Key) => void, - /** - * Handler that is called when a user starts a column resize. - */ - onResizeStart?: (widths: Map) => void, - /** - * Handler that is called when a user performs a column resize. - * Can be used with the width property on columns to put the column widths into - * a controlled state. - */ - onResize?: (widths: Map) => void, - /** - * Handler that is called after a user performs a column resize. - * Can be used to store the widths of columns for another future session. - */ - onResizeEnd?: (widths: Map) => void, - /** - * The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the TableView. - * @version alpha - */ - dragAndDropHooks?: DragAndDropHooks['dragAndDropHooks'] -} - -function TableView(props: SpectrumTableProps, ref: DOMRef) { - props = useProviderProps(props); +function TableView(props: TableProps, ref: DOMRef) { let { - isQuiet, - onAction, - onResizeStart: propsOnResizeStart, - onResizeEnd: propsOnResizeEnd, + selectionStyle, dragAndDropHooks } = props; + let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(selectionStyle !== 'highlight'); let isTableDraggable = !!dragAndDropHooks?.useDraggableCollectionState; - let isTableDroppable = !!dragAndDropHooks?.useDroppableCollectionState; - let dragHooksProvided = useRef(isTableDraggable); - let dropHooksProvided = useRef(isTableDroppable); - useEffect(() => { - if (dragHooksProvided.current !== isTableDraggable) { - console.warn('Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.'); - } - if (dropHooksProvided.current !== isTableDroppable) { - console.warn('Drop hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.'); - } - }, [isTableDraggable, isTableDroppable]); - let {styleProps} = useStyleProps(props); - - let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(props.selectionStyle !== 'highlight'); - let {direction} = useLocale(); - let {scale} = useProvider(); - - const getDefaultWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { - if (hideHeader) { - let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; - return showDivider ? width + 1 : width; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH[scale]; - } else if (isDragButtonCell) { - return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; - } - }, [scale]); - - const getDefaultMinWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { - if (hideHeader) { - let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; - return showDivider ? width + 1 : width; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH[scale]; - } else if (isDragButtonCell) { - return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; - } - return 75; - }, [scale]); - - // Starts when the user selects resize from the menu, ends when resizing ends - // used to control the visibility of the resizer Nubbin - let [isInResizeMode, setIsInResizeMode] = useState(false); - // Starts when the resizer is actually moved - // entering resizing/exiting resizing doesn't trigger a render - // with table layout, so we need to track it here - let [, setIsResizing] = useState(false); let state = useTableState({ ...props, showSelectionCheckboxes, @@ -238,1216 +38,10 @@ function TableView(props: SpectrumTableProps, ref: DOMRef(); - let bodyRef = useRef(); - let stringFormatter = useLocalizedStringFormatter(intlMessages); - - let density = props.density || 'regular'; - let columnLayout = useMemo( - () => new TableColumnLayout({ - getDefaultWidth, - getDefaultMinWidth - }), - [getDefaultWidth, getDefaultMinWidth] - ); - let tableLayout = useMemo(() => new TableLayout({ - // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. - rowHeight: props.overflowMode === 'wrap' - ? null - : ROW_HEIGHTS[density][scale], - estimatedRowHeight: props.overflowMode === 'wrap' - ? ROW_HEIGHTS[density][scale] - : null, - headingHeight: props.overflowMode === 'wrap' - ? null - : DEFAULT_HEADER_HEIGHT[scale], - estimatedHeadingHeight: props.overflowMode === 'wrap' - ? DEFAULT_HEADER_HEIGHT[scale] - : null, - columnLayout, - initialCollection: state.collection - }), - // don't recompute when state.collection changes, only used for initial value - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.overflowMode, scale, density, columnLayout] - ); - - // Use a proxy so that a new object is created for each render so that alternate instances aren't affected by mutation. - // This can be thought of as equivalent to `{…tableLayout, tableState: state}`, but works with classes as well. - let layout = useMemo(() => { - let proxy = new Proxy(tableLayout, { - get(target, prop, receiver) { - return prop === 'tableState' ? state : Reflect.get(target, prop, receiver); - } - }); - return proxy as TableLayout & {tableState: TableState}; - }, [state, tableLayout]); - - let dragState: DraggableCollectionState; - let preview = useRef(null); - if (isTableDraggable) { - dragState = dragAndDropHooks.useDraggableCollectionState({ - collection: state.collection, - selectionManager: state.selectionManager, - preview - }); - dragAndDropHooks.useDraggableCollection({}, dragState, domRef); - } - - let DragPreview = dragAndDropHooks?.DragPreview; - let dropState: DroppableCollectionState; - let droppableCollection: DroppableCollectionResult; - let isRootDropTarget: boolean; - if (isTableDroppable) { - dropState = dragAndDropHooks.useDroppableCollectionState({ - collection: state.collection, - selectionManager: state.selectionManager - }); - droppableCollection = dragAndDropHooks.useDroppableCollection({ - keyboardDelegate: layout, - dropTargetDelegate: layout - }, dropState, domRef); - - isRootDropTarget = dropState.isDropTarget({type: 'root'}); - } - - let {gridProps} = useTable({ - ...props, - isVirtualized: true, - layout, - onRowAction: onAction - }, state, domRef); - let [headerMenuOpen, setHeaderMenuOpen] = useState(false); - let [headerRowHovered, setHeaderRowHovered] = useState(false); - - // This overrides collection view's renderWrapper to support DOM hierarchy. - type View = ReusableView, ReactNode>; - let renderWrapper = (parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { - let style = layoutInfoToStyle(reusableView.layoutInfo, direction, parent && parent.layoutInfo); - if (style.overflow === 'hidden') { - style.overflow = 'visible'; // needed to support position: sticky - } - - if (reusableView.viewType === 'rowgroup') { - return ( - - {isTableDroppable && - - } - {renderChildren(children)} - - ); - } - - if (reusableView.viewType === 'header') { - return ( - - {renderChildren(children)} - - ); - } - - if (reusableView.viewType === 'row') { - return ( - - {renderChildren(children)} - - ); - } - - if (reusableView.viewType === 'headerrow') { - return ( - - {renderChildren(children)} - - ); - } - let isDropTarget: boolean; - let isRootDroptarget: boolean; - if (isTableDroppable) { - if (parent.content) { - isDropTarget = dropState.isDropTarget({type: 'item', dropPosition: 'on', key: parent.content.key}); - } - isRootDroptarget = dropState.isDropTarget({type: 'root'}); - } - - return ( - - {reusableView.rendered} - - ); - }; - - let renderView = (type: string, item: GridNode) => { - switch (type) { - case 'header': - case 'rowgroup': - case 'section': - case 'row': - case 'headerrow': - return null; - case 'cell': { - if (item.props.isSelectionCell) { - return ; - } - - if (item.props.isDragButtonCell) { - return ; - } - - return ; - } - case 'placeholder': - // TODO: move to react-aria? - return ( -
1 ? item.colspan : null} /> - ); - case 'column': - if (item.props.isSelectionCell) { - return ; - } - - if (item.props.isDragButtonCell) { - return ; - } - - // TODO: consider this case, what if we have hidden headers and a empty table - if (item.props.hideHeader) { - return ( - - - {item.rendered} - - ); - } - - if (item.props.allowsResizing && !item.hasChildNodes) { - return ; - } - - return ( - - ); - case 'loader': - return ( - - 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> - - ); - case 'empty': { - let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; - if (emptyState == null) { - return null; - } - - return ( - - {emptyState} - - ); - } - } - }; - - let [isVerticalScrollbarVisible, setVerticalScollbarVisible] = useState(false); - let [isHorizontalScrollbarVisible, setHorizontalScollbarVisible] = useState(false); - let viewport = useRef({x: 0, y: 0, width: 0, height: 0}); - let onVisibleRectChange = useCallback((e) => { - if (viewport.current.width === e.width && viewport.current.height === e.height) { - return; - } - viewport.current = e; - if (bodyRef.current) { - setVerticalScollbarVisible(bodyRef.current.clientWidth + 2 < bodyRef.current.offsetWidth); - setHorizontalScollbarVisible(bodyRef.current.clientHeight + 2 < bodyRef.current.offsetHeight); - } - }, []); - let {isFocusVisible, focusProps} = useFocusRing(); - let isEmpty = state.collection.size === 0; - - let onFocusedResizer = () => { - bodyRef.current.scrollLeft = headerRef.current.scrollLeft; - }; - - let onResizeStart = useCallback((widths) => { - setIsResizing(true); - propsOnResizeStart?.(widths); - }, [setIsResizing, propsOnResizeStart]); - let onResizeEnd = useCallback((widths) => { - setIsInResizeMode(false); - setIsResizing(false); - propsOnResizeEnd?.(widths); - }, [propsOnResizeEnd, setIsInResizeMode, setIsResizing]); - - let focusedKey = state.selectionManager.focusedKey; - if (dropState?.target?.type === 'item') { - focusedKey = dropState.target.key; - } - - let mergedProps = mergeProps( - isTableDroppable && droppableCollection?.collectionProps, - gridProps, - focusProps, - dragAndDropHooks?.isVirtualDragging() && {tabIndex: null} - ); - - return ( - - - {DragPreview && isTableDraggable && - - {() => { - if (dragAndDropHooks.renderPreview) { - return dragAndDropHooks.renderPreview(dragState.draggingKeys, dragState.draggedKey); - } - let itemCount = dragState.draggingKeys.size; - let maxWidth = bodyRef.current.getBoundingClientRect().width; - let height = ROW_HEIGHTS[density][scale]; - let itemText = state.collection.getTextValue(dragState.draggedKey); - return ; - }} - - } - - ); -} - -// This is a custom Virtualizer that also has a header that syncs its scroll position with the body. -function TableVirtualizer(props) { - let {layout, collection, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; - let {direction} = useLocale(); - let loadingState = collection.body.props.loadingState; - let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - let onLoadMore = collection.body.props.onLoadMore; - let transitionDuration = 220; - if (isLoading) { - transitionDuration = 160; - } - if (layout.resizingColumn != null) { - // while resizing, prop changes should not cause animations - transitionDuration = 0; - } - let state = useVirtualizerState({ - layout, - collection, - renderView, - renderWrapper, - onVisibleRectChange(rect) { - bodyRef.current.scrollTop = rect.y; - setScrollLeft(bodyRef.current, direction, rect.x); - }, - transitionDuration - }); - - let scrollToItem = useCallback((key) => { - let item = collection.getItem(key); - let column = collection.columns[0]; - let virtualizer = state.virtualizer; - - virtualizer.scrollToItem(key, { - duration: 0, - // Prevent scrolling to the top when clicking on column headers. - shouldScrollY: item?.type !== 'column', - // Offset scroll position by width of selection cell - // (which is sticky and will overlap the cell we're scrolling to). - offsetX: column.props.isSelectionCell || column.props.isDragButtonCell - ? layout.getColumnWidth(column.key) - : 0 - }); - - // Sync the scroll positions of the column headers and the body so scrollIntoViewport can - // properly decide if the column is outside the viewport or not - headerRef.current.scrollLeft = bodyRef.current.scrollLeft; - }, [collection, bodyRef, headerRef, layout, state.virtualizer]); - - let memoedVirtualizerProps = useMemo(() => ({ - tabIndex: otherProps.tabIndex, - focusedKey, - scrollToItem, - isLoading, - onLoadMore - }), [otherProps.tabIndex, focusedKey, scrollToItem, isLoading, onLoadMore]); - - let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef); - - // this effect runs whenever the contentSize changes, it doesn't matter what the content size is - // only that it changes in a resize, and when that happens, we want to sync the body to the - // header scroll position - useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current.contains(document.activeElement)) { - scrollIntoView(headerRef.current, document.activeElement as HTMLElement); - scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); - bodyRef.current.scrollLeft = headerRef.current.scrollLeft; - } - }, [state.contentSize, headerRef, bodyRef, domRef]); - - let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; - let visibleRect = state.virtualizer.visibleRect; - - // Sync the scroll position from the table body to the header container. - let onScroll = useCallback(() => { - headerRef.current.scrollLeft = bodyRef.current.scrollLeft; - }, [bodyRef, headerRef]); - - let resizerPosition = layout.getResizerPosition() - 2; - - let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; - // this should be fine, every movement of the resizer causes a rerender - // scrolling can cause it to lag for a moment, but it's always updated - let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.maxX; - let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; - - // minimize re-render caused on Resizers by memoing this - let resizingColumnWidth = layout.getColumnWidth(layout.resizingColumn); - let resizingColumn = useMemo(() => ({ - width: resizingColumnWidth, - key: layout.resizingColumn - }), [resizingColumnWidth, layout.resizingColumn]); - let mergedProps = mergeProps( - otherProps, - virtualizerProps, - isVirtualDragging && {tabIndex: null} - ); - - return ( - - -
-
- {state.visibleViews[0]} -
- - {state.visibleViews[1]} -
- -
- - - ); -} - -function TableHeader({children, ...otherProps}) { - let {rowGroupProps} = useTableRowGroup(); - - return ( -
- {children} -
- ); -} - -function TableColumnHeader(props) { - let {column} = props; - let ref = useRef(null); - let {state, isEmpty} = useTableContext(); - let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); - let columnProps = column.props as SpectrumColumnProps; - useEffect(() => { - if (column.hasChildNodes && columnProps.allowsResizing) { - console.warn(`Column key: ${column.key}. Columns with child columns don't allow resizing.`); - } - }, [column.hasChildNodes, column.key, columnProps.allowsResizing]); - - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); - - let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); - - const allProps = [columnHeaderProps, hoverProps, pressProps]; - - return ( - -
1, - 'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end' - } - ) - ) - }> - {columnProps.allowsSorting && - - } - {columnProps.hideHeader ? - {column.rendered} : -
{column.rendered}
- } -
-
- ); -} - -let _TableColumnHeaderButton = (props, ref: FocusableRef) => { - let {focusProps, alignment, ...otherProps} = props; - let {isEmpty} = useTableContext(); - let domRef = useFocusableRef(ref); - let {buttonProps} = useButton({...otherProps, elementType: 'div', isDisabled: isEmpty}, domRef); - let {hoverProps, isHovered} = useHover({...otherProps, isDisabled: isEmpty}); - - return ( -
-
- {props.children} -
-
- ); -}; -let TableColumnHeaderButton = React.forwardRef(_TableColumnHeaderButton); - -function ResizableTableColumnHeader(props) { - let {column} = props; - let ref = useRef(null); - let triggerRef = useRef(null); - let resizingRef = useRef(null); - let { - state, - layout, - onResizeStart, - onResize, - onResizeEnd, - headerRowHovered, - setIsInResizeMode, - isEmpty, - onFocusedResizer, - isInResizeMode, - headerMenuOpen, - setHeaderMenuOpen - } = useTableContext(); - let stringFormatter = useLocalizedStringFormatter(intlMessages); - let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); - - let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty || headerMenuOpen}); - - const allProps = [columnHeaderProps, pressProps, hoverProps]; - - let columnProps = column.props as SpectrumColumnProps; - - let {isFocusVisible, focusProps} = useFocusRing(); - - const onMenuSelect = (key) => { - switch (key) { - case 'sort-asc': - state.sort(column.key, 'ascending'); - break; - case 'sort-desc': - state.sort(column.key, 'descending'); - break; - case 'resize': - layout.startResize(column.key); - setIsInResizeMode(true); - state.setKeyboardNavigationDisabled(true); - break; - } - }; - let allowsSorting = column.props?.allowsSorting; - let items = useMemo(() => { - let options = [ - allowsSorting ? { - label: stringFormatter.format('sortAscending'), - id: 'sort-asc' - } : undefined, - allowsSorting ? { - label: stringFormatter.format('sortDescending'), - id: 'sort-desc' - } : undefined, - { - label: stringFormatter.format('resizeColumn'), - id: 'resize' - } - ]; - return options; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allowsSorting]); - let isMobile = useIsMobileDevice(); - - let resizingColumn = layout.resizingColumn; - let prevResizingColumn = useRef(null); - let timeout = useRef(null); - useEffect(() => { - if (prevResizingColumn.current !== resizingColumn && - resizingColumn != null && - resizingColumn === column.key) { - if (timeout.current) { - clearTimeout(timeout.current); - } - // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait - // without the immediate timeout, Android Chrome doesn't move focus to the resizer - let focusResizer = () => { - resizingRef.current.focus(); - onFocusedResizer(); - timeout.current = null; - }; - if (isMobile) { - timeout.current = setTimeout(focusResizer, 400); - return; - } - timeout.current = setTimeout(focusResizer, 0); - } - prevResizingColumn.current = resizingColumn; - }, [resizingColumn, column.key, isMobile, onFocusedResizer, resizingRef, prevResizingColumn, timeout]); - - // eslint-disable-next-line arrow-body-style - useEffect(() => { - return () => clearTimeout(timeout.current); - }, []); - - let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); - let alignment = 'start'; - let menuAlign = 'start' as 'start' | 'end'; - if (columnProps.align === 'center' || column.colspan > 1) { - alignment = 'center'; - } else if (columnProps.align === 'end') { - alignment = 'end'; - menuAlign = 'end'; - } - return ( - -
- - - {columnProps.allowsSorting && - - } - {columnProps.hideHeader ? - {column.rendered} : -
{column.rendered}
- } - { - columnProps.allowsResizing && - } -
- - {(item) => ( - - {item.label} - - )} - -
- -
-
- -
-
-
-
+ ); } -function TableSelectAllCell({column}) { - let ref = useRef(); - let {state} = useTableContext(); - let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); - - let {checkboxProps} = useTableSelectAllCheckbox(state); - let {hoverProps, isHovered} = useHover({}); - - return ( - -
- { - /* - In single selection mode, the checkbox will be hidden. - So to avoid leaving a column header with no accessible content, - we use a VisuallyHidden component to include the aria-label from the checkbox, - which for single selection will be "Select." - */ - isSingleSelectionMode && - {checkboxProps['aria-label']} - } - -
-
- ); -} - -function TableDragHeaderCell({column}) { - let ref = useRef(); - let {state} = useTableContext(); - let {columnHeaderProps} = useTableColumnHeader({ - node: column, - isVirtualized: true - }, state, ref); - let stringFormatter = useLocalizedStringFormatter(intlMessages); - - return ( - -
- {stringFormatter.format('drag')} -
-
- ); -} - -function TableRowGroup({children, ...otherProps}) { - let {rowGroupProps} = useTableRowGroup(); - - return ( -
- {children} -
- ); -} - -function DragButton() { - let {dragButtonProps, dragButtonRef, isFocusVisibleWithin} = useTableRowContext(); - let {visuallyHiddenProps} = useVisuallyHidden(); - return ( - -
} - className={ - classNames( - stylesOverrides, - 'react-spectrum-Table-dragButton' - ) - } - style={!isFocusVisibleWithin ? {...visuallyHiddenProps.style} : {}} - ref={dragButtonRef} - draggable="true"> - -
-
- ); -} - -interface TableRowContextValue { - dragButtonProps: React.HTMLAttributes, - dragButtonRef: React.MutableRefObject, - isFocusVisibleWithin: boolean -} - - -const TableRowContext = React.createContext(null); -export function useTableRowContext() { - return useContext(TableRowContext); -} - -function TableRow({item, children, hasActions, isTableDraggable, isTableDroppable, ...otherProps}) { - let ref = useRef(); - let {state, layout, dragAndDropHooks, dragState, dropState} = useTableContext(); - let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; - let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); - let isDroppable = isTableDroppable && !isDisabled; - let isSelected = state.selectionManager.isSelected(item.key); - let {rowProps} = useTableRow({ - node: item, - isVirtualized: true, - shouldSelectOnPressUp: isTableDraggable - }, state, ref); - - let {pressProps, isPressed} = usePress({isDisabled}); - - // The row should show the focus background style when any cell inside it is focused. - // If the row itself is focused, then it should have a blue focus indicator on the left. - let { - isFocusVisible: isFocusVisibleWithin, - focusProps: focusWithinProps - } = useFocusRing({within: true}); - let {isFocusVisible, focusProps} = useFocusRing(); - let {hoverProps, isHovered} = useHover({isDisabled}); - let isFirstRow = state.collection.rows.find(row => row.level === 1)?.key === item.key; - let isLastRow = item.nextKey == null; - // Figure out if the TableView content is equal or greater in height to the container. If so, we'll need to round the bottom - // border corners of the last row when selected. - let isFlushWithContainerBottom = false; - if (isLastRow) { - if (layout.getContentSize()?.height >= layout.virtualizer?.getVisibleRect().height) { - isFlushWithContainerBottom = true; - } - } - - let draggableItem: DraggableItemResult; - if (isTableDraggable) { - // eslint-disable-next-line react-hooks/rules-of-hooks - draggableItem = dragAndDropHooks.useDraggableItem({key: item.key, hasDragButton: true}, dragState); - if (isDisabled) { - draggableItem = null; - } - } - let droppableItem: DroppableItemResult; - let isDropTarget: boolean; - let dropIndicator: DropIndicatorAria; - let dropIndicatorRef = useRef(); - if (isTableDroppable) { - let target = {type: 'item', key: item.key, dropPosition: 'on'} as DropTarget; - isDropTarget = dropState.isDropTarget(target); - // eslint-disable-next-line react-hooks/rules-of-hooks - dropIndicator = dragAndDropHooks.useDropIndicator({target}, dropState, dropIndicatorRef); - } - - let dragButtonRef = React.useRef(); - let {buttonProps: dragButtonProps} = useButton({ - ...draggableItem?.dragButtonProps, - elementType: 'div' - }, dragButtonRef); - - let props = mergeProps( - rowProps, - otherProps, - focusWithinProps, - focusProps, - hoverProps, - pressProps, - draggableItem?.dragProps, - // Remove tab index from list row if performing a screenreader drag. This prevents TalkBack from focusing the row, - // allowing for single swipe navigation between row drop indicator - dragAndDropHooks?.isVirtualDragging() && {tabIndex: null} - ) as HTMLAttributes & DOMAttributes; - - let dropProps = isDroppable ? droppableItem?.dropProps : {'aria-hidden': droppableItem?.dropProps['aria-hidden']}; - let {visuallyHiddenProps} = useVisuallyHidden(); - - return ( - - {isTableDroppable && isFirstRow && - - } - {isTableDroppable && !dropIndicator?.isHidden && -
-
-
-
-
- } -
- {children} -
- {isTableDroppable && - - } - - ); -} - -function TableHeaderRow({item, children, style, ...props}) { - let {state, headerMenuOpen} = useTableContext(); - let ref = useRef(); - let {rowProps} = useTableHeaderRow({node: item, isVirtualized: true}, state, ref); - let {hoverProps} = useHover({...props, isDisabled: headerMenuOpen}); - - return ( -
- {children} -
- ); -} - -function TableDragCell({cell}) { - let ref = useRef(); - let {state, isTableDraggable} = useTableContext(); - let isDisabled = state.disabledKeys.has(cell.parentKey); - let {gridCellProps} = useTableCell({ - node: cell, - isVirtualized: true - }, state, ref); - - - return ( - -
- {isTableDraggable && !isDisabled && } -
-
- ); -} - -function TableCheckboxCell({cell}) { - let ref = useRef(); - let {state} = useTableContext(); - let isDisabled = state.disabledKeys.has(cell.parentKey); - let {gridCellProps} = useTableCell({ - node: cell, - isVirtualized: true - }, state, ref); - - let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); - - return ( - -
- {state.selectionManager.selectionMode !== 'none' && - - } -
-
- ); -} - -function TableCell({cell}) { - let {state} = useTableContext(); - let ref = useRef(); - let columnProps = cell.column.props as SpectrumColumnProps; - let isDisabled = state.disabledKeys.has(cell.parentKey); - let {gridCellProps} = useTableCell({ - node: cell, - isVirtualized: true - }, state, ref); - - return ( - -
- - {cell.rendered} - -
-
- ); -} - -function CenteredWrapper({children}) { - let {state} = useTableContext(); - return ( -
-
- {children} -
-
- ); -} - -/** - * Tables are containers for displaying information. They allow users to quickly scan, sort, compare, and take action on large amounts of data. - */ -const _TableView = React.forwardRef(TableView) as (props: SpectrumTableProps & {ref?: DOMRef}) => ReactElement; - +const _TableView = React.forwardRef(TableView) as (props: TableProps & {ref?: DOMRef}) => ReactElement; export {_TableView as TableView}; diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx new file mode 100644 index 00000000000..1eca3efa743 --- /dev/null +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -0,0 +1,1493 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import ArrowDownSmall from '@spectrum-icons/ui/ArrowDownSmall'; +import {chain, isAndroid, mergeProps, scrollIntoView, scrollIntoViewport} from '@react-aria/utils'; +import {Checkbox} from '@react-spectrum/checkbox'; +import ChevronDownMedium from '@spectrum-icons/ui/ChevronDownMedium'; +import ChevronLeftMedium from '@spectrum-icons/ui/ChevronLeftMedium'; +import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; +import { + classNames, + useDOMRef, + useFocusableRef, + useIsMobileDevice, + useStyleProps, + useUnwrapDOMRef +} from '@react-spectrum/utils'; +import {ColumnSize, SpectrumColumnProps} from '@react-types/table'; +import {DOMRef, DropTarget, FocusableElement, FocusableRef} from '@react-types/shared'; +import type {DragAndDropHooks} from '@react-spectrum/dnd'; +import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; +import type {DraggableItemResult, DropIndicatorAria, DroppableCollectionResult, DroppableItemResult} from '@react-aria/dnd'; +import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus'; +import {getInteractionModality, isFocusVisible, useHover, usePress} from '@react-aria/interactions'; +import {GridNode} from '@react-types/grid'; +import {InsertionIndicator} from './InsertionIndicator'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; +import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; +import ListGripper from '@spectrum-icons/ui/ListGripper'; +import {Nubbin} from './Nubbin'; +import {ProgressCircle} from '@react-spectrum/progress'; +import React, {DOMAttributes, HTMLAttributes, Key, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {Resizer} from './Resizer'; +import {ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; +import {RootDropIndicator} from './RootDropIndicator'; +import {DragPreview as SpectrumDragPreview} from './DragPreview'; +import {SpectrumTableProps} from './TableViewWrapper'; +import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import stylesOverrides from './table.css'; +import {TableColumnLayout, TableState, TreeGridState} from '@react-stately/table'; +import {TableLayout} from '@react-stately/layout'; +import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; +import {useButton} from '@react-aria/button'; +import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useProvider, useProviderProps} from '@react-spectrum/provider'; +import { + useTable, + useTableCell, + useTableColumnHeader, + useTableHeaderRow, + useTableRow, + useTableRowGroup, + useTableSelectAllCheckbox, + useTableSelectionCheckbox +} from '@react-aria/table'; +import {useVisuallyHidden, VisuallyHidden} from '@react-aria/visually-hidden'; + +const DEFAULT_HEADER_HEIGHT = { + medium: 34, + large: 40 +}; + +const DEFAULT_HIDE_HEADER_CELL_WIDTH = { + medium: 38, + large: 46 +}; + +const ROW_HEIGHTS = { + compact: { + medium: 32, + large: 40 + }, + regular: { + medium: 40, + large: 50 + }, + spacious: { + medium: 48, + large: 60 + } +}; + +const SELECTION_CELL_DEFAULT_WIDTH = { + medium: 38, + large: 48 +}; + +const DRAG_BUTTON_CELL_DEFAULT_WIDTH = { + medium: 16, + large: 20 +}; + +const LEVEL_OFFSET_WIDTH = { + medium: 16, + large: 20 +}; + +export interface TableContextValue { + state: TableState | TreeGridState, + dragState: DraggableCollectionState, + dropState: DroppableCollectionState, + dragAndDropHooks: DragAndDropHooks['dragAndDropHooks'], + isTableDraggable: boolean, + isTableDroppable: boolean, + layout: TableLayout & {tableState: TableState | TreeGridState}, + headerRowHovered: boolean, + isInResizeMode: boolean, + setIsInResizeMode: (val: boolean) => void, + isEmpty: boolean, + onFocusedResizer: () => void, + onResizeStart: (widths: Map) => void, + onResize: (widths: Map) => void, + onResizeEnd: (widths: Map) => void, + headerMenuOpen: boolean, + setHeaderMenuOpen: (val: boolean) => void +} + +export const TableContext = React.createContext>(null); +export function useTableContext() { + return useContext(TableContext); +} + +export const VirtualizerContext = React.createContext(null); +export function useVirtualizerContext() { + return useContext(VirtualizerContext); +} + +interface TableBaseProps extends SpectrumTableProps { + state: TableState | TreeGridState +} + +function TableViewBase(props: TableBaseProps, ref: DOMRef) { + props = useProviderProps(props); + let { + isQuiet, + onAction, + onResizeStart: propsOnResizeStart, + onResizeEnd: propsOnResizeEnd, + dragAndDropHooks, + state + } = props; + let isTableDraggable = !!dragAndDropHooks?.useDraggableCollectionState; + let isTableDroppable = !!dragAndDropHooks?.useDroppableCollectionState; + let dragHooksProvided = useRef(isTableDraggable); + let dropHooksProvided = useRef(isTableDroppable); + useEffect(() => { + if (dragHooksProvided.current !== isTableDraggable) { + console.warn('Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.'); + } + if (dropHooksProvided.current !== isTableDroppable) { + console.warn('Drop hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.'); + } + if ('expandedKeys' in state && (isTableDraggable || isTableDroppable)) { + console.warn('Drag and drop is not yet fully supported with expandable rows and may produce unexpected results.'); + } + }, [isTableDraggable, isTableDroppable, state]); + + let {styleProps} = useStyleProps(props); + let {direction} = useLocale(); + let {scale} = useProvider(); + + const getDefaultWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; + } + }, [scale]); + + const getDefaultMinWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; + } + return 75; + }, [scale]); + + // Starts when the user selects resize from the menu, ends when resizing ends + // used to control the visibility of the resizer Nubbin + let [isInResizeMode, setIsInResizeMode] = useState(false); + // Starts when the resizer is actually moved + // entering resizing/exiting resizing doesn't trigger a render + // with table layout, so we need to track it here + let [, setIsResizing] = useState(false); + + let domRef = useDOMRef(ref); + let headerRef = useRef(); + let bodyRef = useRef(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + + let density = props.density || 'regular'; + let columnLayout = useMemo( + () => new TableColumnLayout({ + getDefaultWidth, + getDefaultMinWidth + }), + [getDefaultWidth, getDefaultMinWidth] + ); + let tableLayout = useMemo(() => new TableLayout({ + // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. + rowHeight: props.overflowMode === 'wrap' + ? null + : ROW_HEIGHTS[density][scale], + estimatedRowHeight: props.overflowMode === 'wrap' + ? ROW_HEIGHTS[density][scale] + : null, + headingHeight: props.overflowMode === 'wrap' + ? null + : DEFAULT_HEADER_HEIGHT[scale], + estimatedHeadingHeight: props.overflowMode === 'wrap' + ? DEFAULT_HEADER_HEIGHT[scale] + : null, + columnLayout, + initialCollection: state.collection + }), + // don't recompute when state.collection changes, only used for initial value + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.overflowMode, scale, density, columnLayout] + ); + + // Use a proxy so that a new object is created for each render so that alternate instances aren't affected by mutation. + // This can be thought of as equivalent to `{…tableLayout, tableState: state}`, but works with classes as well. + let layout = useMemo(() => { + let proxy = new Proxy(tableLayout, { + get(target, prop, receiver) { + return prop === 'tableState' ? state : Reflect.get(target, prop, receiver); + } + }); + return proxy as TableLayout & {tableState: TableState | TreeGridState}; + }, [state, tableLayout]); + + let dragState: DraggableCollectionState; + let preview = useRef(null); + if (isTableDraggable) { + dragState = dragAndDropHooks.useDraggableCollectionState({ + collection: state.collection, + selectionManager: state.selectionManager, + preview + }); + dragAndDropHooks.useDraggableCollection({}, dragState, domRef); + } + + let DragPreview = dragAndDropHooks?.DragPreview; + let dropState: DroppableCollectionState; + let droppableCollection: DroppableCollectionResult; + let isRootDropTarget: boolean; + if (isTableDroppable) { + dropState = dragAndDropHooks.useDroppableCollectionState({ + collection: state.collection, + selectionManager: state.selectionManager + }); + droppableCollection = dragAndDropHooks.useDroppableCollection({ + keyboardDelegate: layout, + dropTargetDelegate: layout + }, dropState, domRef); + + isRootDropTarget = dropState.isDropTarget({type: 'root'}); + } + + let {gridProps} = useTable({ + ...props, + isVirtualized: true, + layout, + onRowAction: onAction + }, state, domRef); + let [headerMenuOpen, setHeaderMenuOpen] = useState(false); + let [headerRowHovered, setHeaderRowHovered] = useState(false); + + // This overrides collection view's renderWrapper to support DOM hierarchy. + type View = ReusableView, ReactNode>; + let renderWrapper = (parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { + let style = layoutInfoToStyle(reusableView.layoutInfo, direction, parent && parent.layoutInfo); + if (style.overflow === 'hidden') { + style.overflow = 'visible'; // needed to support position: sticky + } + + if (reusableView.viewType === 'rowgroup') { + return ( + + {isTableDroppable && + + } + {renderChildren(children)} + + ); + } + + if (reusableView.viewType === 'header') { + return ( + + {renderChildren(children)} + + ); + } + + if (reusableView.viewType === 'row') { + return ( + + {renderChildren(children)} + + ); + } + + if (reusableView.viewType === 'headerrow') { + return ( + + {renderChildren(children)} + + ); + } + let isDropTarget: boolean; + let isRootDroptarget: boolean; + if (isTableDroppable) { + if (parent.content) { + isDropTarget = dropState.isDropTarget({type: 'item', dropPosition: 'on', key: parent.content.key}); + } + isRootDroptarget = dropState.isDropTarget({type: 'root'}); + } + + return ( + + {reusableView.rendered} + + ); + }; + + let renderView = (type: string, item: GridNode) => { + switch (type) { + case 'header': + case 'rowgroup': + case 'section': + case 'row': + case 'headerrow': + return null; + case 'cell': { + if (item.props.isSelectionCell) { + return ; + } + + if (item.props.isDragButtonCell) { + return ; + } + + return ; + } + case 'placeholder': + // TODO: move to react-aria? + return ( +
1 ? item.colspan : null} /> + ); + case 'column': + if (item.props.isSelectionCell) { + return ; + } + + if (item.props.isDragButtonCell) { + return ; + } + + // TODO: consider this case, what if we have hidden headers and a empty table + if (item.props.hideHeader) { + return ( + + + {item.rendered} + + ); + } + + if (item.props.allowsResizing && !item.hasChildNodes) { + return ; + } + + return ( + + ); + case 'loader': + return ( + + 0 ? stringFormatter.format('loadingMore') : stringFormatter.format('loading')} /> + + ); + case 'empty': { + let emptyState = props.renderEmptyState ? props.renderEmptyState() : null; + if (emptyState == null) { + return null; + } + + return ( + + {emptyState} + + ); + } + } + }; + + let [isVerticalScrollbarVisible, setVerticalScollbarVisible] = useState(false); + let [isHorizontalScrollbarVisible, setHorizontalScollbarVisible] = useState(false); + let viewport = useRef({x: 0, y: 0, width: 0, height: 0}); + let onVisibleRectChange = useCallback((e) => { + if (viewport.current.width === e.width && viewport.current.height === e.height) { + return; + } + viewport.current = e; + if (bodyRef.current) { + setVerticalScollbarVisible(bodyRef.current.clientWidth + 2 < bodyRef.current.offsetWidth); + setHorizontalScollbarVisible(bodyRef.current.clientHeight + 2 < bodyRef.current.offsetHeight); + } + }, []); + let {isFocusVisible, focusProps} = useFocusRing(); + let isEmpty = state.collection.size === 0; + + let onFocusedResizer = () => { + bodyRef.current.scrollLeft = headerRef.current.scrollLeft; + }; + + let onResizeStart = useCallback((widths) => { + setIsResizing(true); + propsOnResizeStart?.(widths); + }, [setIsResizing, propsOnResizeStart]); + let onResizeEnd = useCallback((widths) => { + setIsInResizeMode(false); + setIsResizing(false); + propsOnResizeEnd?.(widths); + }, [propsOnResizeEnd, setIsInResizeMode, setIsResizing]); + + let focusedKey = state.selectionManager.focusedKey; + if (dropState?.target?.type === 'item') { + focusedKey = dropState.target.key; + } + + let mergedProps = mergeProps( + isTableDroppable && droppableCollection?.collectionProps, + gridProps, + focusProps, + dragAndDropHooks?.isVirtualDragging() && {tabIndex: null} + ); + + return ( + + + {DragPreview && isTableDraggable && + + {() => { + if (dragAndDropHooks.renderPreview) { + return dragAndDropHooks.renderPreview(dragState.draggingKeys, dragState.draggedKey); + } + let itemCount = dragState.draggingKeys.size; + let maxWidth = bodyRef.current.getBoundingClientRect().width; + let height = ROW_HEIGHTS[density][scale]; + let itemText = state.collection.getTextValue(dragState.draggedKey); + return ; + }} + + } + + ); +} + +// This is a custom Virtualizer that also has a header that syncs its scroll position with the body. +function TableVirtualizer(props) { + let {layout, collection, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; + let {direction} = useLocale(); + let loadingState = collection.body.props.loadingState; + let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + let onLoadMore = collection.body.props.onLoadMore; + let transitionDuration = 220; + if (isLoading) { + transitionDuration = 160; + } + if (layout.resizingColumn != null) { + // while resizing, prop changes should not cause animations + transitionDuration = 0; + } + let state = useVirtualizerState({ + layout, + collection, + renderView, + renderWrapper, + onVisibleRectChange(rect) { + bodyRef.current.scrollTop = rect.y; + setScrollLeft(bodyRef.current, direction, rect.x); + }, + transitionDuration + }); + + let scrollToItem = useCallback((key) => { + let item = collection.getItem(key); + let column = collection.columns[0]; + let virtualizer = state.virtualizer; + + virtualizer.scrollToItem(key, { + duration: 0, + // Prevent scrolling to the top when clicking on column headers. + shouldScrollY: item?.type !== 'column', + // Offset scroll position by width of selection cell + // (which is sticky and will overlap the cell we're scrolling to). + offsetX: column.props.isSelectionCell || column.props.isDragButtonCell + ? layout.getColumnWidth(column.key) + : 0 + }); + + // Sync the scroll positions of the column headers and the body so scrollIntoViewport can + // properly decide if the column is outside the viewport or not + headerRef.current.scrollLeft = bodyRef.current.scrollLeft; + }, [collection, bodyRef, headerRef, layout, state.virtualizer]); + + let memoedVirtualizerProps = useMemo(() => ({ + tabIndex: otherProps.tabIndex, + focusedKey, + scrollToItem, + isLoading, + onLoadMore + }), [otherProps.tabIndex, focusedKey, scrollToItem, isLoading, onLoadMore]); + + let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef); + + // this effect runs whenever the contentSize changes, it doesn't matter what the content size is + // only that it changes in a resize, and when that happens, we want to sync the body to the + // header scroll position + useEffect(() => { + if (getInteractionModality() === 'keyboard' && headerRef.current.contains(document.activeElement)) { + scrollIntoView(headerRef.current, document.activeElement as HTMLElement); + scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); + bodyRef.current.scrollLeft = headerRef.current.scrollLeft; + } + }, [state.contentSize, headerRef, bodyRef, domRef]); + + let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; + let visibleRect = state.virtualizer.visibleRect; + + // Sync the scroll position from the table body to the header container. + let onScroll = useCallback(() => { + headerRef.current.scrollLeft = bodyRef.current.scrollLeft; + }, [bodyRef, headerRef]); + + let resizerPosition = layout.getResizerPosition() - 2; + + let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; + // this should be fine, every movement of the resizer causes a rerender + // scrolling can cause it to lag for a moment, but it's always updated + let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.maxX; + let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; + + // minimize re-render caused on Resizers by memoing this + let resizingColumnWidth = layout.getColumnWidth(layout.resizingColumn); + let resizingColumn = useMemo(() => ({ + width: resizingColumnWidth, + key: layout.resizingColumn + }), [resizingColumnWidth, layout.resizingColumn]); + let mergedProps = mergeProps( + otherProps, + virtualizerProps, + isVirtualDragging && {tabIndex: null} + ); + + return ( + + +
+
+ {state.visibleViews[0]} +
+ + {state.visibleViews[1]} +
+ +
+ + + ); +} + +function TableHeader({children, ...otherProps}) { + let {rowGroupProps} = useTableRowGroup(); + + return ( +
+ {children} +
+ ); +} + +function TableColumnHeader(props) { + let {column} = props; + let ref = useRef(null); + let {state, isEmpty} = useTableContext(); + let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); + let columnProps = column.props as SpectrumColumnProps; + useEffect(() => { + if (column.hasChildNodes && columnProps.allowsResizing) { + console.warn(`Column key: ${column.key}. Columns with child columns don't allow resizing.`); + } + }, [column.hasChildNodes, column.key, columnProps.allowsResizing]); + + let {columnHeaderProps} = useTableColumnHeader({ + node: column, + isVirtualized: true + }, state, ref); + + let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty}); + + const allProps = [columnHeaderProps, hoverProps, pressProps]; + + return ( + +
1, + 'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end' + } + ) + ) + }> + {columnProps.allowsSorting && + + } + {columnProps.hideHeader ? + {column.rendered} : +
{column.rendered}
+ } +
+
+ ); +} + +let _TableColumnHeaderButton = (props, ref: FocusableRef) => { + let {focusProps, alignment, ...otherProps} = props; + let {isEmpty} = useTableContext(); + let domRef = useFocusableRef(ref); + let {buttonProps} = useButton({...otherProps, elementType: 'div', isDisabled: isEmpty}, domRef); + let {hoverProps, isHovered} = useHover({...otherProps, isDisabled: isEmpty}); + + return ( +
+
+ {props.children} +
+
+ ); +}; +let TableColumnHeaderButton = React.forwardRef(_TableColumnHeaderButton); + +function ResizableTableColumnHeader(props) { + let {column} = props; + let ref = useRef(null); + let triggerRef = useRef(null); + let resizingRef = useRef(null); + let { + state, + layout, + onResizeStart, + onResize, + onResizeEnd, + headerRowHovered, + setIsInResizeMode, + isEmpty, + onFocusedResizer, + isInResizeMode, + headerMenuOpen, + setHeaderMenuOpen + } = useTableContext(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); + let {columnHeaderProps} = useTableColumnHeader({ + node: column, + isVirtualized: true + }, state, ref); + + let {hoverProps, isHovered} = useHover({...props, isDisabled: isEmpty || headerMenuOpen}); + + const allProps = [columnHeaderProps, pressProps, hoverProps]; + + let columnProps = column.props as SpectrumColumnProps; + + let {isFocusVisible, focusProps} = useFocusRing(); + + const onMenuSelect = (key) => { + switch (key) { + case 'sort-asc': + state.sort(column.key, 'ascending'); + break; + case 'sort-desc': + state.sort(column.key, 'descending'); + break; + case 'resize': + layout.startResize(column.key); + setIsInResizeMode(true); + state.setKeyboardNavigationDisabled(true); + break; + } + }; + let allowsSorting = column.props?.allowsSorting; + let items = useMemo(() => { + let options = [ + allowsSorting ? { + label: stringFormatter.format('sortAscending'), + id: 'sort-asc' + } : undefined, + allowsSorting ? { + label: stringFormatter.format('sortDescending'), + id: 'sort-desc' + } : undefined, + { + label: stringFormatter.format('resizeColumn'), + id: 'resize' + } + ]; + return options; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowsSorting]); + let isMobile = useIsMobileDevice(); + + let resizingColumn = layout.resizingColumn; + let prevResizingColumn = useRef(null); + let timeout = useRef(null); + useEffect(() => { + if (prevResizingColumn.current !== resizingColumn && + resizingColumn != null && + resizingColumn === column.key) { + if (timeout.current) { + clearTimeout(timeout.current); + } + // focusSafely won't actually focus because the focus moves from the menuitem to the body during the after transition wait + // without the immediate timeout, Android Chrome doesn't move focus to the resizer + let focusResizer = () => { + resizingRef.current.focus(); + onFocusedResizer(); + timeout.current = null; + }; + if (isMobile) { + timeout.current = setTimeout(focusResizer, 400); + return; + } + timeout.current = setTimeout(focusResizer, 0); + } + prevResizingColumn.current = resizingColumn; + }, [resizingColumn, column.key, isMobile, onFocusedResizer, resizingRef, prevResizingColumn, timeout]); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => clearTimeout(timeout.current); + }, []); + + let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); + let alignment = 'start'; + let menuAlign = 'start' as 'start' | 'end'; + if (columnProps.align === 'center' || column.colspan > 1) { + alignment = 'center'; + } else if (columnProps.align === 'end') { + alignment = 'end'; + menuAlign = 'end'; + } + + return ( + +
+ + + {columnProps.allowsSorting && + + } + {columnProps.hideHeader ? + {column.rendered} : +
{column.rendered}
+ } + { + columnProps.allowsResizing && + } +
+ + {(item) => ( + + {item.label} + + )} + +
+ +
+
+ +
+
+
+
+ ); +} + +function TableSelectAllCell({column}) { + let ref = useRef(); + let {state} = useTableContext(); + let isSingleSelectionMode = state.selectionManager.selectionMode === 'single'; + let {columnHeaderProps} = useTableColumnHeader({ + node: column, + isVirtualized: true + }, state, ref); + + let {checkboxProps} = useTableSelectAllCheckbox(state); + let {hoverProps, isHovered} = useHover({}); + + return ( + +
+ { + /* + In single selection mode, the checkbox will be hidden. + So to avoid leaving a column header with no accessible content, + we use a VisuallyHidden component to include the aria-label from the checkbox, + which for single selection will be "Select." + */ + isSingleSelectionMode && + {checkboxProps['aria-label']} + } + +
+
+ ); +} + +function TableDragHeaderCell({column}) { + let ref = useRef(); + let {state} = useTableContext(); + let {columnHeaderProps} = useTableColumnHeader({ + node: column, + isVirtualized: true + }, state, ref); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + + return ( + +
+ {stringFormatter.format('drag')} +
+
+ ); +} + +function TableRowGroup({children, ...otherProps}) { + let {rowGroupProps} = useTableRowGroup(); + + return ( +
+ {children} +
+ ); +} + +function DragButton() { + let {dragButtonProps, dragButtonRef, isFocusVisibleWithin} = useTableRowContext(); + let {visuallyHiddenProps} = useVisuallyHidden(); + return ( + +
} + className={ + classNames( + stylesOverrides, + 'react-spectrum-Table-dragButton' + ) + } + style={!isFocusVisibleWithin ? {...visuallyHiddenProps.style} : {}} + ref={dragButtonRef} + draggable="true"> + +
+
+ ); +} + +interface TableRowContextValue { + dragButtonProps: React.HTMLAttributes, + dragButtonRef: React.MutableRefObject, + isFocusVisibleWithin: boolean +} + + +const TableRowContext = React.createContext(null); +export function useTableRowContext() { + return useContext(TableRowContext); +} + +function TableRow({item, children, hasActions, isTableDraggable, isTableDroppable, ...otherProps}) { + let ref = useRef(); + let {state, layout, dragAndDropHooks, dragState, dropState} = useTableContext(); + let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; + let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); + let isDroppable = isTableDroppable && !isDisabled; + let isSelected = state.selectionManager.isSelected(item.key); + let {rowProps} = useTableRow({ + node: item, + isVirtualized: true, + shouldSelectOnPressUp: isTableDraggable + }, state, ref); + + let {pressProps, isPressed} = usePress({isDisabled}); + + // The row should show the focus background style when any cell inside it is focused. + // If the row itself is focused, then it should have a blue focus indicator on the left. + let { + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); + let {isFocusVisible, focusProps} = useFocusRing(); + let {hoverProps, isHovered} = useHover({isDisabled}); + let isFirstRow = state.collection.rows.find(row => row.level === 1)?.key === item.key; + let isLastRow = item.nextKey == null; + // Figure out if the TableView content is equal or greater in height to the container. If so, we'll need to round the bottom + // border corners of the last row when selected. + let isFlushWithContainerBottom = false; + if (isLastRow) { + if (layout.getContentSize()?.height >= layout.virtualizer?.getVisibleRect().height) { + isFlushWithContainerBottom = true; + } + } + + let draggableItem: DraggableItemResult; + if (isTableDraggable) { + // eslint-disable-next-line react-hooks/rules-of-hooks + draggableItem = dragAndDropHooks.useDraggableItem({key: item.key, hasDragButton: true}, dragState); + if (isDisabled) { + draggableItem = null; + } + } + let droppableItem: DroppableItemResult; + let isDropTarget: boolean; + let dropIndicator: DropIndicatorAria; + let dropIndicatorRef = useRef(); + if (isTableDroppable) { + let target = {type: 'item', key: item.key, dropPosition: 'on'} as DropTarget; + isDropTarget = dropState.isDropTarget(target); + // eslint-disable-next-line react-hooks/rules-of-hooks + dropIndicator = dragAndDropHooks.useDropIndicator({target}, dropState, dropIndicatorRef); + } + + let dragButtonRef = React.useRef(); + let {buttonProps: dragButtonProps} = useButton({ + ...draggableItem?.dragButtonProps, + elementType: 'div' + }, dragButtonRef); + + let props = mergeProps( + rowProps, + otherProps, + focusWithinProps, + focusProps, + hoverProps, + pressProps, + draggableItem?.dragProps, + // Remove tab index from list row if performing a screenreader drag. This prevents TalkBack from focusing the row, + // allowing for single swipe navigation between row drop indicator + dragAndDropHooks?.isVirtualDragging() && {tabIndex: null} + ) as HTMLAttributes & DOMAttributes; + + let dropProps = isDroppable ? droppableItem?.dropProps : {'aria-hidden': droppableItem?.dropProps['aria-hidden']}; + let {visuallyHiddenProps} = useVisuallyHidden(); + + return ( + + {isTableDroppable && isFirstRow && + + } + {isTableDroppable && !dropIndicator?.isHidden && +
+
+
+
+
+ } +
+ {children} +
+ {isTableDroppable && + + } + + ); +} + +function TableHeaderRow({item, children, style, ...props}) { + let {state, headerMenuOpen} = useTableContext(); + let ref = useRef(); + let {rowProps} = useTableHeaderRow({node: item, isVirtualized: true}, state, ref); + let {hoverProps} = useHover({...props, isDisabled: headerMenuOpen}); + + return ( +
+ {children} +
+ ); +} + +function TableDragCell({cell}) { + let ref = useRef(); + let {state, isTableDraggable} = useTableContext(); + let isDisabled = state.disabledKeys.has(cell.parentKey); + let {gridCellProps} = useTableCell({ + node: cell, + isVirtualized: true + }, state, ref); + + + return ( + +
+ {isTableDraggable && !isDisabled && } +
+
+ ); +} + +function TableCheckboxCell({cell}) { + let ref = useRef(); + let {state} = useTableContext(); + let isDisabled = state.disabledKeys.has(cell.parentKey); + let {gridCellProps} = useTableCell({ + node: cell, + isVirtualized: true + }, state, ref); + + let {checkboxProps} = useTableSelectionCheckbox({key: cell.parentKey}, state); + + return ( + +
+ {state.selectionManager.selectionMode !== 'none' && + + } +
+
+ ); +} + +function TableCell({cell}) { + let {scale} = useProvider(); + let {state} = useTableContext(); + let isExpandableTable = 'expandedKeys' in state; + let ref = useRef(); + let columnProps = cell.column.props as SpectrumColumnProps; + let isDisabled = state.disabledKeys.has(cell.parentKey); + let {gridCellProps} = useTableCell({ + node: cell, + isVirtualized: true + }, state, ref); + let {id, ...otherGridCellProps} = gridCellProps; + let isFirstRowHeaderCell = state.collection.rowHeaderColumnKeys.keys().next().value === cell.column.key; + let isRowExpandable = false; + let showExpandCollapseButton = false; + let levelOffset = 0; + + if ('expandedKeys' in state) { + isRowExpandable = state.keyMap.get(cell.parentKey)?.props.UNSTABLE_childItems?.length > 0 || state.keyMap.get(cell.parentKey)?.props?.children?.length > state.userColumnCount; + showExpandCollapseButton = isFirstRowHeaderCell && isRowExpandable; + // Offset based on level, and add additional offset if there is no expand/collapse button on a row + levelOffset = (cell.level - 2) * LEVEL_OFFSET_WIDTH[scale] + (!showExpandCollapseButton ? LEVEL_OFFSET_WIDTH[scale] * 2 : 0); + } + + return ( + +
+ {showExpandCollapseButton && } + + {cell.rendered} + +
+
+ ); +} + +function ExpandableRowChevron({cell}) { + // TODO: move some/all of the chevron button setup into a separate hook? + let {direction} = useLocale(); + let {state} = useTableContext(); + let expandButtonRef = useRef(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let isExpanded; + + if ('expandedKeys' in state) { + isExpanded = state.expandedKeys === 'all' || state.expandedKeys.has(cell.parentKey); + } + + // Will need to keep the chevron as a button for iOS VO at all times since VO doesn't focus the cell. Also keep as button if cellAction is defined by the user in the future + let {buttonProps} = useButton({ + // Desktop and mobile both toggle expansion of a native expandable row on mouse/touch up + onPress: () => { + (state as TreeGridState).toggleKey(cell.parentKey); + if (!isFocusVisible()) { + state.selectionManager.setFocused(true); + state.selectionManager.setFocusedKey(cell.parentKey); + } + }, + elementType: 'span', + 'aria-label': isExpanded ? stringFormatter.format('collapse') : stringFormatter.format('expand') + }, expandButtonRef); + + return ( + + {direction === 'ltr' ? : } + + ); +} + +function CenteredWrapper({children}) { + let {state} = useTableContext(); + let rowProps; + + if ('expandedKeys' in state) { + let topLevelRowCount = [...state.keyMap.get(state.collection.body.key).childNodes].length; + rowProps = { + 'aria-level': 1, + 'aria-posinset': topLevelRowCount + 1, + 'aria-setsize': topLevelRowCount + 1 + }; + } else { + rowProps = { + 'aria-rowindex': state.collection.headerRows.length + state.collection.size + 1 + }; + } + + return ( +
+
+ {children} +
+
+ ); +} + +const _TableViewBase = React.forwardRef(TableViewBase) as (props: TableBaseProps & {ref?: DOMRef}) => ReactElement; + +export {_TableViewBase as TableViewBase}; diff --git a/packages/@react-spectrum/table/src/TableViewWrapper.tsx b/packages/@react-spectrum/table/src/TableViewWrapper.tsx new file mode 100644 index 00000000000..6d26e52e5d3 --- /dev/null +++ b/packages/@react-spectrum/table/src/TableViewWrapper.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type {AriaLabelingProps, DOMProps, DOMRef, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; +import type {ColumnSize, TableProps} from '@react-types/table'; +import type {DragAndDropHooks} from '@react-spectrum/dnd'; +import React, {Key, ReactElement} from 'react'; +import {tableNestedRows} from '@react-stately/flags'; +import {TableView} from './TableView'; +import {TreeGridTableView} from './TreeGridTableView'; + +export interface SpectrumTableProps extends TableProps, SpectrumSelectionProps, DOMProps, AriaLabelingProps, StyleProps { + /** + * Sets the amount of vertical padding within each cell. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** + * Sets the overflow behavior for the cell contents. + * @default 'truncate' + */ + overflowMode?: 'wrap' | 'truncate', + /** Whether the TableView should be displayed with a quiet style. */ + isQuiet?: boolean, + /** Sets what the TableView should render when there is no content to display. */ + renderEmptyState?: () => JSX.Element, + /** Handler that is called when a user performs an action on a row. */ + onAction?: (key: Key) => void, + /** + * Handler that is called when a user starts a column resize. + */ + onResizeStart?: (widths: Map) => void, + /** + * Handler that is called when a user performs a column resize. + * Can be used with the width property on columns to put the column widths into + * a controlled state. + */ + onResize?: (widths: Map) => void, + /** + * Handler that is called after a user performs a column resize. + * Can be used to store the widths of columns for another future session. + */ + onResizeEnd?: (widths: Map) => void, + /** + * The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the TableView. + * @version beta + */ + dragAndDropHooks?: DragAndDropHooks['dragAndDropHooks'], + /** + * Whether the TableView should support expandable rows. Requires the feature flag to be enabled first, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. + * @version alpha + * @private + */ + UNSTABLE_allowsExpandableRows?: boolean, + /** + * The currently expanded keys in the collection (controlled). Requires the feature flag to be + * enabled along with UNSTABLE_allowsExpandableRows, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. + * @version alpha + * @private + */ + UNSTABLE_expandedKeys?: 'all' | Iterable, + /** + * The initial expanded keys in the collection (uncontrolled). Requires the feature flag to be + * enabled along with UNSTABLE_allowsExpandableRows, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. + * @version alpha + * @private + */ + UNSTABLE_defaultExpandedKeys?: 'all' | Iterable, + /** + * Handler that is called when items are expanded or collapsed. Requires the feature flag to be + * enabled along with UNSTABLE_allowsExpandableRows, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. + * @version alpha + * @private + */ + UNSTABLE_onExpandedChange?: (keys: Set) => any +} + +function TableViewWrapper(props: SpectrumTableProps, ref: DOMRef) { + let {UNSTABLE_allowsExpandableRows, ...otherProps} = props; + if (tableNestedRows() && UNSTABLE_allowsExpandableRows) { + return ; + } else { + return ; + } +} + +/** + * Tables are containers for displaying information. They allow users to quickly scan, sort, compare, and take action on large amounts of data. + */ +const _TableViewWrapper = React.forwardRef(TableViewWrapper) as (props: SpectrumTableProps & {ref?: DOMRef}) => ReactElement; + +export {_TableViewWrapper as TableView}; diff --git a/packages/@react-spectrum/table/src/TreeGridTableView.tsx b/packages/@react-spectrum/table/src/TreeGridTableView.tsx new file mode 100644 index 00000000000..752bcfb6cb5 --- /dev/null +++ b/packages/@react-spectrum/table/src/TreeGridTableView.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {DOMRef} from '@react-types/shared'; +import React, {ReactElement, useState} from 'react'; +import {SpectrumTableProps} from './TableViewWrapper'; +import {TableViewBase} from './TableViewBase'; +import {UNSTABLE_useTreeGridState} from '@react-stately/table'; + +export interface TreeGridTableProps extends Omit, 'UNSTABLE_allowsExpandableRows'> {} + +function TreeGridTableView(props: TreeGridTableProps, ref: DOMRef) { + let { + selectionStyle, + dragAndDropHooks + } = props; + let [showSelectionCheckboxes, setShowSelectionCheckboxes] = useState(selectionStyle !== 'highlight'); + let isTableDraggable = !!dragAndDropHooks?.useDraggableCollectionState; + let state = UNSTABLE_useTreeGridState({ + ...props, + showSelectionCheckboxes, + showDragButtons: isTableDraggable, + selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' + }); + + // If the selection behavior changes in state, we need to update showSelectionCheckboxes here due to the circular dependency... + let shouldShowCheckboxes = state.selectionManager.selectionBehavior !== 'replace'; + if (shouldShowCheckboxes !== showSelectionCheckboxes) { + setShowSelectionCheckboxes(shouldShowCheckboxes); + } + + return ( + + ); +} + +const _TreeGridTableView = React.forwardRef(TreeGridTableView) as (props: TreeGridTableProps & {ref?: DOMRef}) => ReactElement; +export {_TreeGridTableView as TreeGridTableView}; diff --git a/packages/@react-spectrum/table/src/index.ts b/packages/@react-spectrum/table/src/index.ts index c12ec07503e..f05623fa0c1 100644 --- a/packages/@react-spectrum/table/src/index.ts +++ b/packages/@react-spectrum/table/src/index.ts @@ -12,7 +12,7 @@ /// -export {TableView} from './TableView'; +export {TableView} from './TableViewWrapper'; import {Column} from '@react-stately/table'; import {SpectrumColumnProps} from '@react-types/table'; @@ -29,4 +29,4 @@ export { } from '@react-stately/table'; export type {SpectrumColumnProps, TableHeaderProps, TableBodyProps, RowProps, CellProps} from '@react-types/table'; -export type {SpectrumTableProps} from './TableView'; +export type {SpectrumTableProps} from './TableViewWrapper'; diff --git a/packages/@react-spectrum/table/src/table.css b/packages/@react-spectrum/table/src/table.css index 1f9c8964efe..c2c2475b88c 100644 --- a/packages/@react-spectrum/table/src/table.css +++ b/packages/@react-spectrum/table/src/table.css @@ -34,6 +34,13 @@ .react-spectrum-Table-cell { display: flex; align-items: center; + + &.react-spectrum-Table-cell--hasExpandCollapseButton { + display: grid; + grid-template-columns: var(--spectrum-global-dimension-size-400) 1fr; + grid-template-rows: 1fr; + grid-template-areas: "expand-button contents"; + } } .react-spectrum-Table-cellWrapper > .react-spectrum-Table-cell { diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 582f71facaa..d2214bae50d 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -41,6 +41,7 @@ import {useFilter} from '@react-aria/i18n'; export default { title: 'TableView', + excludeStories: ['columns', 'renderEmptyState', 'EmptyStateTable'], component: TableView, args: { onAction: action('onAction'), @@ -152,7 +153,7 @@ export const Static: TableStory = { name: 'static' }; -let columns = [ +export let columns = [ {name: 'Foo', key: 'foo'}, {name: 'Bar', key: 'bar'}, {name: 'Baz', key: 'baz'} @@ -323,12 +324,14 @@ export const StaticNestedColumns: TableStory = { Test - - Foo - Bar - - - Baz + + + Foo + Bar + + + Baz + @@ -374,7 +377,7 @@ export const DynamicNestedColumns: TableStory = { {column => - {column.name} + {column.name} } @@ -876,21 +879,22 @@ function renderEmptyState() { ); } -function EmptyStateTable(props) { +export function EmptyStateTable(props) { + let {items, columns, allowsSorting, ...otherProps} = props; let [show, setShow] = useState(false); let [sortDescriptor, setSortDescriptor] = useState({}); return ( setShow(show => !show)}>Toggle items - - - {column => - {column.name} + + + {(column: any) => + {column.name} } - - {item => - ( + + {(item: any) => + ( {key => {item[key]}} ) } diff --git a/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx b/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx new file mode 100644 index 00000000000..8f0de178188 --- /dev/null +++ b/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx @@ -0,0 +1,326 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from '@storybook/addon-actions'; +import {ActionButton} from '@react-spectrum/button'; +import {Cell, Column, Row, SpectrumTableProps, TableBody, TableHeader, TableView} from '../'; +import {chain} from '@react-aria/utils'; +import {ComponentMeta} from '@storybook/react'; +import defaultConfig, {columns, EmptyStateTable, TableStory} from './Table.stories'; +import {enableTableNestedRows} from '@react-stately/flags'; +import {Flex} from '@react-spectrum/layout'; +import React, {Key, useState} from 'react'; + +enableTableNestedRows(); + +export default { + ...defaultConfig, + title: 'TableView/Expandable rows' +} as ComponentMeta; + +export const StaticExpandableRows: TableStory = { + args: { + 'aria-label': 'TableView with static expandable rows', + width: 500, + height: 200 + }, + render: (args) => ( + + + Foo + Bar + Baz + + + + Lvl 1 Foo 1 + Lvl 1 Bar 1 + Lvl 1 Baz 1 + + Lvl 2 Foo 1 + Lvl 2 Bar 1 + Lvl 2 Baz 1 + + Lvl 3 Foo 1 + Lvl 3 Bar 1 + Lvl 3 Baz 1 + + + + Lvl 2 Foo 2 + Lvl 2 Bar 2 + Lvl 2 Baz 2 + + + + + ), + name: 'static with expandable rows' +}; + +let nestedItems = [ + {foo: 'Lvl 1 Foo 1', bar: 'Lvl 1 Bar 1', baz: 'Lvl 1 Baz 1', childRows: [ + {foo: 'Lvl 2 Foo 1', bar: 'Lvl 2 Bar 1', baz: 'Lvl 2 Baz 1', childRows: [ + {foo: 'Lvl 3 Foo 1', bar: 'Lvl 3 Bar 1', baz: 'Lvl 3 Baz 1'} + ]}, + {foo: 'Lvl 2 Foo 2', bar: 'Lvl 2 Bar 2', baz: 'Lvl 2 Baz 2'} + ]} +]; + +function DynamicExpandableRows(props: SpectrumTableProps) { + let [expandedKeys, setExpandedKeys] = useState<'all' | Set>('all'); + + return ( + + setExpandedKeys('all')}>Expand all + setExpandedKeys(new Set([]))}>Collapse all + setExpandedKeys(new Set(['Lvl 1 Foo 1']))}>Set expanded to Lvl 1 Foo 1 + + + {column => {column.name}} + + + {item => + ( + {(key) => { + // Note: The "item" here will reflect the child Row's values from nestedItems + return {item[key]}; + }} + ) + } + + + + ); +} + +export const DynamicExpandableRowsStory: TableStory = { + args: { + 'aria-label': 'TableView with dynamic expandable rows', + width: 500, + height: 400 + }, + render: (args) => ( + + ), + name: 'dynamic with expandable rows' +}; + +export const UserSetRowHeader: TableStory = { + args: { + 'aria-label': 'TableView with expandable rows and multiple row headers', + width: 500, + height: 400 + }, + render: (args) => ( + + + Foo + Bar + Baz + + + + Lvl 1 Foo 1 + Lvl 1 Bar 1 + Lvl 1 Baz 1 + + Lvl 2 Foo 1 + Lvl 2 Bar 1 + Lvl 2 Baz 1 + + Lvl 3 Foo 1 + Lvl 3 Bar 1 + Lvl 3 Baz 1 + + + + Lvl 2 Foo 2 + Lvl 2 Bar 2 + Lvl 2 Baz 2 + + + + + ), + name: 'multiple user set row headers', + parameters: { + description: { + data: 'Row headers are Bar and Baz column cells, chevron' + } + } +}; + +let manyRows = []; +function generateRow(lvlIndex, lvlLimit, rowIndex) { + let row = {key: `Row ${rowIndex} Lvl ${lvlIndex}`}; + for (let col of columns) { + row[col.key] = `Row ${rowIndex}, Lvl ${lvlIndex}, ${col.name}`; + } + + if (lvlIndex < lvlLimit) { + row['childRows'] = [generateRow(++lvlIndex, lvlLimit, rowIndex)]; + } + return row; +} + +for (let i = 1; i < 20; i++) { + let row = generateRow(1, 3, i); + manyRows.push(row); +} + +interface ManyExpandableRowsProps extends SpectrumTableProps { + allowsResizing?: boolean, + showDivider?: boolean +} + +function ManyExpandableRows(props: ManyExpandableRowsProps) { + let {allowsResizing, showDivider, ...otherProps} = props; + let [expandedKeys, setExpandedKeys] = useState<'all' | Set>('all'); + + return ( + + setExpandedKeys('all')}>Expand all + setExpandedKeys(new Set([]))}>Collapse all + + + {column => {column.name}} + + + {item => + ( + {(key) => { + return {item[key]}; + }} + ) + } + + + + ); +} + +export const ManyExpandableRowsStory: TableStory = { + args: { + 'aria-label': 'TableView with many dynamic expandable rows', + width: 500, + height: 400 + }, + render: (args) => ( + + ), + name: 'many expandable rows' +}; + +export const EmptyTreeGridStory: TableStory = { + args: { + 'aria-label': 'TableView with empty state', + width: 500, + height: 400 + }, + render: (args) => ( + + ), + name: 'empty state' +}; + +function LoadingStateTable(props) { + let [show, setShow] = useState(false); + + return ( + + setShow(show => !show)}>Toggle items + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + + ); +} + +export const LoadingTreeGridStory: TableStory = { + args: { + 'aria-label': 'TableView with loading', + width: 500, + height: 400 + }, + render: (args) => ( + + ), + name: 'isLoading' +}; + +export const NestedColumnsStory: TableStory = { + args: { + 'aria-label': 'TableView with nested columns', + width: 500, + height: 400 + }, + render: (args) => ( + + + + + Foo + Bar + + + Baz + + + + + + Lvl 1 Foo 1 + Lvl 1 Bar 1 + Lvl 1 Baz 1 + + Lvl 2 Foo 1 + Lvl 2 Bar 1 + Lvl 2 Baz 1 + + Lvl 3 Foo 1 + Lvl 3 Bar 1 + Lvl 3 Baz 1 + + + + Lvl 2 Foo 2 + Lvl 2 Bar 2 + Lvl 2 Baz 2 + + + + + ), + name: 'with nested columns' +}; + +export const ResizableColumnsStory: TableStory = { + args: { + 'aria-label': 'TableView with many dynamic expandable rows and resizable columns', + width: 500, + height: 400 + }, + render: (args) => ( + + ), + name: 'resizable columns' +}; diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 94021715952..ee5fe6ccead 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -22,6 +22,7 @@ import {Content} from '@react-spectrum/view'; import {CRUDExample} from '../stories/CRUDExample'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import {Divider} from '@react-spectrum/divider'; +import {enableTableNestedRows} from '@react-stately/flags'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {Heading} from '@react-spectrum/text'; import {Link} from '@react-spectrum/link'; @@ -135,7 +136,7 @@ function pointerEvent(type, opts) { return evt; } -describe('TableView', function () { +export let tableTests = () => { let offsetWidth, offsetHeight; beforeAll(function () { @@ -243,17 +244,19 @@ describe('TableView', function () { expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); expect(rowheader).toHaveTextContent('Foo 1'); expect(rowheader).toHaveAttribute('aria-colindex', '1'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); expect(rowheader).toHaveTextContent('Foo 2'); expect(rowheader).toHaveAttribute('aria-colindex', '1'); expect(rows[1]).not.toHaveAttribute('aria-selected'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); let cells = within(rowgroups[1]).getAllByRole('gridcell'); @@ -328,27 +331,29 @@ describe('TableView', function () { expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); expect(rowheader).toHaveTextContent('Foo 1'); expect(rowheader).toHaveAttribute('aria-colindex', '2'); expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); checkbox = within(rows[0]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); expect(rowheader).toHaveTextContent('Foo 2'); expect(rowheader).toHaveAttribute('aria-colindex', '2'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); checkbox = within(rows[1]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); let cells = within(rowgroups[1]).getAllByRole('gridcell'); expect(cells).toHaveLength(6); @@ -430,17 +435,19 @@ describe('TableView', function () { expect(rows).toHaveLength(2); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); expect(rowheader).toHaveTextContent('Foo 1'); expect(rowheader).toHaveAttribute('aria-colindex', '1'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); expect(rowheader).toHaveTextContent('Foo 2'); expect(rowheader).toHaveAttribute('aria-colindex', '1'); expect(rows[1]).not.toHaveAttribute('aria-selected'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); let cells = within(rowgroups[1]).getAllByRole('gridcell'); expect(cells).toHaveLength(4); @@ -498,27 +505,29 @@ describe('TableView', function () { expect(rows).toHaveLength(2); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); expect(rowheader).toHaveTextContent('Foo 1'); expect(rowheader).toHaveAttribute('aria-colindex', '2'); expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); checkbox = within(rows[0]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); expect(rowheader).toHaveTextContent('Foo 2'); expect(rowheader).toHaveAttribute('aria-colindex', '2'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); checkbox = within(rows[1]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); let cells = within(rowgroups[1]).getAllByRole('gridcell'); expect(cells).toHaveLength(6); @@ -649,27 +658,29 @@ describe('TableView', function () { expect(rows).toHaveLength(2); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Test 1'); expect(rowheader).toHaveTextContent('Test 1'); expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); expect(rows[0]).toHaveAttribute('aria-rowindex', '3'); checkbox = within(rows[0]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Test 2'); expect(rowheader).toHaveTextContent('Test 2'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); expect(rows[1]).toHaveAttribute('aria-rowindex', '4'); checkbox = within(rows[1]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); let cells = within(rowgroups[1]).getAllByRole('gridcell'); expect(cells).toHaveLength(8); @@ -752,27 +763,29 @@ describe('TableView', function () { expect(rows).toHaveLength(2); let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Test 1'); expect(rowheader).toHaveTextContent('Test 1'); expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); expect(rows[0]).toHaveAttribute('aria-rowindex', '4'); checkbox = within(rows[0]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Test 2'); expect(rowheader).toHaveTextContent('Test 2'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', rowheader.id); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); expect(rows[1]).toHaveAttribute('aria-rowindex', '5'); checkbox = within(rows[1]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheader.id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); let cells = within(rowgroups[1]).getAllByRole('gridcell'); expect(cells).toHaveLength(10); @@ -809,23 +822,27 @@ describe('TableView', function () { expect(rowheaders).toHaveLength(2); expect(rowheaders[0]).toHaveTextContent('Sam'); expect(rowheaders[1]).toHaveTextContent('Smith'); + let firstCellSpan = within(rowheaders[0]).getByText('Sam'); + let secondCellSpan = within(rowheaders[1]).getByText('Smith'); - expect(rows[0]).toHaveAttribute('aria-labelledby', `${rowheaders[0].id} ${rowheaders[1].id}`); + expect(rows[0]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); let checkbox = within(rows[0]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheaders[0].id} ${rowheaders[1].id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); rowheaders = within(rows[1]).getAllByRole('rowheader'); expect(rowheaders).toHaveLength(2); expect(rowheaders[0]).toHaveTextContent('Julia'); expect(rowheaders[1]).toHaveTextContent('Jones'); + firstCellSpan = within(rowheaders[0]).getByText('Julia'); + secondCellSpan = within(rowheaders[1]).getByText('Jones'); - expect(rows[1]).toHaveAttribute('aria-labelledby', `${rowheaders[0].id} ${rowheaders[1].id}`); + expect(rows[1]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); checkbox = within(rows[1]).getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${rowheaders[0].id} ${rowheaders[1].id}`); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); }); describe('keyboard focus', function () { @@ -1826,6 +1843,10 @@ describe('TableView', function () { }); describe('selection', function () { + afterEach(() => { + act(() => jest.runAllTimers()); + }); + let renderJSX = (props, items = manyItems) => ( @@ -4503,4 +4524,14 @@ describe('TableView', function () { expect(table).toHaveAttribute('tabIndex', '0'); }); }); +}; + +describe('TableView', tableTests); + +describe('TableView with expandable rows flag on', function () { + beforeAll(() => { + enableTableNestedRows(); + }); + + tableTests(); }); diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx new file mode 100644 index 00000000000..042f78b7c62 --- /dev/null +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -0,0 +1,1512 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +jest.mock('@react-aria/live-announcer'); +import {act, fireEvent, installPointerEvent, render as renderComponent, triggerPress, within} from '@react-spectrum/test-utils'; +import {announce} from '@react-aria/live-announcer'; +import {composeStories} from '@storybook/testing-react'; +import {enableTableNestedRows} from '@react-stately/flags'; +import {Provider} from '@react-spectrum/provider'; +import React from 'react'; +import {Scale} from '@react-types/provider'; +import * as stories from '../stories/TreeGridTable.stories'; +import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; + +let { + StaticExpandableRows: StaticExpandableTable, + DynamicExpandableRowsStory: DynamicExpandableTable, + ManyExpandableRowsStory: ManyRowsExpandableTable, + EmptyTreeGridStory: EmptyStateTable, + LoadingTreeGridStory: LoadingTable, + UserSetRowHeader: UserSetRowHeaderTable +} = composeStories(stories); + +let onSelectionChange = jest.fn(); +let onExpandedChange = jest.fn(); +let onAction = jest.fn(); + +let getCell = (tree, text) => { + // Find by text, then go up to the element with the cell role. + let el = tree.getByText(text); + while (el && !/gridcell|rowheader|columnheader/.test(el.getAttribute('role'))) { + el = el.parentElement; + } + + return el; +}; + +let focusCell = (tree, text) => act(() => getCell(tree, text).focus()); +let moveFocus = (key, opts = {}) => { + fireEvent.keyDown(document.activeElement, {key, ...opts}); + fireEvent.keyUp(document.activeElement, {key, ...opts}); +}; + +let render = (children, scale = 'medium' as Scale, locale = 'en-US') => { + let tree = renderComponent( + + {children} + + ); + + act(() => {jest.runAllTimers();}); + return tree; +}; + +let rerender = (tree, children, scale = 'medium' as Scale) => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; +}; + +describe('TableView with expandable rows', function () { + beforeAll(function () { + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.useFakeTimers(); + enableTableNestedRows(); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => {jest.runAllTimers();}); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it.each` + Name | Component + ${'static'} | ${StaticExpandableTable} + ${'dynamic'} | ${DynamicExpandableTable} + `('renders a $Name expandable rows table', ({Component}) => { + let {getByRole} = render(); + + let treegrid = getByRole('treegrid'); + expect(treegrid).toBeVisible(); + + expect(treegrid).toHaveAttribute('aria-rowcount', '5'); + expect(treegrid).toHaveAttribute('aria-colcount', '3'); + + let rowgroups = within(treegrid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(1); + expect(headerRows[0]).not.toHaveAttribute('aria-rowindex'); + + let headers = within(treegrid).getAllByRole('columnheader'); + expect(headers).toHaveLength(3); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + + for (let header of headers) { + expect(header).not.toHaveAttribute('aria-sort'); + expect(header).not.toHaveAttribute('aria-describedby'); + } + + expect(headers[0]).toHaveTextContent('Foo'); + expect(headers[1]).toHaveTextContent('Bar'); + expect(headers[2]).toHaveTextContent('Baz'); + + // First row + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(4); + let row = rows[0]; + expect(row).toHaveAttribute('aria-expanded', 'true'); + expect(row).toHaveAttribute('aria-level', '1'); + expect(row).toHaveAttribute('aria-posinset', '1'); + expect(row).toHaveAttribute('aria-setsize', '1'); + expect(row).not.toHaveAttribute('aria-rowindex'); + + let rowheader = within(row).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Lvl 1 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + let cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(2); + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + + // first child Row + row = rows[1]; + expect(row).toHaveAttribute('aria-expanded', 'true'); + expect(row).toHaveAttribute('aria-level', '2'); + expect(row).toHaveAttribute('aria-posinset', '1'); + expect(row).toHaveAttribute('aria-setsize', '2'); + expect(row).not.toHaveAttribute('aria-rowindex'); + + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 2 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 2 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(2); + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + + // child of child Row + row = rows[2]; + expect(row).not.toHaveAttribute('aria-expanded'); + expect(row).toHaveAttribute('aria-level', '3'); + expect(row).toHaveAttribute('aria-posinset', '1'); + expect(row).toHaveAttribute('aria-setsize', '1'); + expect(row).not.toHaveAttribute('aria-rowindex'); + + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 3 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 3 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(2); + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + + // 2nd child Row of original top level row + row = rows[3]; + expect(row).not.toHaveAttribute('aria-expanded'); + expect(row).toHaveAttribute('aria-level', '2'); + expect(row).toHaveAttribute('aria-posinset', '2'); + expect(row).toHaveAttribute('aria-setsize', '2'); + expect(row).not.toHaveAttribute('aria-rowindex'); + + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 2 Foo 2'); + expect(rowheader).toHaveTextContent('Lvl 2 Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(2); + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + }); + + it.each` + Name | Component + ${'static'} | ${StaticExpandableTable} + ${'dynamic'} | ${DynamicExpandableTable} + `('renders a $Name expandable rows table with selection', ({Component}) => { + let {getByRole} = render(); + + let treegrid = getByRole('treegrid'); + expect(treegrid).toHaveAttribute('aria-multiselectable', 'true'); + expect(treegrid).toHaveAttribute('aria-colcount', '4'); + + let rowgroups = within(treegrid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headers = within(treegrid).getAllByRole('columnheader'); + expect(headers).toHaveLength(4); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + expect(headers[3]).toHaveAttribute('aria-colindex', '4'); + + let checkbox = within(headers[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select All'); + + // First row + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(4); + let row = rows[0]; + + let rowheader = within(row).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Lvl 1 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + checkbox = within(row).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + let cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(3); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + + // first child Row + row = rows[1]; + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 2 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 2 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + checkbox = within(row).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(3); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + + // child of child Row + row = rows[2]; + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 3 Foo 1'); + expect(rowheader).toHaveTextContent('Lvl 3 Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(3); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + + // 2nd child Row of original top level row + row = rows[3]; + rowheader = within(row).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Lvl 2 Foo 2'); + expect(rowheader).toHaveTextContent('Lvl 2 Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + expect(row).toHaveAttribute('aria-labelledby', cellSpan.id); + + cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(3); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + }); + + it('shouldn\'t render a child row if its parent isn\'t included in the expanded keys', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(1); + expect(rows[0]).toContainElement(getCell(treegrid, 'Lvl 1 Foo 1')); + rerender(treegrid, ); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(4); + }); + + it('should place the chevron cell on the first row header', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + for (let i = 0; i < 2; i++) { + let row = rows[i]; + let rowheaders = within(row).getAllByRole('rowheader'); + expect(rowheaders).toHaveLength(2); + let rowheader = rowheaders[0]; + let chevron = within(rowheader).getByRole('button'); + expect(chevron).toBeTruthy(); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + } + }); + + describe('collapsing and expanding rows', function () { + describe('with press', function () { + it('should expand a row when pressing the chevron', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(3); + let thirdRow = rows[2]; + expect(thirdRow).toHaveAttribute('aria-level', '2'); + expect(thirdRow).toHaveAttribute('aria-posinset', '2'); + expect(thirdRow).toHaveAttribute('aria-setsize', '2'); + expect(thirdRow).toHaveTextContent('Lvl 2 Foo 2'); + + let rowToExpand = rows[1]; + expect(rowToExpand).toHaveAttribute('aria-expanded', 'false'); + let chevron = within(rowToExpand).getByRole('button'); + expect(chevron).toBeTruthy(); + expect(chevron).toHaveAttribute('aria-label', 'Expand'); + triggerPress(chevron); + act(() => jest.runAllTimers()); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set(['child row 1 level 2', 'row 1'])); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + expect(rows).toHaveLength(4); + rowToExpand = rows[1]; + expect(rowToExpand).toHaveAttribute('aria-expanded', 'true'); + + thirdRow = rows[2]; + expect(thirdRow).toHaveAttribute('aria-level', '3'); + expect(thirdRow).toHaveAttribute('aria-posinset', '1'); + expect(thirdRow).toHaveAttribute('aria-setsize', '1'); + expect(thirdRow).toHaveTextContent('Lvl 3 Foo 1'); + + let fourthRow = rows[3]; + expect(fourthRow).toHaveAttribute('aria-level', '2'); + expect(fourthRow).toHaveAttribute('aria-posinset', '2'); + expect(fourthRow).toHaveAttribute('aria-setsize', '2'); + expect(fourthRow).toHaveTextContent('Lvl 2 Foo 2'); + }); + + it('should collapse a row when pressing the chevron', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(3); + + let rowToCollapse = rows[0]; + expect(rowToCollapse).toHaveAttribute('aria-level', '1'); + expect(rowToCollapse).toHaveAttribute('aria-posinset', '1'); + expect(rowToCollapse).toHaveAttribute('aria-setsize', '1'); + expect(rowToCollapse).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowToCollapse).toHaveAttribute('aria-expanded', 'true'); + let chevron = within(rowToCollapse).getByRole('button'); + expect(chevron).toBeTruthy(); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + triggerPress(chevron); + act(() => jest.runAllTimers()); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set()); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(chevron).toHaveAttribute('aria-label', 'Expand'); + expect(rows).toHaveLength(1); + rowToCollapse = rows[0]; + expect(rowToCollapse).toHaveAttribute('aria-level', '1'); + expect(rowToCollapse).toHaveAttribute('aria-posinset', '1'); + expect(rowToCollapse).toHaveAttribute('aria-setsize', '1'); + expect(rowToCollapse).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowToCollapse).toHaveAttribute('aria-expanded', 'false'); + }); + }); + + describe('with keyboard', function () { + it.each` + Arrow | Locale + ${'ArrowRight'} | ${'en-US'} + ${'ArrowLeft'} | ${'ar-AE'} + `('should expand a row via $Arrow if focus is on the row ($Locale)', ({Arrow, Locale}) => { + let treegrid = render(, undefined, Locale); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(3); + let thirdRow = rows[2]; + expect(thirdRow).toHaveAttribute('aria-level', '2'); + expect(thirdRow).toHaveAttribute('aria-posinset', '2'); + expect(thirdRow).toHaveAttribute('aria-setsize', '2'); + expect(thirdRow).toHaveTextContent('Lvl 2 Foo 2'); + + let rowToExpand = rows[1]; + expect(rowToExpand).toHaveAttribute('aria-expanded', 'false'); + let chevron = within(rowToExpand).getByRole('button'); + expect(chevron).toBeTruthy(); + expect(chevron).toHaveAttribute('aria-label', 'Expand'); + + focusCell(treegrid, 'Lvl 2 Foo 1'); + moveFocus(Arrow); + act(() => jest.runAllTimers()); + expect(onExpandedChange).not.toHaveBeenCalled(); + + act(() => {rowToExpand.focus();}); + moveFocus(Arrow); + act(() => jest.runAllTimers()); + + expect(document.activeElement).toBe(rowToExpand); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set(['child row 1 level 2', 'row 1'])); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + expect(rows).toHaveLength(4); + rowToExpand = rows[1]; + expect(rowToExpand).toHaveAttribute('aria-expanded', 'true'); + + thirdRow = rows[2]; + expect(thirdRow).toHaveAttribute('aria-level', '3'); + expect(thirdRow).toHaveAttribute('aria-posinset', '1'); + expect(thirdRow).toHaveAttribute('aria-setsize', '1'); + expect(thirdRow).toHaveTextContent('Lvl 3 Foo 1'); + + let fourthRow = rows[3]; + expect(fourthRow).toHaveAttribute('aria-level', '2'); + expect(fourthRow).toHaveAttribute('aria-posinset', '2'); + expect(fourthRow).toHaveAttribute('aria-setsize', '2'); + expect(fourthRow).toHaveTextContent('Lvl 2 Foo 2'); + }); + + it.each` + Arrow | Locale + ${'ArrowLeft'} | ${'en-US'} + ${'ArrowRight'} | ${'ar-AE'} + `('should collapse a row via $Arrow if focus is on the row ($Locale)', ({Arrow, Locale}) => { + let treegrid = render(, undefined, Locale); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(3); + + let rowToCollapse = rows[0]; + expect(rowToCollapse).toHaveAttribute('aria-level', '1'); + expect(rowToCollapse).toHaveAttribute('aria-posinset', '1'); + expect(rowToCollapse).toHaveAttribute('aria-setsize', '1'); + expect(rowToCollapse).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowToCollapse).toHaveAttribute('aria-expanded', 'true'); + let chevron = within(rowToCollapse).getByRole('button'); + expect(chevron).toBeTruthy(); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + + focusCell(treegrid, 'Lvl 1 Foo 1'); + moveFocus(Arrow); + act(() => jest.runAllTimers()); + expect(onExpandedChange).not.toHaveBeenCalled(); + + act(() => {rowToCollapse.focus();}); + moveFocus(Arrow); + act(() => jest.runAllTimers()); + + expect(document.activeElement).toBe(rowToCollapse); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set()); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(chevron).toHaveAttribute('aria-label', 'Expand'); + expect(rows).toHaveLength(1); + rowToCollapse = rows[0]; + expect(rowToCollapse).toHaveAttribute('aria-level', '1'); + expect(rowToCollapse).toHaveAttribute('aria-posinset', '1'); + expect(rowToCollapse).toHaveAttribute('aria-setsize', '1'); + expect(rowToCollapse).toHaveTextContent('Lvl 1 Foo 1'); + expect(rowToCollapse).toHaveAttribute('aria-expanded', 'false'); + }); + }); + }); + + describe('keyboard focus', function () { + describe('ArrowDown', function () { + it('should move focus to the nested row\'s below', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[3]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 3, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[4]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + }); + + it('should move focus to the nested row\'s cell below', function () { + let treegrid = render(); + focusCell(treegrid, 'Row 1, Lvl 1, Foo'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 3, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + }); + + it('should move focus to the cell below a column header', function () { + let treegrid = render(); + focusCell(treegrid, 'Bar'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 1, Bar')); + }); + + it('should allow the user to focus disabled nested rows', function () { + let treegrid = render(); + focusCell(treegrid, 'Row 1, Lvl 1, Foo'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + + it('should skip child rows of non-expanded parent rows', function () { + // Only one child level of Row 1 and Row 3 should be exposed, otherwise only the top level rows are rendered + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[3]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[4]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(rows[5]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 3, Lvl 2, Foo')); + }); + }); + + describe('ArrowUp', function () { + it('should move focus to the nested row\'s above', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[4].focus();}); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[3]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 3, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[1]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + }); + + it('should move focus to the nested row\'s cell above', function () { + let treegrid = render(); + focusCell(treegrid, 'Row 2, Lvl 1, Foo'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 3, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + }); + + it('should move focus to the column header above a cell in the first row ', function () { + let treegrid = render(); + focusCell(treegrid, 'Row 1, Lvl 1, Bar'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Bar')); + }); + + it('should move focus to the column header above the first row', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Foo')); + }); + + it('should allow the user to focus disabled nested rows', function () { + let treegrid = render(); + focusCell(treegrid, 'Row 1, Lvl 3, Foo'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + + it('should skip child rows of non-expanded parent rows', function () { + // Only one child level of Row 1 and Row 3 should be exposed, otherwise only the top level rows are rendered + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[5].focus();}); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[4]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[3]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(rows[2]); + expect(document.activeElement).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + }); + + describe('ArrowRight', function () { + it('should properly wrap focus with ArrowRight', function () { + let treegrid = render(); + let row = treegrid.getAllByRole('row')[2]; + focusCell(treegrid, 'Row 1, Lvl 2, Foo'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Bar')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Baz')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(row); + expect(row).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + + it('should properly wrap focus with ArrowRight (RTL)', function () { + let treegrid = render(, undefined, 'ar-AE'); + let row = treegrid.getAllByRole('row')[2]; + focusCell(treegrid, 'Row 1, Lvl 2, Foo'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(row); + expect(row).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + // Focus doesn't move because Arrow Right on the row will collapse it + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(row); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Baz')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Bar')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + }); + + describe('ArrowLeft', function () { + it('should properly wrap focus with ArrowLeft', function () { + let treegrid = render(); + let row = treegrid.getAllByRole('row')[2]; + focusCell(treegrid, 'Row 1, Lvl 2, Foo'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(row); + expect(row).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + // Focus doesn't move because Arrow Left on the row will collapse it here + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(row); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Baz')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Bar')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + + it('should properly wrap focus with ArrowLeft (RTL)', function () { + let treegrid = render(, undefined, 'ar-AE'); + let row = treegrid.getAllByRole('row')[2]; + focusCell(treegrid, 'Row 1, Lvl 2, Foo'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Bar')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Baz')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(row); + expect(row).toContainElement(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 2, Foo')); + }); + }); + + describe('End', function () { + it('should focus the last nested row with End', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('End'); + rows = treegrid.getAllByRole('row'); + expect(document.activeElement).toBe(rows.at(-1)); + expect(document.activeElement).toHaveTextContent('Row 19, Lvl 3, Foo'); + }); + }); + + describe('Home', function () { + it('should focus the first row from a nested row with Home', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[15].focus();}); + expect(document.activeElement).toHaveTextContent('Row 5, Lvl 3, Foo'); + moveFocus('Home'); + expect(document.activeElement).toBe(rows[1]); + expect(document.activeElement).toHaveTextContent('Row 1, Lvl 1, Foo'); + }); + }); + + describe('PageDown', function () { + it('should focus a nested row a page below', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[2].focus();}); + moveFocus('PageDown'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 9, Lvl 2, Foo'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 17, Lvl 2, Foo'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 19, Lvl 3, Foo'})); + }); + }); + + describe('PageUp', function () { + it('should focus a nested row a page above', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('End'); + moveFocus('PageUp'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 11, Lvl 3, Foo'})); + moveFocus('PageUp'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 3, Lvl 3, Foo'})); + moveFocus('PageUp'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Row 1, Lvl 1, Foo'})); + }); + }); + + describe('type to select', function () { + it('should focus a nested row', function () { + let treegrid = render(); + let rows = treegrid.getAllByRole('row'); + act(() => {rows[1].focus();}); + moveFocus('L'); + moveFocus('v'); + moveFocus('l'); + moveFocus(' '); + moveFocus('2'); + expect(document.activeElement).toBe(treegrid.getByRole('row', {name: 'Lvl 2 Foo 1'})); + }); + }); + + describe('scrolling', function () { + it('should scroll to a cell when it is focused', function () { + let treegrid = render(); + let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); + expect(body.scrollTop).toBe(0); + + focusCell(treegrid, 'Row 9, Lvl 1, Foo'); + expect(body.scrollTop).toBe(24); + }); + + it('should scroll to a nested row cell when it is focused off screen', function () { + let treegrid = render(); + let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + act(() => cell.focus()); + expect(document.activeElement).toBe(cell); + expect(body.scrollTop).toBe(0); + + // When scrolling the focused item out of view, focus should remain on the item, + // virtualizer keeps focused items from being reused + body.scrollTop = 1000; + body.scrollLeft = 1000; + fireEvent.scroll(body); + + expect(body.scrollTop).toBe(1000); + expect(document.activeElement).toBe(cell); + + // Ensure we have the correct sticky cells in the right order. + let row = cell.closest('[role=row]'); + let cells = within(row).getAllByRole('gridcell'); + let rowHeaders = within(row).getAllByRole('rowheader'); + expect(cells).toHaveLength(3); + expect(rowHeaders).toHaveLength(1); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); // checkbox + expect(rowHeaders[0]).toHaveAttribute('aria-colindex', '2'); // rowheader + expect(rowHeaders[0]).toBe(cell); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[1]).toHaveTextContent('Row 1, Lvl 3, Bar'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + expect(cells[2]).toHaveTextContent('Row 1, Lvl 3, Baz'); + + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(within(rows[0]).getByRole('rowheader')).toHaveTextContent('Row 1, Lvl 3, Foo'); + expect(within(rows[1]).getByRole('rowheader')).toHaveTextContent('Row 9, Lvl 1, Foo'); + + // Moving focus should scroll the new focused item into view + moveFocus('ArrowRight'); + expect(body.scrollTop).toBe(82); + expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 3, Bar')); + }); + }); + }); + + describe('selection', function () { + let checkSelection = (onSelectionChange, selectedKeys) => { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); + }; + + let checkSelectAll = (tree, state = 'indeterminate') => { + let checkbox = tree.getByLabelText('Select All'); + if (state === 'indeterminate') { + expect(checkbox.indeterminate).toBe(true); + } else { + expect(checkbox.checked).toBe(state === 'checked'); + } + }; + + let checkRowSelection = (rows, selected) => { + for (let row of rows) { + expect(row).toHaveAttribute('aria-selected', '' + selected); + } + }; + + let pressWithKeyboard = (element, key = ' ') => { + fireEvent.keyDown(element, {key}); + act(() => {element.focus();}); + fireEvent.keyUp(element, {key}); + act(() => jest.runAllTimers()); + }; + + describe('row selection', function () { + describe('with pointer', function () { + it('should select a row when clicking on the chevron cell', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let chevronCell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + let chevron = within(chevronCell).getByRole('button'); + expect(chevron).toHaveAttribute('aria-label', 'Collapse'); + + checkRowSelection(rows, false); + triggerPress(chevronCell); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1' + ]); + checkRowSelection(rows.slice(0, 1), true); + + onSelectionChange.mockReset(); + triggerPress(chevron); + expect(onSelectionChange).not.toHaveBeenCalled(); + checkRowSelection(rows.slice(0, 1), true); + }); + + it('should select a nested row on click', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + checkRowSelection(rows, false); + triggerPress(cell); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 3' + ]); + checkRowSelection(rows.slice(2, 3), true); + }); + }); + + describe('with keyboard', function () { + it('should select a nested row by pressing the Enter key on a row', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(rows[1], 'Enter'); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 2' + ]); + checkRowSelection(rows.slice(1, 2), true); + checkSelectAll(treegrid); + }); + + it('should select a nested row by pressing the Space key on a row', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(rows[1]); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 2' + ]); + checkRowSelection(rows.slice(1, 2), true); + checkSelectAll(treegrid); + }); + + it('should select a row by pressing the Enter key on a chevron cell', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + + checkRowSelection(rows, false); + pressWithKeyboard(cell, 'Enter'); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1' + ]); + checkRowSelection(rows.slice(0, 1), true); + checkSelectAll(treegrid); + }); + + it('should select a row by pressing the Space key on a chevron cell', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + + checkRowSelection(rows, false); + pressWithKeyboard(cell); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1' + ]); + checkRowSelection(rows.slice(0, 1), true); + checkSelectAll(treegrid); + }); + }); + + it('should select nested rows if select all checkbox is pressed', function () { + let treegrid = render(); + let checkbox = treegrid.getByLabelText('Select All'); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + triggerPress(checkbox); + checkRowSelection(rows, true); + checkSelectAll(treegrid, 'checked'); + }); + + it('should not allow selection of disabled nested rows', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 2, Foo'); + + triggerPress(cell); + expect(onSelectionChange).not.toHaveBeenCalled(); + checkRowSelection(rows, false); + + let checkbox = treegrid.getByLabelText('Select All'); + triggerPress(checkbox); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0]).has('Row 1 Lvl 2')).toBeFalsy(); + checkRowSelection([rows[1]], false); + }); + }); + + describe('range selection', function () { + describe('with pointer', function () { + it('should support selecting a range from a top level row to a nested row', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + triggerPress(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + triggerPress(getCell(treegrid, 'Row 2, Lvl 3, Foo'), {shiftKey: true}); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1', 'Row 2 Lvl 2', 'Row 2 Lvl 3' + ]); + checkRowSelection(rows.slice(0, 6), true); + checkRowSelection(rows.slice(6), false); + }); + + it('should support selecting a range from a nested row to a top level row', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + triggerPress(getCell(treegrid, 'Row 2, Lvl 3, Foo')); + onSelectionChange.mockReset(); + + triggerPress(getCell(treegrid, 'Row 1, Lvl 1, Foo'), {shiftKey: true}); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1', 'Row 2 Lvl 2', 'Row 2 Lvl 3' + ]); + checkRowSelection(rows.slice(0, 6), true); + checkRowSelection(rows.slice(6), false); + }); + + it('should support selecting a range from a top level row to a descendent child row', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + triggerPress(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + triggerPress(getCell(treegrid, 'Row 1, Lvl 3, Foo'), {shiftKey: true}); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3' + ]); + checkRowSelection(rows.slice(0, 3), true); + checkRowSelection(rows.slice(3), false); + }); + + it('should support selecting a range from a nested child row to its top level row ancestor', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + triggerPress(getCell(treegrid, 'Row 1, Lvl 3, Foo')); + onSelectionChange.mockReset(); + + triggerPress(getCell(treegrid, 'Row 1, Lvl 1, Foo'), {shiftKey: true}); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3' + ]); + checkRowSelection(rows.slice(0, 3), true); + checkRowSelection(rows.slice(3), false); + }); + + it('should not include disabled rows', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + triggerPress(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + triggerPress(getCell(treegrid, 'Row 2, Lvl 3, Foo'), {shiftKey: true}); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 3', 'Row 2 Lvl 1', 'Row 2 Lvl 2', 'Row 2 Lvl 3' + ]); + checkRowSelection(rows.slice(0, 1), true); + checkRowSelection(rows.slice(1, 2), false); + checkRowSelection(rows.slice(2, 6), true); + }); + }); + + describe('with keyboard', function () { + it('should extend a selection with Shift + ArrowDown through nested keys', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2' + ]); + + onSelectionChange.mockReset(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3' + ]); + + onSelectionChange.mockReset(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1' + ]); + + checkRowSelection(rows.slice(0, 4), true); + checkRowSelection(rows.slice(4), false); + }); + + it('should extend a selection with Shift + ArrowUp through nested keys', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 2 Lvl 1', 'Row 1 Lvl 3' + ]); + + onSelectionChange.mockReset(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 2 Lvl 1', 'Row 1 Lvl 3', 'Row 1 Lvl 2' + ]); + + onSelectionChange.mockReset(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 2 Lvl 1', 'Row 1 Lvl 3', 'Row 1 Lvl 2', 'Row 1 Lvl 1' + ]); + + checkRowSelection(rows.slice(0, 4), true); + checkRowSelection(rows.slice(4), false); + }); + + it('should extend a selection with Ctrl + Shift + Home', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'Home', shiftKey: true, ctrlKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'Home', shiftKey: true, ctrlKey: true}); + act(() => jest.runAllTimers()); + + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1', 'Row 2 Lvl 2', 'Row 2 Lvl 3', 'Row 3 Lvl 1' + ]); + + checkRowSelection(rows.slice(0, 7), true); + checkRowSelection(rows.slice(7), false); + }); + + it('should extend a selection with Ctrl + Shift + End', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'End', shiftKey: true, ctrlKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'End', shiftKey: true, ctrlKey: true}); + act(() => jest.runAllTimers()); + + checkRowSelection(rows.slice(6), true); + }); + + it('should extend a selection with Shift + PageDown', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'PageDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'PageDown', shiftKey: true}); + act(() => jest.runAllTimers()); + + checkSelection(onSelectionChange, [ + 'Row 3 Lvl 1', 'Row 3 Lvl 2', 'Row 3 Lvl 3', 'Row 4 Lvl 1', 'Row 4 Lvl 2', 'Row 4 Lvl 3', + 'Row 5 Lvl 1', 'Row 5 Lvl 2', 'Row 5 Lvl 3', 'Row 6 Lvl 1', 'Row 6 Lvl 2', 'Row 6 Lvl 3', + 'Row 7 Lvl 1', 'Row 7 Lvl 2', 'Row 7 Lvl 3', 'Row 8 Lvl 1', 'Row 8 Lvl 2', 'Row 8 Lvl 3', + 'Row 9 Lvl 1', 'Row 9 Lvl 2', 'Row 9 Lvl 3', 'Row 10 Lvl 1', 'Row 10 Lvl 2', 'Row 10 Lvl 3', + 'Row 11 Lvl 1' + ]); + }); + + it('should extend a selection with Shift + PageUp', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'PageUp', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'PageUp', shiftKey: true}); + act(() => jest.runAllTimers()); + + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1', 'Row 2 Lvl 2', 'Row 2 Lvl 3', 'Row 3 Lvl 1' + ]); + }); + + it('should not include disabled rows', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + checkRowSelection(rows, false); + pressWithKeyboard(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + onSelectionChange.mockReset(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + act(() => jest.runAllTimers()); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, [ + 'Row 1 Lvl 1', 'Row 1 Lvl 3' + ]); + + checkRowSelection(rows.slice(0, 1), true); + checkRowSelection(rows.slice(1, 2), false); + checkRowSelection(rows.slice(2, 3), true); + }); + }); + }); + + describe('onAction', function () { + installPointerEvent(); + + it.each` + Name + ${'mouse'} + ${'touch'} + `('should trigger onAction when clicking nested rows with $Name', ({Name}) => { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + fireEvent.pointerDown(cell, {pointerType: Name, pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: Name, pointerId: 1}); + act(() => jest.runAllTimers()); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('Row 1 Lvl 3'); + checkRowSelection([rows[2]], false); + + let checkbox = within(rows[0]).getByRole('checkbox'); + userEvent.click(checkbox); + act(() => jest.runAllTimers()); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Row 1 Lvl 1']); + checkRowSelection([rows[0]], true); + onSelectionChange.mockReset(); + + fireEvent.pointerDown(cell, {pointerType: Name, pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: Name, pointerId: 1}); + act(() => jest.runAllTimers()); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Row 1 Lvl 1', 'Row 1 Lvl 3']); + checkRowSelection([rows[0], rows[2]], true); + }); + + it('should trigger onAction when pressing Enter', function () { + let treegrid = render(); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + fireEvent.keyDown(cell, {key: 'Enter'}); + fireEvent.keyUp(cell, {key: 'Enter'}); + act(() => jest.runAllTimers()); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('Row 1 Lvl 3'); + checkRowSelection(rows, false); + + onAction.mockReset(); + fireEvent.keyDown(cell, {key: ' '}); + fireEvent.keyUp(cell, {key: ' '}); + act(() => jest.runAllTimers()); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onAction).not.toHaveBeenCalled(); + checkRowSelection([rows[2]], true); + }); + }); + + describe('selectionStyle highlight', function () { + installPointerEvent(); + + it('should toggle selection with mouse', function () { + let treegrid = render(); + expect(treegrid.queryByLabelText('Select All')).toBeNull(); + + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + checkRowSelection(rows, false); + fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Row 1 Lvl 3']); + checkRowSelection([rows[2]], true); + onSelectionChange.mockReset(); + + cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 1, Foo selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['Row 1 Lvl 1']); + checkRowSelection([rows[0]], true); + checkRowSelection(rows.slice(1), false); + }); + + it('should toggle selection with touch', function () { + let treegrid = render(); + expect(treegrid.queryByLabelText('Select All')).toBeNull(); + + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + checkRowSelection(rows, false); + fireEvent.pointerDown(cell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Row 1 Lvl 3']); + checkRowSelection([rows[2]], true); + onSelectionChange.mockReset(); + + cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + fireEvent.pointerDown(cell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 1, Foo selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['Row 1 Lvl 1', 'Row 1 Lvl 3']); + checkRowSelection([rows[0], rows[2]], true); + }); + + it('should support long press to enter selection mode on touch', function () { + let treegrid = render(); + userEvent.click(document.body); + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let firstCell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + let secondCell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + + fireEvent.pointerDown(firstCell, {pointerType: 'touch'}); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + + act(() => jest.advanceTimersByTime(800)); + + checkSelection(onSelectionChange, ['Row 1 Lvl 3']); + checkRowSelection([rows[2]], true); + expect(onAction).not.toHaveBeenCalled(); + + fireEvent.pointerUp(firstCell, {pointerType: 'touch'}); + onSelectionChange.mockReset(); + + fireEvent.pointerDown(secondCell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(secondCell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, ['Row 1 Lvl 1', 'Row 1 Lvl 3']); + checkRowSelection([rows[0], rows[2]], true); + + // Deselect all to exit selection mode + fireEvent.pointerDown(firstCell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(firstCell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + onSelectionChange.mockReset(); + fireEvent.pointerDown(secondCell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(secondCell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, []); + expect(onAction).not.toHaveBeenCalled(); + checkRowSelection(rows, false); + }); + + it('should support double click to perform onAction with mouse', function () { + let treegrid = render(); + expect(treegrid.queryByLabelText('Select All')).toBeNull(); + + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + checkRowSelection(rows, false); + fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Row 1 Lvl 3']); + expect(onAction).not.toHaveBeenCalled(); + onSelectionChange.mockReset(); + // @ts-ignore + userEvent.dblClick(cell, {pointerType: 'mouse'}); + act(() => jest.runAllTimers()); + expect(announce).toHaveBeenCalledTimes(1); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Row 1 Lvl 3'); + }); + + it('should support single tap to perform onAction with touch', function () { + let treegrid = render(); + expect(treegrid.queryByLabelText('Select All')).toBeNull(); + let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); + + fireEvent.pointerDown(cell, {pointerType: 'touch', pointerId: 1}); + fireEvent.pointerUp(cell, {pointerType: 'touch', pointerId: 1}); + act(() => jest.runAllTimers()); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Row 1 Lvl 3'); + }); + }); + }); + + describe('empty state', function () { + it('should display an empty state with the proper aria attributes', async function () { + let treegrid = render(); + await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(1); + let row = rows[0]; + expect(row).not.toHaveAttribute('aria-expanded'); + expect(row).toHaveAttribute('aria-level', '1'); + expect(row).toHaveAttribute('aria-posinset', '1'); + expect(row).toHaveAttribute('aria-setsize', '1'); + + let cell = within(rows[0]).getByRole('rowheader'); + expect(cell).toHaveAttribute('aria-colspan', '3'); + + let heading = within(cell).getByRole('heading'); + expect(heading).toBeVisible(); + expect(heading).toHaveTextContent('No results'); + + let showItemsButton = treegrid.getAllByRole('button')[0]; + triggerPress(showItemsButton); + act(() => jest.runAllTimers()); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(19); + expect(heading).not.toBeInTheDocument(); + }); + }); + + describe('loading state', function () { + it('should render a spinner row with the proper attributes when loading', async function () { + let treegrid = render(); + await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings + let rowgroups = treegrid.getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(1); + let row = rows[0]; + expect(row).not.toHaveAttribute('aria-expanded'); + expect(row).toHaveAttribute('aria-level', '1'); + expect(row).toHaveAttribute('aria-posinset', '1'); + expect(row).toHaveAttribute('aria-setsize', '1'); + + let cell = within(rows[0]).getByRole('rowheader'); + expect(cell).toHaveAttribute('aria-colspan', '3'); + + let spinner = within(cell).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'Loading…'); + expect(spinner).not.toHaveAttribute('aria-valuenow'); + + let showItemsButton = treegrid.getAllByRole('button')[0]; + triggerPress(showItemsButton); + act(() => jest.runAllTimers()); + rowgroups = treegrid.getAllByRole('rowgroup'); + rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(20); + + row = rows[19]; + expect(row).not.toHaveAttribute('aria-expanded'); + expect(row).toHaveAttribute('aria-level', '1'); + expect(row).toHaveAttribute('aria-posinset', '20'); + expect(row).toHaveAttribute('aria-setsize', '20'); + spinner = within(row).getByRole('progressbar'); + expect(spinner).toBeTruthy(); + }); + }); +}); diff --git a/packages/@react-stately/collections/src/getChildNodes.ts b/packages/@react-stately/collections/src/getChildNodes.ts index e7bf397b5b1..8692c2030b0 100644 --- a/packages/@react-stately/collections/src/getChildNodes.ts +++ b/packages/@react-stately/collections/src/getChildNodes.ts @@ -57,10 +57,11 @@ export function compareNodeOrder(collection: Collection>, a: Node, } // Otherwise, collect all of the ancestors from each node, and find the first one that doesn't match starting from the root. - let aAncestors = getAncestors(collection, a); - let bAncestors = getAncestors(collection, b); + // Include the base nodes in case we are comparing nodes of different levels so that we can compare the higher node to the lower level node's + // ancestor of the same level + let aAncestors = [...getAncestors(collection, a), a]; + let bAncestors = [...getAncestors(collection, b), b]; let firstNonMatchingAncestor = aAncestors.slice(0, bAncestors.length).findIndex((a, i) => a !== bAncestors[i]); - if (firstNonMatchingAncestor !== -1) { // Compare the indices of two children within the common ancestor. a = aAncestors[firstNonMatchingAncestor]; @@ -68,6 +69,13 @@ export function compareNodeOrder(collection: Collection>, a: Node, return a.index - b.index; } + // If there isn't a non matching ancestor, we might be in a case where one of the nodes is the ancestor of the other. + if (aAncestors.findIndex(node => node === b) >= 0) { + return 1; + } else if (bAncestors.findIndex(node => node === a) >= 0) { + return -1; + } + // 🤷 return -1; } diff --git a/packages/@react-stately/collections/src/index.ts b/packages/@react-stately/collections/src/index.ts index b2b3d3daf58..0a6280ee706 100644 --- a/packages/@react-stately/collections/src/index.ts +++ b/packages/@react-stately/collections/src/index.ts @@ -16,3 +16,4 @@ export {Section} from './Section'; export {useCollection} from './useCollection'; export {getItemCount} from './getItemCount'; export {getChildNodes, getFirstItem, getLastItem, getNthItem, compareNodeOrder} from './getChildNodes'; +export {CollectionBuilder} from './CollectionBuilder'; diff --git a/packages/@react-stately/flags/src/index.ts b/packages/@react-stately/flags/src/index.ts index f0d0955aca8..63fdb85538b 100644 --- a/packages/@react-stately/flags/src/index.ts +++ b/packages/@react-stately/flags/src/index.ts @@ -10,8 +10,12 @@ * governing permissions and limitations under the License. */ -export let tableNestedRows = false; +let _tableNestedRows = false; export function enableTableNestedRows() { - tableNestedRows = true; + _tableNestedRows = true; +} + +export function tableNestedRows() { + return _tableNestedRows; } diff --git a/packages/@react-stately/grid/src/GridCollection.ts b/packages/@react-stately/grid/src/GridCollection.ts index aa2346afc6c..8f0462a56f0 100644 --- a/packages/@react-stately/grid/src/GridCollection.ts +++ b/packages/@react-stately/grid/src/GridCollection.ts @@ -94,8 +94,7 @@ export class GridCollection implements IGridCollection { childNodes: [...node.childNodes], rendered: undefined, textValue: undefined, - ...node, - index: i + ...node } as GridNode; if (last) { diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 46ba76c2211..8388b8dcf04 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -36,7 +36,8 @@ export interface LayoutNode { layoutInfo: LayoutInfo, header?: LayoutInfo, children?: LayoutNode[], - validRect: Rect + validRect: Rect, + index?: number } const DEFAULT_HEIGHT = 48; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 7857b749187..fd15f170d3d 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -161,6 +161,7 @@ export class TableLayout extends ListLayout { layoutNode.layoutInfo.parentKey = 'header'; y = layoutNode.layoutInfo.rect.maxY; width = Math.max(width, layoutNode.layoutInfo.rect.width); + layoutNode.index = children.length; children.push(layoutNode); } @@ -187,6 +188,7 @@ export class TableLayout extends ListLayout { layoutNode.layoutInfo.parentKey = row.key; x = layoutNode.layoutInfo.rect.maxX; height = Math.max(height, layoutNode.layoutInfo.rect.height); + layoutNode.index = columns.length; columns.push(layoutNode); } for (let [i, layout] of columns.entries()) { @@ -276,7 +278,7 @@ export class TableLayout extends ListLayout { let skipped = 0; let width = 0; let children: LayoutNode[] = []; - for (let node of this.collection) { + for (let [i, node] of [...this.collection].entries()) { let rowHeight = (this.rowHeight ?? this.estimatedRowHeight) + 1; // Skip rows before the valid rectangle unless they are already cached. @@ -288,6 +290,7 @@ export class TableLayout extends ListLayout { let layoutNode = this.buildChild(node, 0, y); layoutNode.layoutInfo.parentKey = 'body'; + layoutNode.index = i; y = layoutNode.layoutInfo.rect.maxY; width = Math.max(width, layoutNode.layoutInfo.rect.width); children.push(layoutNode); @@ -354,20 +357,23 @@ export class TableLayout extends ListLayout { let children: LayoutNode[] = []; let height = 0; - for (let child of getChildNodes(node, this.collection)) { - if (x > this.validRect.maxX) { - // Adjust existing cached layoutInfo to ensure that it is out of view. - // This can happen due to column resizing. - let layoutNode = this.layoutNodes.get(child.key); - if (layoutNode) { - layoutNode.layoutInfo.rect.x = x; - x += layoutNode.layoutInfo.rect.width; + for (let [i, child] of [...getChildNodes(node, this.collection)].entries()) { + if (child.type === 'cell') { + if (x > this.validRect.maxX) { + // Adjust existing cached layoutInfo to ensure that it is out of view. + // This can happen due to column resizing. + let layoutNode = this.layoutNodes.get(child.key); + if (layoutNode) { + layoutNode.layoutInfo.rect.x = x; + x += layoutNode.layoutInfo.rect.width; + } + } else { + let layoutNode = this.buildChild(child, x, y); + x = layoutNode.layoutInfo.rect.maxX; + height = Math.max(height, layoutNode.layoutInfo.rect.height); + layoutNode.index = i; + children.push(layoutNode); } - } else { - let layoutNode = this.buildChild(child, x, y); - x = layoutNode.layoutInfo.rect.maxX; - height = Math.max(height, layoutNode.layoutInfo.rect.height); - children.push(layoutNode); } } @@ -550,10 +556,7 @@ export class TableLayout extends ListLayout { this.persistedIndices.set(layoutInfo.parentKey, indices); } - let index = collectionNode.index; - if (layoutInfo.parentKey === 'body') { - index -= this.collection.headerRows.length; - } + let index = this.layoutNodes.get(layoutInfo.key).index; if (!indices.includes(index)) { indices.push(index); @@ -570,12 +573,13 @@ export class TableLayout extends ListLayout { getInitialLayoutInfo(layoutInfo: LayoutInfo) { let res = super.getInitialLayoutInfo(layoutInfo); + res.transform = null; + return res; + } - // If this insert was the result of async loading, remove the zoom effect and just keep the fade in. - if (this.wasLoading) { - res.transform = null; - } - + getFinalLayoutInfo(layoutInfo: LayoutInfo) { + let res = super.getFinalLayoutInfo(layoutInfo); + res.transform = null; return res; } @@ -616,7 +620,7 @@ export class TableLayout extends ListLayout { key = layoutInfo.key; } } - + if (key == null || this.collection.size === 0) { return {type: 'root'}; } diff --git a/packages/@react-stately/table/package.json b/packages/@react-stately/table/package.json index d008a8d0924..a6689755653 100644 --- a/packages/@react-stately/table/package.json +++ b/packages/@react-stately/table/package.json @@ -23,8 +23,10 @@ }, "dependencies": { "@react-stately/collections": "^3.9.0", + "@react-stately/flags": "3.0.0-alpha.0", "@react-stately/grid": "^3.7.0", "@react-stately/selection": "^3.13.2", + "@react-stately/utils": "^3.7.0", "@react-types/grid": "^3.1.8", "@react-types/shared": "^3.18.1", "@react-types/table": "^3.7.0", diff --git a/packages/@react-stately/table/src/Row.ts b/packages/@react-stately/table/src/Row.ts index b6a5b313c1a..5a73e136ec2 100644 --- a/packages/@react-stately/table/src/Row.ts +++ b/packages/@react-stately/table/src/Row.ts @@ -15,12 +15,12 @@ import {PartialNode} from '@react-stately/collections'; import React, {ReactElement} from 'react'; import {RowProps} from '@react-types/table'; -function Row(props: RowProps): ReactElement { // eslint-disable-line @typescript-eslint/no-unused-vars +function Row(props: RowProps): ReactElement { // eslint-disable-line @typescript-eslint/no-unused-vars return null; } -Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: CollectionBuilderContext): Generator> { - let {children, textValue} = props; +Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: CollectionBuilderContext): Generator> { + let {children, textValue, UNSTABLE_childItems} = props; yield { type: 'item', @@ -39,7 +39,7 @@ Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: } }; } - + if (context.showSelectionCheckboxes && context.selectionMode !== 'none') { yield { type: 'cell', @@ -58,13 +58,36 @@ Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: key: column.key // this is combined with the row key by CollectionBuilder }; } + + if (UNSTABLE_childItems) { + for (let child of UNSTABLE_childItems) { + // Note: in order to reuse the render function of TableBody for our child rows, we just need to yield a type and a value here. CollectionBuilder will then look up + // the parent renderer and use that to build the full node of this child row, using the value provided here to generate the cells + yield { + type: 'item', + value: child + }; + } + } } else { let cells: PartialNode[] = []; - React.Children.forEach(children, cell => { - cells.push({ - type: 'cell', - element: cell - }); + let childRows: PartialNode[] = []; + React.Children.forEach(children, node => { + if (node.type === Row) { + if (cells.length < context.columns.length) { + throw new Error('All of a Row\'s child Cells must be positioned before any child Rows.'); + } + + childRows.push({ + type: 'item', + element: node + }); + } else { + cells.push({ + type: 'cell', + element: node + }); + } }); if (cells.length !== context.columns.length) { @@ -72,6 +95,7 @@ Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: } yield* cells; + yield* childRows; } }, shouldInvalidate(newContext: CollectionBuilderContext) { @@ -91,5 +115,5 @@ Row.getCollectionNode = function* getCollectionNode(props: RowProps, context: * based on the columns defined in the TableHeader. */ // We don't want getCollectionNode to show up in the type definition -let _Row = Row as (props: RowProps) => JSX.Element; +let _Row = Row as (props: RowProps) => JSX.Element; export {_Row as Row}; diff --git a/packages/@react-stately/table/src/TableCollection.ts b/packages/@react-stately/table/src/TableCollection.ts index fd5f19aa75f..6e379581e76 100644 --- a/packages/@react-stately/table/src/TableCollection.ts +++ b/packages/@react-stately/table/src/TableCollection.ts @@ -184,7 +184,6 @@ export class TableCollection extends GridCollection implements ITableColle let rowHeaderColumnKeys: Set = new Set(); let body: GridNode; let columns: GridNode[] = []; - // Add cell for selection checkboxes if needed. if (opts?.showSelectionCheckboxes) { let rowHeaderColumn: GridNode = { diff --git a/packages/@react-stately/table/src/index.ts b/packages/@react-stately/table/src/index.ts index e16c277a54c..3bc9b63806f 100644 --- a/packages/@react-stately/table/src/index.ts +++ b/packages/@react-stately/table/src/index.ts @@ -13,6 +13,7 @@ export type {TableColumnResizeState, TableColumnResizeStateProps} from './useTableColumnResizeState'; export type {TableState, CollectionBuilderContext, TableStateProps} from './useTableState'; export type {TableHeaderProps, TableBodyProps, ColumnProps, RowProps, CellProps} from '@react-types/table'; +export type {TreeGridState, TreeGridStateProps} from './useTreeGridState'; export {useTableColumnResizeState} from './useTableColumnResizeState'; export {useTableState} from './useTableState'; @@ -24,3 +25,4 @@ export {Cell} from './Cell'; export {Section} from '@react-stately/collections'; export {TableCollection, buildHeaderRows} from './TableCollection'; export {TableColumnLayout} from './TableColumnLayout'; +export {UNSTABLE_useTreeGridState} from './useTreeGridState'; diff --git a/packages/@react-stately/table/src/useTreeGridState.ts b/packages/@react-stately/table/src/useTreeGridState.ts new file mode 100644 index 00000000000..7a9bcde836f --- /dev/null +++ b/packages/@react-stately/table/src/useTreeGridState.ts @@ -0,0 +1,276 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CollectionBuilder} from '@react-stately/collections'; +import {GridNode} from '@react-types/grid'; +import {Key, ReactElement, useMemo} from 'react'; +import {TableCollection} from './TableCollection'; +import {tableNestedRows} from '@react-stately/flags'; +import {TableState, TableStateProps, useTableState} from './useTableState'; +import {useControlledState} from '@react-stately/utils'; + +export interface TreeGridState extends TableState { + /** A set of keys for items that are expanded. */ + expandedKeys: 'all' | Set, + /** Toggles the expanded state for a row by its key. */ + toggleKey(key: Key): void, + /** The key map containing nodes representing the collection's tree grid structure. */ + keyMap: Map>, + /** The number of leaf columns provided by the user. */ + userColumnCount: number +} + +export interface TreeGridStateProps extends Omit, 'collection'> { + /** The currently expanded keys in the collection (controlled). */ + UNSTABLE_expandedKeys?: 'all' | Iterable, + /** The initial expanded keys in the collection (uncontrolled). */ + UNSTABLE_defaultExpandedKeys?: 'all' | Iterable, + /** Handler that is called when items are expanded or collapsed. */ + UNSTABLE_onExpandedChange?: (keys: Set) => any +} + +/** + * Provides state management for a tree grid component. Handles building a collection + * of columns and rows from props. In addition, it tracks and manages expanded rows, row selection, and sort order changes. + */ +export function UNSTABLE_useTreeGridState(props: TreeGridStateProps): TreeGridState { + let { + selectionMode = 'none', + showSelectionCheckboxes, + showDragButtons, + UNSTABLE_expandedKeys: propExpandedKeys, + UNSTABLE_defaultExpandedKeys: propDefaultExpandedKeys, + UNSTABLE_onExpandedChange, + children + } = props; + + if (!tableNestedRows()) { + throw new Error('Feature flag for table nested rows must be enabled to use useTreeGridState.'); + } + + let [expandedKeys, setExpandedKeys] = useControlledState( + propExpandedKeys ? convertExpanded(propExpandedKeys) : undefined, + propDefaultExpandedKeys ? convertExpanded(propDefaultExpandedKeys) : new Set(), + UNSTABLE_onExpandedChange + ); + + let context = useMemo(() => ({ + showSelectionCheckboxes: showSelectionCheckboxes && selectionMode !== 'none', + showDragButtons: showDragButtons, + selectionMode, + columns: [] + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [children, showSelectionCheckboxes, selectionMode, showDragButtons]); + + let builder = useMemo(() => new CollectionBuilder(), []); + let nodes = useMemo(() => builder.build({children: children as ReactElement[]}, context), [builder, children, context]); + let treeGridCollection = useMemo(() => { + return generateTreeGridCollection(nodes, {showSelectionCheckboxes, showDragButtons, expandedKeys}); + }, [nodes, showSelectionCheckboxes, showDragButtons, expandedKeys]); + + let onToggle = (key: Key) => { + setExpandedKeys(toggleKey(expandedKeys, key, treeGridCollection)); + }; + + let collection = useMemo(() => { + return new TableCollection(treeGridCollection.tableNodes, null, context); + }, [context, treeGridCollection.tableNodes]); + + let tableState = useTableState({...props, collection}); + return { + ...tableState, + keyMap: treeGridCollection.keyMap, + userColumnCount: treeGridCollection.userColumnCount, + expandedKeys, + toggleKey: onToggle + }; +} + +function toggleKey(currentExpandedKeys: 'all' | Set, key: Key, collection: TreeGridCollection): Set { + let updatedExpandedKeys: Set; + if (currentExpandedKeys === 'all') { + updatedExpandedKeys = new Set(collection.flattenedRows.filter(row => row.props.UNSTABLE_childItems || row.props.children.length > collection.userColumnCount).map(row => row.key)); + updatedExpandedKeys.delete(key); + } else { + updatedExpandedKeys = new Set(currentExpandedKeys); + if (updatedExpandedKeys.has(key)) { + updatedExpandedKeys.delete(key); + } else { + updatedExpandedKeys.add(key); + } + } + + return updatedExpandedKeys; +} + +function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { + if (!expanded) { + return new Set(); + } + + return expanded === 'all' + ? 'all' + : new Set(expanded); +} + +interface TreeGridCollectionOptions { + showSelectionCheckboxes?: boolean, + showDragButtons?: boolean, + expandedKeys: 'all' | Set +} + +interface TreeGridCollection { + keyMap: Map>, + tableNodes: GridNode[], + flattenedRows: GridNode[], + userColumnCount: number +} +function generateTreeGridCollection(nodes, opts: TreeGridCollectionOptions): TreeGridCollection { + let { + expandedKeys = new Set() + } = opts; + + let body: GridNode; + let flattenedRows = []; + let columnCount = 0; + let userColumnCount = 0; + let originalColumns = []; + let keyMap = new Map(); + + if (opts?.showSelectionCheckboxes) { + columnCount++; + } + + if (opts?.showDragButtons) { + columnCount++; + } + + let topLevelRows = []; + let visit = (node: GridNode) => { + switch (node.type) { + case 'body': + body = node; + keyMap.set(body.key, body); + break; + case 'column': + if (!node.hasChildNodes) { + userColumnCount++; + } + break; + case 'item': + topLevelRows.push(node); + return; + } + + for (let child of node.childNodes) { + visit(child); + } + }; + + for (let node of nodes) { + if (node.type === 'column') { + originalColumns.push(node); + } + visit(node); + } + columnCount += userColumnCount; + + // Update each grid node in the treegrid table with values specific to a treegrid structure. Also store a set of flattened row nodes for TableCollection to consume + let globalRowCount = 0; + let visitNode = (node: GridNode, i?: number) => { + // Clone row node and its children so modifications to the node for treegrid specific values aren't applied on the nodes provided + // to TableCollection. Index, level, and parent keys are all changed to reflect a flattened row structure rather than the treegrid structure + // values automatically calculated via CollectionBuilder + if (node.type === 'item') { + let childNodes = []; + for (let child of node.childNodes) { + if (child.type === 'cell') { + let cellClone = {...child}; + if (cellClone.index + 1 === columnCount) { + cellClone.nextKey = null; + } + childNodes.push({...cellClone}); + } + } + let clone = {...node, childNodes: childNodes, parentKey: body.key, level: 1, index: globalRowCount++}; + flattenedRows.push(clone); + } + + let newProps = {}; + + // Assign indexOfType to cells and rows for aria-posinset + if (node.type !== 'placeholder' && node.type !== 'column') { + newProps['indexOfType'] = i; + } + + // Use Object.assign instead of spread to preserve object reference for keyMap. Also ensures retrieving nodes + // via .childNodes returns the same object as the one found via keyMap look up + Object.assign(node, newProps); + keyMap.set(node.key, node); + + let lastNode: GridNode; + let rowIndex = 0; + for (let child of node.childNodes) { + if (!(child.type === 'item' && expandedKeys !== 'all' && !expandedKeys.has(node.key))) { + if (child.parentKey == null) { + // if child is a cell/expanded row/column and the parent key isn't already established by the collection, match child node to parent row + child.parentKey = node.key; + } + + if (lastNode) { + lastNode.nextKey = child.key; + child.prevKey = lastNode.key; + } else { + child.prevKey = null; + } + + if (child.type === 'item') { + visitNode(child, rowIndex++); + } else { + // We enforce that the cells come before rows so can just reuse cell index + visitNode(child, child.index); + } + + lastNode = child; + } + } + + if (lastNode) { + lastNode.nextKey = null; + } + }; + + let last: GridNode; + topLevelRows.forEach((node: GridNode, i) => { + visitNode(node as GridNode, i); + + if (last) { + last.nextKey = node.key; + node.prevKey = last.key; + } else { + node.prevKey = null; + } + + last = node; + }); + + if (last) { + last.nextKey = null; + } + + return { + keyMap, + userColumnCount, + flattenedRows, + tableNodes: [...originalColumns, {...body, childNodes: flattenedRows}] + }; +} diff --git a/packages/@react-types/grid/src/index.d.ts b/packages/@react-types/grid/src/index.d.ts index 38dfc0d9931..1e7c26255c7 100644 --- a/packages/@react-types/grid/src/index.d.ts +++ b/packages/@react-types/grid/src/index.d.ts @@ -13,7 +13,7 @@ import {Collection, Node} from '@react-types/shared'; import {Key} from 'react'; -export interface GridCollection extends Collection> { +export interface GridCollection extends Collection> { /** The number of columns in the grid. */ columnCount: number, /** A list of rows in the grid. */ @@ -31,5 +31,7 @@ export interface GridNode extends Node { /** The number of columns spanned by this cell. */ colspan?: number, /** The column index of this cell, accounting for any colspans. */ - colIndex?: number + colIndex?: number, + /** The index of this node within its parent, ignoring sibling nodes that aren't of the same type. */ + indexOfType?: number } diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 4719cf33139..43e3ef2236a 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -69,9 +69,9 @@ export interface CollectionStateBase> = Collecti export interface Expandable { /** The currently expanded keys in the collection (controlled). */ - expandedKeys?: Iterable, + expandedKeys?: 'all' | Iterable, /** The initial expanded keys in the collection (uncontrolled). */ - defaultExpandedKeys?: Iterable, + defaultExpandedKeys?: 'all' | Iterable, /** Handler that is called when items are expanded or collapsed. */ onExpandedChange?: (keys: Set) => any } diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index f3ab4893f90..abd3d552be4 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -122,20 +122,25 @@ export interface SpectrumColumnProps extends ColumnProps { hideHeader?: boolean } -export type RowElement = ReactElement; +export type RowElement = ReactElement>; export interface TableBodyProps extends Omit { /** The contents of the table body. Supports static items or a function for dynamic rendering. */ - children: RowElement | RowElement[] | ((item: T) => RowElement), + children: RowElement | RowElement[] | ((item: T) => RowElement), /** A list of row objects in the table body used when dynamically rendering rows. */ items?: Iterable, /** The current loading state of the table. */ loadingState?: LoadingState } -export interface RowProps { - // treeble case? Unsupported props for now - // /** A list of child item objects used when dynamically rendering row children. */ - // childItems?: Iterable, +export interface RowProps { + /** + * A list of child item objects used when dynamically rendering row children. Requires the feature flag to be + * enabled along with UNSTABLE_allowsExpandableRows, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. + * @version alpha + * @private + */ + UNSTABLE_childItems?: Iterable, + // TODO: update when async loading is supported for expandable rows // /** Whether this row has children, even if not loaded yet. */ // hasChildItems?: boolean, /** Rendered contents of the row or row child items. */