diff --git a/packages/@react-aria/test-utils/src/events.ts b/packages/@react-aria/test-utils/src/events.ts index ae1e16dfcf7..47183e630b8 100644 --- a/packages/@react-aria/test-utils/src/events.ts +++ b/packages/@react-aria/test-utils/src/events.ts @@ -41,6 +41,10 @@ export async function pressElement(user, element: HTMLElement, interactionType: // stick to simulting an actual user's keyboard operations as closely as possible // There are problems when using this approach though, actions like trying to trigger the select all checkbox and stuff behave oddly. act(() => element.focus()); + // TODO: would be better throw? + if (document.activeElement !== element) { + return; + } await user.keyboard('[Space]'); } else if (interactionType === 'touch') { await user.pointer({target: element, keys: '[TouchA]'}); diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts new file mode 100644 index 00000000000..ba328efe7f8 --- /dev/null +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -0,0 +1,270 @@ +/* + * Copyright 2024 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 {act, fireEvent, within} from '@testing-library/react'; +import {BaseTesterOpts, UserOpts} from './user'; +import {pressElement, triggerLongPress} from './events'; +export interface TreeOptions extends UserOpts, BaseTesterOpts { + user?: any +} + +// TODO: Previously used logic like https://github.com/testing-library/react-testing-library/blame/c63b873072d62c858959c2a19e68f8e2cc0b11be/src/pure.js#L16 +// but https://github.com/testing-library/dom-testing-library/issues/987#issuecomment-891901804 indicates that it may falsely indicate that fake timers are enabled +// when they aren't +export class TreeTester { + private user; + private _interactionType: UserOpts['interactionType']; + private _advanceTimer: UserOpts['advanceTimer']; + private _tree: HTMLElement; + + constructor(opts: TreeOptions) { + let {root, user, interactionType, advanceTimer} = opts; + this.user = user; + this._interactionType = interactionType || 'mouse'; + this._advanceTimer = advanceTimer; + this._tree = within(root).getByRole('treegrid'); + } + + setInteractionType = (type: UserOpts['interactionType']) => { + this._interactionType = type; + }; + + toggleRowSelection = async (opts: { + index?: number, + text?: string, + needsLongPress?: boolean, + // if false, will use the row to select instead of the checkbox directly + checkboxSelection?: boolean, + interactionType?: UserOpts['interactionType'] + } = {}) => { + let { + index, + text, + needsLongPress, + checkboxSelection = true, + interactionType = this._interactionType + } = opts; + + // this would be better than the check to do nothing in events.ts + // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly + if (interactionType === 'keyboard' && !checkboxSelection) { + await this.keyboardNavigateToRow({row: this.findRow({index, text})}); + await this.user.keyboard('[Space]'); + return; + } + let row = this.findRow({index, text}); + let rowCheckbox = within(row).queryByRole('checkbox'); + if (rowCheckbox) { + await pressElement(this.user, rowCheckbox, interactionType); + } else { + let cell = within(row).getAllByRole('gridcell')[0]; + if (needsLongPress && interactionType === 'touch') { + if (this._advanceTimer == null) { + throw new Error('No advanceTimers provided for long press.'); + } + + // Note that long press interactions with rows is strictly touch only for grid rows + await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); + // TODO: interestingly enough, we need to do a followup click otherwise future row selections may not fire properly? + // To reproduce, try removing this, forcing toggleRowSelection to hit "needsLongPress ? await triggerLongPress(cell) : await action(cell);" and + // run Table.test's "should support long press to enter selection mode on touch" test to see what happens + await fireEvent.click(cell); + } else { + await pressElement(this.user, cell, interactionType); + } + } + }; + + expandItem = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => { + let { + index, + text, + interactionType = this._interactionType + } = opts; + if (!this.tree.contains(document.activeElement)) { + await act(async () => { + this.tree.focus(); + }); + } + + let row = this.findRow({index, text}); + + if (row.getAttribute('aria-expanded') === 'true') { + return; + } + + if (interactionType === 'mouse' || interactionType === 'touch') { + let rowExpander = within(row).getAllByRole('button')[0]; // what happens if the button is not first? how can we differentiate? + await pressElement(this.user, rowExpander, interactionType); + } else if (interactionType === 'keyboard') { + await this.keyboardNavigateToRow({row}); + await this.user.keyboard('[ArrowRight]'); + } + }; + + collapseItem = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => { + let { + index, + text, + interactionType = this._interactionType + } = opts; + if (!this.tree.contains(document.activeElement)) { + await act(async () => { + this.tree.focus(); + }); + } + + let row = this.findRow({index, text}); + + if (row.getAttribute('aria-expanded') === 'false') { + return; + } + + if (interactionType === 'mouse' || interactionType === 'touch') { + let rowExpander = within(row).getAllByRole('button')[0]; // what happens if the button is not first? how can we differentiate? + await pressElement(this.user, rowExpander, interactionType); + } else if (interactionType === 'keyboard') { + await this.keyboardNavigateToRow({row}); + await this.user.keyboard('[ArrowLeft]'); + } + }; + + keyboardNavigateToRow = async (opts: {row: HTMLElement}) => { + let {row} = opts; + let rows = this.rows; + let targetIndex = rows.indexOf(row); + if (targetIndex === -1) { + throw new Error('Option provided is not in the menu'); + } + if (document.activeElement === this.tree) { + await this.user.keyboard('[ArrowDown]'); + } else if (document.activeElement!.getAttribute('role') !== 'row') { + do { + await this.user.keyboard('[ArrowLeft]'); + } while (document.activeElement!.getAttribute('role') !== 'row'); + } + let currIndex = rows.indexOf(document.activeElement as HTMLElement); + if (targetIndex === -1) { + throw new Error('ActiveElement is not in the menu'); + } + let direction = targetIndex > currIndex ? 'down' : 'up'; + + for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) { + await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`); + } + }; + + // TODO: should there be a util for triggering a row action? Perhaps there should be but it would rely on the user teling us the config of the + // table. Maybe we could rely on the user knowing to trigger a press/double click? We could have the user pass in "needsDoubleClick" + // It is also iffy if there is any row selected because then the table is in selectionMode and the below actions will simply toggle row selection + triggerRowAction = async (opts: {index?: number, text?: string, needsDoubleClick?: boolean, interactionType?: UserOpts['interactionType']} = {}) => { + let { + index, + text, + needsDoubleClick, + interactionType = this._interactionType + } = opts; + + let row = this.findRow({index, text}); + if (row) { + if (needsDoubleClick) { + await this.user.dblClick(row); + } else if (interactionType === 'keyboard') { + act(() => row.focus()); + await this.user.keyboard('[Enter]'); + } else { + await pressElement(this.user, row, interactionType); + } + } + }; + + // TODO: should there be utils for drag and drop and column resizing? For column resizing, I'm not entirely convinced that users will be doing that in their tests. + // For DnD, it might be tricky to do for keyboard DnD since we wouldn't know what valid drop zones there are... Similarly, for simulating mouse drag and drop the coordinates depend + // on the mocks the user sets up for their row height/etc. + // Additionally, should we also support keyboard navigation/typeahead? Those felt like they could be very easily replicated by the user via user.keyboard already and don't really + // add much value if we provide that to them + + toggleSelectAll = async (opts: {interactionType?: UserOpts['interactionType']} = {}) => { + let { + interactionType = this._interactionType + } = opts; + let checkbox = within(this.tree).getByLabelText('Select All'); + if (interactionType === 'keyboard') { + // TODO: using the .focus -> trigger keyboard Enter approach doesn't work for some reason, for now just trigger select all with click. + await this.user.click(checkbox); + } else { + await pressElement(this.user, checkbox, interactionType); + } + }; + + findRow = (opts: {index?: number, text?: string} = {}) => { + let { + index, + text + } = opts; + + let row; + let rows = this.rows; + let bodyRowGroup = this.rowGroups[1]; + if (index != null) { + row = rows[index]; + } else if (text != null) { + row = within(bodyRowGroup).getByText(text); + while (row && row.getAttribute('role') !== 'row') { + row = row.parentElement; + } + } + + return row; + }; + + findCell = (opts: {text: string}) => { + let { + text + } = opts; + + let cell = within(this.tree).getByText(text); + if (cell) { + while (cell && !/gridcell|rowheader|columnheader/.test(cell.getAttribute('role') || '')) { + if (cell.parentElement) { + cell = cell.parentElement; + } else { + break; + } + } + } + + return cell; + }; + + get tree() { + return this._tree; + } + + get rowGroups() { + let tree = this.tree; + return tree ? within(tree).queryAllByRole('rowgroup') : []; + } + + get columns() { + let headerRowGroup = this.rowGroups[0]; + return headerRowGroup ? within(headerRowGroup).queryAllByRole('columnheader') : []; + } + + get rows() { + return within(this.tree).queryAllByRole('row') ?? []; + } + + get selectedRows() { + return this.rows.filter(row => row.getAttribute('aria-selected') === 'true'); + } +} diff --git a/packages/@react-aria/test-utils/src/user.ts b/packages/@react-aria/test-utils/src/user.ts index abae908181a..b1b041c9046 100644 --- a/packages/@react-aria/test-utils/src/user.ts +++ b/packages/@react-aria/test-utils/src/user.ts @@ -16,6 +16,7 @@ import {MenuOptions, MenuTester} from './menu'; import {pointerMap} from './'; import {SelectOptions, SelectTester} from './select'; import {TableOptions, TableTester} from './table'; +import {TreeOptions, TreeTester} from './tree'; import userEvent from '@testing-library/user-event'; // https://github.com/testing-library/dom-testing-library/issues/939#issuecomment-830771708 is an interesting way of allowing users to configure the timers @@ -33,13 +34,21 @@ export interface BaseTesterOpts { root: HTMLElement } -let keyToUtil = {'Select': SelectTester, 'Table': TableTester, 'Menu': MenuTester, 'ComboBox': ComboBoxTester, 'GridList': GridListTester} as const; +let keyToUtil = { + 'Select': SelectTester, + 'Table': TableTester, + 'Tree': TreeTester, + 'Menu': MenuTester, + 'ComboBox': ComboBoxTester, + 'GridList': GridListTester +} as const; export type PatternNames = keyof typeof keyToUtil; // Conditional type: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html type ObjectType = T extends 'Select' ? SelectTester : T extends 'Table' ? TableTester : + T extends 'Tree' ? TreeTester : T extends 'Menu' ? MenuTester : T extends 'ComboBox' ? ComboBoxTester : T extends 'GridList' ? GridListTester : @@ -48,6 +57,7 @@ type ObjectType = type ObjectOptionsTypes = T extends 'Select' ? SelectOptions : T extends 'Table' ? TableOptions : + T extends 'Tree' ? TreeOptions : T extends 'Menu' ? MenuOptions : T extends 'ComboBox' ? ComboBoxOptions : T extends 'GridList' ? GridListOptions : diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index ca743f85b66..87f4ea96abb 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -125,9 +125,11 @@ "@react-aria/test-utils": "1.0.0-alpha.1" }, "dependencies": { + "@react-aria/button": "^3.11.0", "@react-aria/collections": "3.0.0-alpha.6", "@react-aria/i18n": "^3.12.4", "@react-aria/interactions": "^3.22.5", + "@react-aria/tree": "3.0.0-beta.2", "@react-aria/utils": "^3.26.0", "@react-spectrum/utils": "^3.12.0", "@react-stately/layout": "^4.1.0", diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index dc9425565c6..885816ea251 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -22,6 +22,7 @@ import { SubmenuTrigger as AriaSubmenuTrigger, SubmenuTriggerProps as AriaSubmenuTriggerProps, ContextValue, + DEFAULT_SLOT, Provider, Separator, SeparatorProps @@ -478,6 +479,7 @@ export function MenuItem(props: MenuItemProps) { }], [TextContext, { slots: { + [DEFAULT_SLOT]: {styles: label({size})}, label: {styles: label({size})}, description: {styles: description({...renderProps, size})}, value: {styles: value} diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx new file mode 100644 index 00000000000..88c38a0026f --- /dev/null +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -0,0 +1,469 @@ +/* + * Copyright 2024 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 {ActionMenuContext, Checkbox, IconContext, Text, TextContext} from '@react-spectrum/s2'; +import { + ButtonContext, + Collection, + Provider, + TreeItemProps as RACTreeItemProps, + TreeProps as RACTreeProps, + UNSTABLE_Tree, + UNSTABLE_TreeItem, + UNSTABLE_TreeItemContent, + useContextProps +} from 'react-aria-components'; +import Chevron from '../ui-icons/Chevron'; +import {colorMix, lightDark, style} from '../style' with {type: 'macro'}; +import {DOMRef, Key} from '@react-types/shared'; +import {isAndroid} from '@react-aria/utils'; +import {raw} from '../style/style-macro' with {type: 'macro'}; +import React, {createContext, forwardRef, isValidElement, JSXElementConstructor, ReactElement, useContext, useRef} from 'react'; +import {StylesPropWithHeight, UnsafeStyles} from './style-utils'; +import {useButton} from '@react-aria/button'; +import {useDOMRef} from '@react-spectrum/utils'; +import {useLocale} from '@react-aria/i18n'; + +interface S2TreeProps { + // Only detatched is supported right now with the current styles from Spectrum + isDetached?: boolean, + onAction?: (key: Key) => void, + // not fully supported yet + isEmphasized?: boolean +} + +// should we remove disabledBehavior? +export interface TreeViewProps extends Omit, 'style' | 'className' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks'>, UnsafeStyles, S2TreeProps { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight +} + +export interface TreeViewItemProps extends Omit { + /** Whether this item has children, even if not loaded yet. */ + hasChildItems?: boolean, + /** A list of child tree item objects used when dynamically rendering the tree item children. */ + childItems?: Iterable +} + +interface TreeRendererContextValue { + renderer?: (item) => ReactElement> +} +const TreeRendererContext = createContext({}); + +function useTreeRendererContext(): TreeRendererContextValue { + return useContext(TreeRendererContext)!; +} + + +let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean}>({}); + +// TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the +// keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't +// scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview +const tree = style({ + boxSizing: 'border-box', + justifyContent: { + isEmpty: 'center' + }, + alignItems: { + isEmpty: 'center' + }, + width: { + isEmpty: 'full' + }, + height: { + isEmpty: 'full' + }, + display: 'flex', + flexDirection: 'column', + gap: { + isDetached: 2 + }, + '--indent': { + type: 'width', + value: 16 + } +}); + +function TreeView(props: TreeViewProps, ref: DOMRef) { + let {children, isDetached, isEmphasized} = props; + isDetached = true; + isEmphasized = false; + + let renderer; + if (typeof children === 'function') { + renderer = children; + } + + let domRef = useDOMRef(ref); + + return ( + + + tree({isEmpty, isDetached})} + selectionBehavior="toggle" + ref={domRef}> + {props.children} + + + + ); +} + +const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); +const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); + +const rowBackgroundColor = { + default: '--s2-container-bg', + isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 7), + isHovered: colorMix('gray-25', 'gray-900', 7), + isPressed: colorMix('gray-25', 'gray-900', 10), + isSelected: { + default: colorMix('gray-25', 'gray-900', 7), + isEmphasized: selectedBackground, + isFocusVisibleWithin: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + }, + isHovered: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + }, + isPressed: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + } + }, + forcedColors: { + default: 'Background' + } +} as const; + +const treeRow = style({ + position: 'relative', + display: 'flex', + height: 40, + width: 'full', + minWidth: 240, // do we really want this restriction? + boxSizing: 'border-box', + font: 'ui', + color: 'body', + outlineStyle: 'none', + cursor: { + default: 'default', + isLink: 'pointer' + }, + '--rowBackgroundColor': { + type: 'backgroundColor', + value: rowBackgroundColor + }, + '--rowFocusIndicatorColor': { + type: 'outlineColor', + value: { + default: 'focus-ring', + forcedColors: 'Highlight' + } + } +}); + + +const treeCellGrid = style({ + display: 'grid', + width: 'full', + alignItems: 'center', + gridTemplateColumns: ['minmax(0, auto)', 'minmax(0, auto)', 'minmax(0, auto)', 40, 'minmax(0, auto)', '1fr', 'minmax(0, auto)'], + gridTemplateRows: '1fr', + gridTemplateAreas: [ + 'drag-handle checkbox level-padding expand-button icon content actions' + ], + backgroundColor: '--rowBackgroundColor', + color: { + isDisabled: { + default: 'gray-400', + forcedColors: 'GrayText' + } + }, + '--rowSelectedBorderColor': { + type: 'outlineColor', + value: { + default: 'gray-800', + isFocusVisible: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--rowForcedFocusBorderColor': { + type: 'outlineColor', + value: { + default: 'focus-ring', + forcedColors: 'Highlight' + } + }, + borderTopColor: { + default: 'transparent', + isSelected: { + isFirst: '--rowSelectedBorderColor' + }, + isDetached: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor' + } + }, + borderInlineEndColor: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor', + isDetached: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor' + } + }, + borderBottomColor: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor', + isNextSelected: '--rowSelectedBorderColor', + isNextFocused: '--rowForcedFocusBorderColor', + isDetached: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor' + } + }, + borderInlineStartColor: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor', + isDetached: { + default: 'transparent', + isSelected: '--rowSelectedBorderColor' + } + }, + borderTopWidth: { + default: 0, + isFirst: 1, + isDetached: 1 + }, + borderBottomWidth: 1, + borderStartWidth: 1, + borderEndWidth: 1, + borderStyle: 'solid', + borderRadius: { // odd behaviour, if this is the last property, then bottom right isn't rounded + isDetached: '[6px]' + } +}); + +const treeCheckbox = style({ + gridArea: 'checkbox', + marginStart: 12, + marginEnd: 0, + paddingEnd: 0 +}); + +const treeIcon = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual' +}); + +const treeContent = style({ + gridArea: 'content', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden' +}); + +const treeActions = style({ + gridArea: 'actions', + flexGrow: 0, + flexShrink: 0, + /* TODO: I made this one up, confirm desired behavior. These paddings are to make sure the action group has enough padding for the focus ring */ + marginStart: 2, + marginEnd: 4 +}); + +const cellFocus = { + outlineStyle: { + default: 'none', + isFocusVisible: 'solid' + }, + outlineOffset: -2, + outlineWidth: 2, + outlineColor: 'focus-ring', + borderRadius: '[6px]' +} as const; + +const treeRowFocusIndicator = raw(` + &:before { + content: ""; + display: inline-block; + position: sticky; + inset-inline-start: 0; + width: 3px; + height: 100%; + margin-inline-end: -3px; + margin-block-end: 1px; + z-index: 3; + background-color: var(--rowFocusIndicatorColor); + }` +); + + +export const TreeViewItem = (props: TreeViewItemProps) => { + let { + children, + childItems, + hasChildItems, + href + } = props; + + let content; + let nestedRows; + let {renderer} = useTreeRendererContext(); + let {isDetached, isEmphasized} = useContext(InternalTreeContext); + // TODO alternative api is that we have a separate prop for the TreeItems contents and expect the child to then be + // a nested tree item + + if (typeof children === 'string') { + content = {children}; + } else { + content = []; + nestedRows = []; + React.Children.forEach(children, node => { + if (isValidElement(node) && node.type === TreeViewItem) { + nestedRows.push(node); + } else { + content.push(node); + } + }); + } + + if (childItems != null && renderer) { + nestedRows = ( + + {renderer} + + ); + } + + return ( + // TODO right now all the tree rows have the various data attributes applied on their dom nodes, should they? Doesn't feel very useful + treeRow({...renderProps, isLink: !!href, isEmphasized}) + (renderProps.isFocusVisible && !isDetached ? ' ' + treeRowFocusIndicator : '')}> + + {({isExpanded, hasChildRows, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state}) => { + let isNextSelected = false; + let isNextFocused = false; + let keyAfter = state.collection.getKeyAfter(id); + if (keyAfter != null) { + isNextSelected = state.selectionManager.isSelected(keyAfter); + } + let isFirst = state.collection.getFirstKey() === id; + return ( +
+ {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( + // TODO: add transition? +
+ +
+ )} +
+ {/* TODO: revisit when we do async loading, at the moment hasChildItems will only cause the chevron to be rendered, no aria/data attributes indicating the row's expandability are added */} + {(hasChildRows || hasChildItems) && } + + {content} + + {isFocusVisible && isDetached &&
} +
+ ); + }} + + {nestedRows} + + ); +}; + +interface ExpandableRowChevronProps { + isExpanded?: boolean, + isDisabled?: boolean, + isRTL?: boolean +} + +const expandButton = style({ + gridArea: 'expand-button', + height: 'full', + aspectRatio: 'square', + display: 'flex', + flexWrap: 'wrap', + alignContent: 'center', + justifyContent: 'center', + outlineStyle: 'none', + cursor: 'default', + transform: { + isExpanded: { + default: 'rotate(90deg)', + isRTL: 'rotate(-90deg)' + } + }, + transition: 'default' +}); + +function ExpandableRowChevron(props: ExpandableRowChevronProps) { + let expandButtonRef = useRef(null); + // @ts-ignore - check back on this + let [fullProps, ref] = useContextProps({...props, slot: 'chevron'}, expandButtonRef, ButtonContext); + let {isExpanded, isDisabled} = fullProps; + let {direction} = useLocale(); + + // 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({ + ...fullProps, + elementType: 'span' + }, ref); + + return ( + + + + ); +} + +/** + * A tree view provides users with a way to navigate nested hierarchical information. + */ +const _TreeView = forwardRef(TreeView); +export {_TreeView as TreeView}; diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 72da80811e9..0d0a4636940 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -75,6 +75,7 @@ export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextFiel export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext} from './ToggleButtonGroup'; export {Tooltip, TooltipTrigger} from './Tooltip'; +export {TreeView, TreeViewItem} from './TreeView'; export {pressScale} from './pressScale'; @@ -142,4 +143,5 @@ export type {TextFieldProps, TextAreaProps} from './TextField'; export type {ToggleButtonProps} from './ToggleButton'; export type {ToggleButtonGroupProps} from './ToggleButtonGroup'; export type {TooltipProps} from './Tooltip'; +export type {TreeViewProps, TreeViewItemProps} from './TreeView'; export type {FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps} from 'react-aria-components'; diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx new file mode 100644 index 00000000000..1688dae489b --- /dev/null +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -0,0 +1,289 @@ +/** + * Copyright 2020 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 { + ActionMenu, + Content, + Heading, + IllustratedMessage, + Link, + MenuItem, + Text, + TreeView, + TreeViewItem +} from '../src'; +import {categorizeArgTypes} from './utils'; +import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; +import type {Meta} from '@storybook/react'; +import React from 'react'; + +let onActionFunc = action('onAction'); +let noOnAction = null; +const onActionOptions = {onActionFunc, noOnAction}; + + +const meta: Meta = { + component: TreeView, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: { + // Make sure onAction isn't autogenerated + // @ts-ignore + onAction: null + }, + argTypes: { + ...categorizeArgTypes('Events', ['onAction', 'onSelectionChange']), + onAction: { + options: Object.keys(onActionOptions), // An array of serializable values + mapping: onActionOptions, // Maps serializable option values to complex arg values + control: { + type: 'select', // Type 'select' is automatically inferred when 'options' is defined + labels: { + // 'labels' maps option values to string labels + onActionFunc: 'onAction enabled', + noOnAction: 'onAction disabled' + } + }, + table: {category: 'Events'} + } + } +}; + +export default meta; + + +const TreeExampleStatic = (args) => ( +
+ + + Photos + + + + + Edit + + + + Delete + + + + + Projects + + + + + Edit + + + + Delete + + + + Projects-1 + + + + + Edit + + + + Delete + + + + Projects-1A + + + + + Edit + + + + Delete + + + + + + Projects-2 + + + + + Edit + + + + Delete + + + + + Projects-3 + + + + + Edit + + + + Delete + + + + + +
+); + +export const Example = { + render: TreeExampleStatic, + args: { + selectionMode: 'multiple' + } +}; + +let rows = [ + {id: 'projects', name: 'Projects', icon: , childItems: [ + {id: 'project-1', name: 'Project 1', icon: }, + {id: 'project-2', name: 'Project 2', icon: , childItems: [ + {id: 'project-2A', name: 'Project 2A', icon: }, + {id: 'project-2B', name: 'Project 2B', icon: }, + {id: 'project-2C', name: 'Project 2C', icon: } + ]}, + {id: 'project-3', name: 'Project 3', icon: }, + {id: 'project-4', name: 'Project 4', icon: }, + {id: 'project-5', name: 'Project 5', icon: , childItems: [ + {id: 'project-5A', name: 'Project 5A', icon: }, + {id: 'project-5B', name: 'Project 5B', icon: }, + {id: 'project-5C', name: 'Project 5C', icon: } + ]} + ]}, + {id: 'reports', name: 'Reports', icon: , childItems: [ + {id: 'reports-1', name: 'Reports 1', icon: , childItems: [ + {id: 'reports-1A', name: 'Reports 1A', icon: , childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', icon: , childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC', icon: } + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B', icon: }, + {id: 'reports-1C', name: 'Reports 1C', icon: } + ]}, + {id: 'reports-2', name: 'Reports 2', icon: } + ]} +]; + +const TreeExampleDynamic = (args) => ( +
+ + {(item: any) => ( + + {item.name} + {item.icon} + + + + Edit + + + + Delete + + + + )} + +
+); + +export const Dynamic = { + render: TreeExampleDynamic, + args: { + ...Example.args, + disabledKeys: ['Foo 5'] + } +}; + +function renderEmptyState() { + return ( + + + + No results + + + No results found, press here for more info. + + + ); +} + +export const Empty = { + render: TreeExampleDynamic, + args: { + renderEmptyState, + items: [] + } +}; + +const TreeExampleWithLinks = (args) => ( +
+ + {(item) => ( + + {item.name} + {item.icon} + + + + Edit + + + + Delete + + + + )} + +
+); + +export const WithLinks = { + ...Dynamic, + render: TreeExampleWithLinks, + name: 'Tree with links', + parameters: { + description: { + data: 'every tree item should link to adobe.com' + } + } +}; diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index 347120e82b3..c19dd4e4109 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -288,6 +288,19 @@ const borderWidth = { 4: getToken('border-width-400') }; +const borderColor = createColorProperty({ + ...color, + negative: { + default: colorToken('negative-border-color-default'), + isHovered: colorToken('negative-border-color-hover'), + isFocusVisible: colorToken('negative-border-color-key-focus'), + isPressed: colorToken('negative-border-color-down') + }, + disabled: colorToken('disabled-border-color') + // forcedColors: 'GrayText' + +}); + const radius = { none: getToken('corner-radius-none'), // 0px sm: pxToRem(getToken('corner-radius-small-default')), // 4px @@ -550,18 +563,7 @@ export const style = createTheme({ pasteboard: weirdColorToken('background-pasteboard-color'), elevated: weirdColorToken('background-elevated-color') }), - borderColor: createColorProperty({ - ...color, - negative: { - default: colorToken('negative-border-color-default'), - isHovered: colorToken('negative-border-color-hover'), - isFocusVisible: colorToken('negative-border-color-key-focus'), - isPressed: colorToken('negative-border-color-down') - }, - disabled: colorToken('disabled-border-color') - // forcedColors: 'GrayText' - }), outlineColor: createColorProperty({ ...color, 'focus-ring': { @@ -635,6 +637,10 @@ export const style = createTheme({ borderEndWidth: createRenamedProperty('borderInlineEndWidth', borderWidth), borderTopWidth: borderWidth, borderBottomWidth: borderWidth, + borderInlineStartColor: borderColor, // don't know why i can't use renamed + borderInlineEndColor: borderColor, + borderTopColor: borderColor, + borderBottomColor: borderColor, borderStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const, strokeWidth: { 0: '0', @@ -920,6 +926,7 @@ export const style = createTheme({ scrollMarginX: ['scrollMarginStart', 'scrollMarginEnd'] as const, scrollMarginY: ['scrollMarginTop', 'scrollMarginBottom'] as const, borderWidth: ['borderTopWidth', 'borderBottomWidth', 'borderStartWidth', 'borderEndWidth'] as const, + borderColor: ['borderTopColor', 'borderBottomColor', 'borderInlineStartColor', 'borderInlineEndColor'] as const, borderXWidth: ['borderStartWidth', 'borderEndWidth'] as const, borderYWidth: ['borderTopWidth', 'borderBottomWidth'] as const, borderRadius: ['borderTopStartRadius', 'borderTopEndRadius', 'borderBottomStartRadius', 'borderBottomEndRadius'] as const, diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index d7cde4447f8..b82d8d68cac 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -57,7 +57,7 @@ export function createSizingProperty(values: PropertyValueMa return [{[property]: value}, valueMap.get(value)!]; }); } - + if (typeof value === 'number') { let cssValue = value === 0 ? '0px' : fn(value); return {default: [{[property]: cssValue}, generateName(value + valueMap.size)]}; diff --git a/packages/@react-spectrum/s2/test/Tree.test.tsx b/packages/@react-spectrum/s2/test/Tree.test.tsx new file mode 100644 index 00000000000..ac6e468dd3b --- /dev/null +++ b/packages/@react-spectrum/s2/test/Tree.test.tsx @@ -0,0 +1,162 @@ + +import {AriaTreeTests} from '../../../react-aria-components/test/AriaTree.test-util'; +import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; +import { + Text, + TreeView, + TreeViewItem +} from '../src'; + +AriaTreeTests({ + prefix: 'spectrum2-static', + renderers: { + // todo - we don't support isDisabled on TreeViewItems? + standard: () => render( + + + Photos + + + + Projects + + + Projects-1 + + + Projects-1A + + + + + Projects-2 + + + + Projects-3 + + + + + School + + + Homework-1 + + + Homework-1A + + + + + Homework-2 + + + + Homework-3 + + + + + ), + singleSelection: () => render( + + + Photos + + + + Projects + + + Projects-1 + + + Projects-1A + + + + + Projects-2 + + + + Projects-3 + + + + + School + + + Homework-1 + + + Homework-1A + + + + + Homework-2 + + + + Homework-3 + + + + + ), + allInteractionsDisabled: () => render( + + + Photos + + + + Projects + + + Projects-1 + + + Projects-1A + + + + + Projects-2 + + + + Projects-3 + + + + + School + + + Homework-1 + + + Homework-1A + + + + + Homework-2 + + + + Homework-3 + + + + + ) + } +}); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index d73339e506f..b38c8608538 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,7 +15,7 @@ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuIte import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; -import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {FocusStrategy, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection} from '@react-types/shared'; import {HeaderContext} from './Header'; @@ -378,6 +378,7 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent('item', function MenuI values={[ [TextContext, { slots: { + [DEFAULT_SLOT]: labelProps, label: labelProps, description: descriptionProps } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 053dfaa0b95..b994fc015f2 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -276,7 +276,11 @@ export interface TreeItemContentRenderProps extends ItemRenderProps { // What level the tree item has within the tree. level: number, // Whether the tree item's children have keyboard focus. - isFocusVisibleWithin: boolean + isFocusVisibleWithin: boolean, + // The state of the tree. + state: TreeState, + // The unique id of the tree row. + id: Key } // The TreeItemContent is the one that accepts RenderProps because we would get much more complicated logic in TreeItem otherwise since we'd @@ -350,8 +354,10 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', { ); }; -export const TreeExampleStatic = (args) => ( +const TreeExampleStaticRender = (args) => ( Photos @@ -134,7 +134,8 @@ export const TreeExampleStatic = (args) => ( ); -TreeExampleStatic.story = { +export const TreeExampleStatic = { + render: TreeExampleStaticRender, args: { selectionMode: 'none', selectionBehavior: 'toggle', @@ -271,7 +272,7 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { let defaultExpandedKeys = new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB']); -export const TreeExampleDynamic = (args: TreeProps) => ( +const TreeExampleDynamicRender = (args: TreeProps) => ( {(item) => ( @@ -281,22 +282,22 @@ export const TreeExampleDynamic = (args: TreeProps) => ( ); -TreeExampleDynamic.story = { - ...TreeExampleStatic.story, +export const TreeExampleDynamic = { + ...TreeExampleStatic, + render: TreeExampleDynamicRender, parameters: null }; export const WithActions = { - render: TreeExampleDynamic, ...TreeExampleDynamic, args: { onAction: action('onAction'), - ...TreeExampleDynamic.story.args + ...TreeExampleDynamic.args }, name: 'UNSTABLE_Tree with actions' }; -export const WithLinks = (args: TreeProps) => ( +const WithLinksRender = (args: TreeProps) => ( {(item) => ( @@ -306,8 +307,9 @@ export const WithLinks = (args: TreeProps) => ( ); -WithLinks.story = { - ...TreeExampleDynamic.story, +export const WithLinks = { + ...TreeExampleDynamic, + render: WithLinksRender, name: 'UNSTABLE_Tree with links', parameters: { description: { @@ -486,7 +488,7 @@ export const ButtonLoadingIndicatorStory = { } } }; -export function VirtualizedTree(args) { +function VirtualizedTreeRender(args) { let layout = useMemo(() => { return new ListLayout({ rowHeight: 30 @@ -495,9 +497,12 @@ export function VirtualizedTree(args) { return ( - + ); } -VirtualizedTree.story = TreeExampleDynamic.story; +export const VirtualizedTree = { + ...TreeExampleDynamic, + render: VirtualizedTreeRender +}; diff --git a/packages/react-aria-components/test/AriaTree.test-util.tsx b/packages/react-aria-components/test/AriaTree.test-util.tsx new file mode 100644 index 00000000000..282fc134207 --- /dev/null +++ b/packages/react-aria-components/test/AriaTree.test-util.tsx @@ -0,0 +1,225 @@ +/* + * Copyright 2020 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 {act, render, within} from '@testing-library/react'; +import { + pointerMap +} from '@react-spectrum/test-utils-internal'; +import {User} from '@react-aria/test-utils'; +import userEvent from '@testing-library/user-event'; + +let describeInteractions = ((name, tests) => describe.each` + interactionType + ${'mouse'} + ${'keyboard'} + ${'touch'} +`(`${name} - $interactionType`, tests)); + +// @ts-ignore +describeInteractions.only = ((name, tests) => describe.only.each` + interactionType + ${'mouse'} + ${'keyboard'} + ${'touch'} +`(`${name} - $interactionType`, tests)); + +// @ts-ignore +describeInteractions.skip = ((name, tests) => describe.skip.each` + interactionType + ${'mouse'} + ${'keyboard'} + ${'touch'} +`(`${name} - $interactionType`, tests)); + +interface AriaBaseTestProps { + setup?: () => void, + prefix?: string +} +interface AriaTreeTestProps extends AriaBaseTestProps { + renderers: { + // must have an aria-label + standard: (props?: {name: string}) => ReturnType, + // must have an aria-label + singleSelection?: (props?: {name: string}) => ReturnType, + // must have an aria-label + allInteractionsDisabled?: (props?: {name: string}) => ReturnType + } +} +export const AriaTreeTests = ({renderers, setup, prefix}: AriaTreeTestProps) => { + describe(prefix ? prefix + 'AriaTree' : 'AriaTree', function () { + let user; + let testUtilUser = new User(); + setup?.(); + + beforeAll(function () { + jest.useFakeTimers(); + }); + + beforeEach(function () { + user = userEvent.setup({delay: null, pointerMap}); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + it('should have the base set of aria and data attributes', () => { + let root = (renderers.standard!)(); + let treeTester = testUtilUser.createTester('Tree', {user, root: root.container}); + let tree = treeTester.tree; + expect(tree).toHaveAttribute('aria-label'); + + for (let row of treeTester.rows) { + expect(row).toHaveAttribute('aria-level'); + expect(row).toHaveAttribute('aria-posinset'); + expect(row).toHaveAttribute('aria-setsize'); + } + expect(treeTester.rows[0]).not.toHaveAttribute('aria-expanded'); + expect(treeTester.rows[1]).toHaveAttribute('aria-expanded', 'false'); + }); + + describeInteractions('interaction', function ({interactionType}) { + it('should have the expected attributes on the rows', async () => { + let tree = (renderers.standard!)(); + let treeTester = testUtilUser.createTester('Tree', {user, root: tree.container, interactionType}); + await treeTester.expandItem({index: 1}); + await treeTester.expandItem({index: 2}); + + let rows = treeTester.rows; + let rowNoChild = rows[0]; + expect(rowNoChild).toHaveAttribute('aria-label'); + expect(rowNoChild).not.toHaveAttribute('aria-expanded'); + expect(rowNoChild).toHaveAttribute('aria-level', '1'); + expect(rowNoChild).toHaveAttribute('aria-posinset', '1'); + expect(rowNoChild).toHaveAttribute('aria-setsize', '3'); + + let rowWithChildren = rows[1]; + // Row has action since it is expandable but not selectable. + expect(rowWithChildren).toHaveAttribute('aria-expanded', 'true'); + expect(rowWithChildren).toHaveAttribute('aria-level', '1'); + expect(rowWithChildren).toHaveAttribute('aria-posinset', '2'); + expect(rowWithChildren).toHaveAttribute('aria-setsize', '3'); + + let level2ChildRow = rows[2]; + expect(level2ChildRow).toHaveAttribute('aria-expanded', 'true'); + expect(level2ChildRow).toHaveAttribute('data-expanded', 'true'); + expect(level2ChildRow).toHaveAttribute('aria-level', '2'); + expect(level2ChildRow).toHaveAttribute('aria-posinset', '1'); + expect(level2ChildRow).toHaveAttribute('aria-setsize', '3'); + + let level3ChildRow = rows[3]; + expect(level3ChildRow).not.toHaveAttribute('aria-expanded'); + expect(level3ChildRow).toHaveAttribute('aria-level', '3'); + expect(level3ChildRow).toHaveAttribute('aria-posinset', '1'); + expect(level3ChildRow).toHaveAttribute('aria-setsize', '1'); + + let level2ChildRow2 = rows[4]; + expect(level2ChildRow2).not.toHaveAttribute('aria-expanded'); + expect(level2ChildRow2).toHaveAttribute('aria-level', '2'); + expect(level2ChildRow2).toHaveAttribute('aria-posinset', '2'); + expect(level2ChildRow2).toHaveAttribute('aria-setsize', '3'); + + let level2ChildRow3 = rows[5]; + expect(level2ChildRow3).not.toHaveAttribute('aria-expanded'); + expect(level2ChildRow3).toHaveAttribute('aria-level', '2'); + expect(level2ChildRow3).toHaveAttribute('aria-posinset', '3'); + expect(level2ChildRow3).toHaveAttribute('aria-setsize', '3'); + + // Collapse the first row and make sure it's collpased and that the inner rows are gone + await treeTester.collapseItem({index: 1}); + expect(rowWithChildren).toHaveAttribute('aria-expanded', 'false'); + expect(level2ChildRow).not.toBeInTheDocument(); + }); + }); + + if (renderers.singleSelection) { + describe('single selection', function () { + describeInteractions('interaction', function ({interactionType}) { + // todo add test for using Space on the row to select it + it('can select items', async () => { + let tree = (renderers.singleSelection!)(); + let treeTester = testUtilUser.createTester('Tree', {user, root: tree.container, interactionType}); + + let rows = treeTester.rows; + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + // disabled rows should not be selectable + expect(rows[2]).not.toHaveAttribute('aria-selected'); + expect(within(rows[2]).getByRole('checkbox')).toHaveAttribute('disabled'); + + await treeTester.toggleRowSelection({index: 0}); + expect(rows[0]).toHaveAttribute('aria-selected', 'true'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + + await treeTester.toggleRowSelection({index: 1}); + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + + await treeTester.toggleRowSelection({index: 2}); + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).not.toHaveAttribute('aria-selected'); + + await treeTester.expandItem({index: 1}); + rows = treeTester.rows; + // row 2 is now the subrow of row 1 because we expanded it + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + + await treeTester.toggleRowSelection({index: 2}); + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + + // collapse and re-expand to make sure the selection persists + await treeTester.collapseItem({index: 1}); + await treeTester.expandItem({index: 1}); + rows = treeTester.rows; + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + + await treeTester.toggleRowSelection({index: 2}); + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + + await treeTester.collapseItem({index: 1}); + // items inside a disabled item can be selected + await treeTester.expandItem({index: 2}); + rows = treeTester.rows; + + await treeTester.toggleRowSelection({index: 3}); + expect(rows[3]).toHaveAttribute('aria-selected', 'true'); + }); + }); + }); + } + + if (renderers.allInteractionsDisabled) { + describe('all interactions disabled', function () { + describeInteractions('interaction', function ({interactionType}) { + it('should not be able to interact with the tree', async () => { + let tree = (renderers.allInteractionsDisabled!)(); + let treeTester = testUtilUser.createTester('Tree', {user, root: tree.container, interactionType}); + + let rows = treeTester.rows; + expect(rows[2]).toHaveAttribute('aria-expanded', 'false'); + + await treeTester.expandItem({index: 2}); + expect(rows[2]).toHaveAttribute('aria-expanded', 'false'); + + await treeTester.toggleRowSelection({index: 2}); + expect(rows[2]).not.toHaveAttribute('aria-selected'); + }); + }); + }); + } + }); +}; diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 89b2fdc064e..e62a810b70a 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -11,6 +11,7 @@ */ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {AriaTreeTests} from './AriaTree.test-util'; import {Button, Checkbox, Collection, UNSTABLE_ListLayout as ListLayout, Text, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, UNSTABLE_Virtualizer as Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import React from 'react'; @@ -187,20 +188,14 @@ describe('Tree', () => { expect(tree).toHaveAttribute('style', expect.stringContaining('width: 200px')); }); - it('should have the base set of aria and data attributes', () => { + it('should have the base set of data attributes', () => { let {getByRole, getAllByRole} = render(); let tree = getByRole('treegrid'); - expect(tree).toHaveAttribute('data-rac'); - expect(tree).toHaveAttribute('aria-label', 'test tree'); expect(tree).not.toHaveAttribute('data-empty'); expect(tree).not.toHaveAttribute('data-focused'); expect(tree).not.toHaveAttribute('data-focus-visible'); for (let row of getAllByRole('row')) { - expect(row).toHaveAttribute('aria-level'); - expect(row).toHaveAttribute('data-level'); - expect(row).toHaveAttribute('aria-posinset'); - expect(row).toHaveAttribute('aria-setsize'); expect(row).toHaveAttribute('data-rac'); expect(row).not.toHaveAttribute('data-selected'); expect(row).not.toHaveAttribute('data-disabled'); @@ -220,66 +215,43 @@ describe('Tree', () => { expect(rowNoChild).toHaveAttribute('aria-label', 'Photos'); expect(rowNoChild).not.toHaveAttribute('aria-expanded'); expect(rowNoChild).not.toHaveAttribute('data-expanded'); - expect(rowNoChild).toHaveAttribute('aria-level', '1'); expect(rowNoChild).toHaveAttribute('data-level', '1'); - expect(rowNoChild).toHaveAttribute('aria-posinset', '1'); - expect(rowNoChild).toHaveAttribute('aria-setsize', '2'); expect(rowNoChild).not.toHaveAttribute('data-has-child-rows'); expect(rowNoChild).toHaveAttribute('data-rac'); let rowWithChildren = rows[1]; // Row has action since it is expandable but not selectable. expect(rowWithChildren).toHaveAttribute('aria-label', 'Projects'); - expect(rowWithChildren).toHaveAttribute('aria-expanded', 'true'); expect(rowWithChildren).toHaveAttribute('data-expanded', 'true'); - expect(rowWithChildren).toHaveAttribute('aria-level', '1'); expect(rowWithChildren).toHaveAttribute('data-level', '1'); - expect(rowWithChildren).toHaveAttribute('aria-posinset', '2'); - expect(rowWithChildren).toHaveAttribute('aria-setsize', '2'); expect(rowWithChildren).toHaveAttribute('data-has-child-rows', 'true'); expect(rowWithChildren).toHaveAttribute('data-rac'); let level2ChildRow = rows[2]; expect(level2ChildRow).toHaveAttribute('aria-label', 'Projects-1'); - expect(level2ChildRow).toHaveAttribute('aria-expanded', 'true'); expect(level2ChildRow).toHaveAttribute('data-expanded', 'true'); - expect(level2ChildRow).toHaveAttribute('aria-level', '2'); expect(level2ChildRow).toHaveAttribute('data-level', '2'); - expect(level2ChildRow).toHaveAttribute('aria-posinset', '1'); - expect(level2ChildRow).toHaveAttribute('aria-setsize', '3'); expect(level2ChildRow).toHaveAttribute('data-has-child-rows', 'true'); expect(level2ChildRow).toHaveAttribute('data-rac'); let level3ChildRow = rows[3]; expect(level3ChildRow).toHaveAttribute('aria-label', 'Projects-1A'); - expect(level3ChildRow).not.toHaveAttribute('aria-expanded'); expect(level3ChildRow).not.toHaveAttribute('data-expanded'); - expect(level3ChildRow).toHaveAttribute('aria-level', '3'); expect(level3ChildRow).toHaveAttribute('data-level', '3'); - expect(level3ChildRow).toHaveAttribute('aria-posinset', '1'); - expect(level3ChildRow).toHaveAttribute('aria-setsize', '1'); expect(level3ChildRow).not.toHaveAttribute('data-has-child-rows'); expect(level3ChildRow).toHaveAttribute('data-rac'); let level2ChildRow2 = rows[4]; expect(level2ChildRow2).toHaveAttribute('aria-label', 'Projects-2'); - expect(level2ChildRow2).not.toHaveAttribute('aria-expanded'); expect(level2ChildRow2).not.toHaveAttribute('data-expanded'); - expect(level2ChildRow2).toHaveAttribute('aria-level', '2'); expect(level2ChildRow2).toHaveAttribute('data-level', '2'); - expect(level2ChildRow2).toHaveAttribute('aria-posinset', '2'); - expect(level2ChildRow2).toHaveAttribute('aria-setsize', '3'); expect(level2ChildRow2).not.toHaveAttribute('data-has-child-rows'); expect(level2ChildRow2).toHaveAttribute('data-rac'); let level2ChildRow3 = rows[5]; expect(level2ChildRow3).toHaveAttribute('aria-label', 'Projects-3'); - expect(level2ChildRow3).not.toHaveAttribute('aria-expanded'); expect(level2ChildRow3).not.toHaveAttribute('data-expanded'); - expect(level2ChildRow3).toHaveAttribute('aria-level', '2'); expect(level2ChildRow3).toHaveAttribute('data-level', '2'); - expect(level2ChildRow3).toHaveAttribute('aria-posinset', '3'); - expect(level2ChildRow3).toHaveAttribute('aria-setsize', '3'); expect(level2ChildRow3).not.toHaveAttribute('data-has-child-rows'); expect(level2ChildRow3).toHaveAttribute('data-rac'); }); @@ -288,9 +260,7 @@ describe('Tree', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows[1]).toHaveAttribute('aria-label', 'Projects'); expect(rows[1]).toHaveAttribute('data-has-child-rows', 'true'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); }); it('should support dynamic trees', () => { @@ -871,22 +841,14 @@ describe('Tree', () => { await user.tab(); expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(rows[0]).toHaveAttribute('aria-level', '1'); - expect(rows[0]).toHaveAttribute('aria-posinset', '1'); - expect(rows[0]).toHaveAttribute('aria-setsize', '2'); expect(rows[0]).toHaveAttribute('data-has-child-rows', 'true'); expect(onExpandedChange).toHaveBeenCalledTimes(0); // Check we can open/close a top level row await trigger(rows[0], 'Enter'); expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); expect(rows[0]).not.toHaveAttribute('data-expanded'); - expect(rows[0]).toHaveAttribute('aria-level', '1'); - expect(rows[0]).toHaveAttribute('aria-posinset', '1'); - expect(rows[0]).toHaveAttribute('aria-setsize', '2'); expect(rows[0]).toHaveAttribute('data-has-child-rows', 'true'); expect(onExpandedChange).toHaveBeenCalledTimes(1); // Note that the children of the parent row will still be in the "expanded" array @@ -896,11 +858,7 @@ describe('Tree', () => { await trigger(rows[0], 'Enter'); expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(rows[0]).toHaveAttribute('aria-level', '1'); - expect(rows[0]).toHaveAttribute('aria-posinset', '1'); - expect(rows[0]).toHaveAttribute('aria-setsize', '2'); expect(rows[0]).toHaveAttribute('data-has-child-rows', 'true'); expect(onExpandedChange).toHaveBeenCalledTimes(2); expect(new Set(onExpandedChange.mock.calls[1][0])).toEqual(new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB'])); @@ -910,27 +868,15 @@ describe('Tree', () => { await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[2]); - expect(rows[2]).toHaveAttribute('aria-expanded', 'true'); expect(rows[2]).toHaveAttribute('data-expanded', 'true'); - expect(rows[2]).toHaveAttribute('aria-level', '2'); - expect(rows[2]).toHaveAttribute('aria-posinset', '2'); - expect(rows[2]).toHaveAttribute('aria-setsize', '5'); expect(rows[2]).toHaveAttribute('data-has-child-rows', 'true'); // Check we can close a nested row and it doesn't affect the parent await trigger(rows[2], 'ArrowLeft'); expect(document.activeElement).toBe(rows[2]); - expect(rows[2]).toHaveAttribute('aria-expanded', 'false'); expect(rows[2]).not.toHaveAttribute('data-expanded'); - expect(rows[2]).toHaveAttribute('aria-level', '2'); - expect(rows[2]).toHaveAttribute('aria-posinset', '2'); - expect(rows[2]).toHaveAttribute('aria-setsize', '5'); expect(rows[2]).toHaveAttribute('data-has-child-rows', 'true'); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(rows[0]).toHaveAttribute('aria-level', '1'); - expect(rows[0]).toHaveAttribute('aria-posinset', '1'); - expect(rows[0]).toHaveAttribute('aria-setsize', '2'); expect(rows[0]).toHaveAttribute('data-has-child-rows', 'true'); expect(onExpandedChange).toHaveBeenCalledTimes(3); expect(new Set(onExpandedChange.mock.calls[2][0])).toEqual(new Set(['projects', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB'])); @@ -956,75 +902,6 @@ describe('Tree', () => { expect(rows).toHaveLength(17); }); - it('should not expand/collapse if disabledBehavior is "all" and the row is disabled', async () => { - let {getAllByRole, rerender} = render(); - let rows = getAllByRole('row'); - expect(rows).toHaveLength(20); - - await user.tab(); - // Since first row is disabled, we can't keyboard focus it - expect(document.activeElement).toBe(rows[1]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); - expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(rows[0]).toHaveAttribute('aria-disabled', 'true'); - expect(rows[0]).toHaveAttribute('data-disabled', 'true'); - expect(onExpandedChange).toHaveBeenCalledTimes(0); - - // Try clicking on first row - await trigger(rows[0], 'Space'); - expect(document.activeElement).toBe(rows[1]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); - expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(onExpandedChange).toHaveBeenCalledTimes(0); - - rerender(); - await user.tab(); - rows = getAllByRole('row'); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); - expect(rows[0]).not.toHaveAttribute('data-expanded'); - expect(rows[0]).toHaveAttribute('aria-disabled', 'true'); - expect(rows[0]).toHaveAttribute('data-disabled', 'true'); - expect(onExpandedChange).toHaveBeenCalledTimes(0); - - await trigger(rows[0], 'Space'); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); - expect(rows[0]).not.toHaveAttribute('data-expanded'); - expect(onExpandedChange).toHaveBeenCalledTimes(0); - }); - - it('should expand/collapse if disabledBehavior is "selection" and the row is disabled', async () => { - let {getAllByRole} = render(); - let rows = getAllByRole('row'); - - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); - expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(onExpandedChange).toHaveBeenCalledTimes(0); - expect(onSelectionChange).toHaveBeenCalledTimes(0); - - // Since selection is enabled, we need to click the chevron even for disabled rows since it is still regarded as the primary action - let chevron = within(rows[0]).getAllByRole('button')[0]; - await trigger(chevron, 'ArrowLeft'); - // TODO: reenable this when we make it so the chevron button isn't focusable via click - // expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); - expect(rows[0]).not.toHaveAttribute('data-expanded'); - expect(onExpandedChange).toHaveBeenCalledTimes(1); - expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set(['project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB'])); - expect(onSelectionChange).toHaveBeenCalledTimes(0); - - await trigger(chevron); - expect(rows[0]).toHaveAttribute('aria-expanded', 'true'); - expect(rows[0]).toHaveAttribute('data-expanded', 'true'); - expect(onExpandedChange).toHaveBeenCalledTimes(2); - expect(new Set(onExpandedChange.mock.calls[1][0])).toEqual(new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB'])); - expect(onSelectionChange).toHaveBeenCalledTimes(0); - - let disabledCheckbox = within(rows[0]).getByRole('checkbox'); - expect(disabledCheckbox).toHaveAttribute('disabled'); - }); - it('should not expand when clicking/using Enter on the row if the row is selectable', async () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); @@ -1119,35 +996,6 @@ describe('Tree', () => { }); }); - it('should support controlled expansion', async () => { - function ControlledTree() { - let [expandedKeys, setExpandedKeys] = React.useState(new Set([])); - - return ( - - ); - } - - let {getAllByRole} = render(); - let rows = getAllByRole('row'); - expect(rows).toHaveLength(2); - - await user.tab(); - expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); - expect(rows[0]).not.toHaveAttribute('data-expanded'); - - await user.click(rows[0]); - rows = getAllByRole('row'); - expect(rows).toHaveLength(7); - - await user.click(rows[0]); - expect(rows[0]).toHaveAttribute('aria-expanded', 'false'); - expect(rows[0]).not.toHaveAttribute('data-expanded'); - rows = getAllByRole('row'); - expect(rows).toHaveLength(2); - }); - it('should apply the proper attributes to the chevron', async () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); @@ -1325,3 +1173,177 @@ describe('Tree', () => { }); }); }); + + +AriaTreeTests({ + prefix: 'rac-static', + renderers: { + standard: () => render( + + Photos + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + + + Homework-1A + + + + Homework-2 + + + Homework-3 + + + + ), + singleSelection: () => render( + + Photos + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + + + Homework-1A + + + + Homework-2 + + + Homework-3 + + + + ), + allInteractionsDisabled: () => render( + + Photos + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + + + Homework-1A + + + + Homework-2 + + + Homework-3 + + + + ) + } +}); + +let controlledRows = [ + {id: 'photos', name: 'Photos 1'}, + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1', childItems: [ + {id: 'project-1A', name: 'Project 1A'} + ]}, + {id: 'project-2', name: 'Project 2'}, + {id: 'project-3', name: 'Project 3'} + ]}, + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A'} + ]}, + {id: 'reports-2', name: 'Reports 2'}, + {id: 'reports-3', name: 'Reports 3'} + ]} +]; + +let ControlledDynamicTreeItem = (props) => { + return ( + + + {({isExpanded, hasChildRows, selectionMode, selectionBehavior}) => ( + <> + {(selectionMode !== 'none' || props.href != null) && selectionBehavior === 'toggle' && ( + + )} + {hasChildRows && } + {props.title || props.children} + + + + )} + + + {(item: any) => ( + + {item.name} + + )} + + + ); +}; + +function ControlledDynamicTree(props) { + let [expanded, setExpanded] = React.useState(new Set([])); + + return ( + + {(item: any) => ( + + {item.name} + + )} + + ); +} + +AriaTreeTests({ + prefix: 'rac-controlled-dynamic', + renderers: { + standard: () => render( + + ), + singleSelection: () => render( + + ), + allInteractionsDisabled: () => render( + + ) + } +}); diff --git a/yarn.lock b/yarn.lock index 11bda9e12d8..7426be0ebc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7850,10 +7850,12 @@ __metadata: dependencies: "@adobe/spectrum-tokens": "npm:^13.0.0-beta.53" "@parcel/macros": "npm:^2.13.0" + "@react-aria/button": "npm:^3.11.0" "@react-aria/collections": "npm:3.0.0-alpha.6" "@react-aria/i18n": "npm:^3.12.4" "@react-aria/interactions": "npm:^3.22.5" "@react-aria/test-utils": "npm:1.0.0-alpha.1" + "@react-aria/tree": "npm:3.0.0-beta.2" "@react-aria/utils": "npm:^3.26.0" "@react-spectrum/utils": "npm:^3.12.0" "@react-stately/layout": "npm:^4.1.0"