diff --git a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx index f2fff3b9725..5a0edf91c54 100644 --- a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {ActionMenu, Collection, Content, Heading, IllustratedMessage, Link, MenuItem, Text, TreeItemContent, TreeView, TreeViewItem} from '../src'; +import {ActionMenu, Collection, Content, Heading, IllustratedMessage, Link, MenuItem, Text, TreeView, TreeViewItem, TreeViewItemContent} from '../src'; 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'; @@ -38,7 +38,7 @@ function TreeExample(props) { expandedKeys={['projects']}> - + Photos @@ -51,10 +51,10 @@ function TreeExample(props) { Delete - + - + Projects @@ -67,9 +67,9 @@ function TreeExample(props) { Delete - + - + Projects-1 @@ -82,9 +82,9 @@ function TreeExample(props) { Delete - + - + Projects-1A @@ -97,11 +97,11 @@ function TreeExample(props) { Delete - + - + Projects-2 @@ -114,10 +114,10 @@ function TreeExample(props) { Delete - + - + Projects-3 @@ -130,7 +130,7 @@ function TreeExample(props) { Delete - + @@ -225,7 +225,7 @@ const DynamicTreeItem = (props) => { return ( <> - + {name} {icon} @@ -238,7 +238,7 @@ const DynamicTreeItem = (props) => { Delete - + {(item: any) => ( ) { - tree({isEmpty, isDetached}, props.styles)} selectionBehavior="toggle" ref={domRef}> {props.children} - + @@ -306,7 +306,7 @@ export const TreeViewItem = (props: TreeViewItemProps) => { let {isDetached, isEmphasized} = useContext(InternalTreeContext); return ( - treeRow({ ...renderProps, @@ -315,7 +315,7 @@ export const TreeViewItem = (props: TreeViewItemProps) => { ); }; -export const TreeItemContent = (props: Omit & {children: ReactNode}) => { +export const TreeViewItemContent = (props: Omit & {children: ReactNode}) => { let { children } = props; @@ -323,7 +323,7 @@ export const TreeItemContent = (props: Omit & let scale = useScale(); return ( - + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state}) => { let isNextSelected = false; let isNextFocused = false; @@ -365,7 +365,7 @@ export const TreeItemContent = (props: Omit & ); }} - + ); }; diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 52c6cf7fce0..7a7e0fbb0be 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -76,7 +76,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, TreeItemContent} from './TreeView'; +export {TreeView, TreeViewItem, TreeViewItemContent} from './TreeView'; export {pressScale} from './pressScale'; diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index e272ae1d5c4..f1738c6df58 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -20,9 +20,9 @@ import { Link, MenuItem, Text, - TreeItemContent, TreeView, - TreeViewItem + TreeViewItem, + TreeViewItemContent } from '../src'; import {categorizeArgTypes} from './utils'; import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; @@ -79,7 +79,7 @@ const TreeExampleStatic = (args) => ( onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}> - + Photos @@ -92,10 +92,10 @@ const TreeExampleStatic = (args) => ( Delete - + - + Projects @@ -108,9 +108,9 @@ const TreeExampleStatic = (args) => ( Delete - + - + Projects-1 @@ -123,9 +123,9 @@ const TreeExampleStatic = (args) => ( Delete - + - + Projects-1A @@ -138,11 +138,11 @@ const TreeExampleStatic = (args) => ( Delete - + - + Projects-2 @@ -155,10 +155,10 @@ const TreeExampleStatic = (args) => ( Delete - + - + Projects-3 @@ -171,7 +171,7 @@ const TreeExampleStatic = (args) => ( Delete - + @@ -221,7 +221,7 @@ const DynamicTreeItem = (props) => { return ( <> - + {name} {icon} @@ -234,7 +234,7 @@ const DynamicTreeItem = (props) => { Delete - + {(item: any) => ( render( - + Photos - + - + Projects - + - + Projects-1 - + - + Projects-1A - + - + Projects-2 - + - + Projects-3 - + - + School - + - + Homework-1 - + - + Homework-1A - + - + Homework-2 - + - + Homework-3 - + @@ -101,69 +101,69 @@ AriaTreeTests({ singleSelection: () => render( - + Photos - + - + Projects - + - + Projects-1 - + - + Projects-1A - + - + Projects-2 - + - + Projects-3 - + - + School - + - + Homework-1 - + - + Homework-1A - + - + Homework-2 - + - + Homework-3 - + @@ -171,69 +171,69 @@ AriaTreeTests({ 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-spectrum/tree/chromatic-fc/TreeView.stories.tsx b/packages/@react-spectrum/tree/chromatic-fc/TreeView.stories.tsx index 19728d0823c..00cec78bf0e 100644 --- a/packages/@react-spectrum/tree/chromatic-fc/TreeView.stories.tsx +++ b/packages/@react-spectrum/tree/chromatic-fc/TreeView.stories.tsx @@ -19,7 +19,7 @@ import FileTxt from '@spectrum-icons/workflow/FileTxt'; import Folder from '@spectrum-icons/workflow/Folder'; import React from 'react'; import {Text} from '@react-spectrum/text'; -import {TreeItemContent, TreeView, TreeViewItem} from '../src'; +import {TreeView, TreeViewItem, TreeViewItemContent} from '../src'; export default { title: 'TreeView' @@ -30,7 +30,7 @@ function TestTree(props) {
- + Photos @@ -43,10 +43,10 @@ function TestTree(props) { Delete - + - + Projects @@ -59,9 +59,9 @@ function TestTree(props) { Delete - + - + Projects-1 @@ -74,9 +74,9 @@ function TestTree(props) { Delete - + - + Projects-1A @@ -89,11 +89,11 @@ function TestTree(props) { Delete - + - + Projects-2 @@ -106,13 +106,13 @@ function TestTree(props) { Delete - + - + Projects-3 - + diff --git a/packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx b/packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx index 20fc3b429c5..df3441545c9 100644 --- a/packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx +++ b/packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx @@ -24,7 +24,7 @@ import {Heading, Text} from '@react-spectrum/text'; import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; import {Meta} from '@storybook/react'; import React from 'react'; -import {SpectrumTreeViewProps, TreeItemContent, TreeView, TreeViewItem} from '../src'; +import {SpectrumTreeViewProps, TreeView, TreeViewItem, TreeViewItemContent} from '../src'; let states = [ {selectionMode: ['multiple', 'single']}, @@ -78,7 +78,7 @@ const Template = ({combos}) => ( - + Photos @@ -91,10 +91,10 @@ const Template = ({combos}) => ( Delete - + - + Projects @@ -107,9 +107,9 @@ const Template = ({combos}) => ( Delete - + - + Projects-1 @@ -122,9 +122,9 @@ const Template = ({combos}) => ( Delete - + - + Projects-1A @@ -137,11 +137,11 @@ const Template = ({combos}) => ( Delete - + - + Projects-2 @@ -154,13 +154,13 @@ const Template = ({combos}) => ( Delete - + - + Projects-3 - + @@ -190,9 +190,9 @@ const EmptyTemplate = () => renderEmptyState={renderEmptyState}> {() => ( - + dummy content - + )} diff --git a/packages/@react-spectrum/tree/docs/TreeView.mdx b/packages/@react-spectrum/tree/docs/TreeView.mdx index 44d74459b20..273ea9f537f 100644 --- a/packages/@react-spectrum/tree/docs/TreeView.mdx +++ b/packages/@react-spectrum/tree/docs/TreeView.mdx @@ -26,7 +26,7 @@ import Image from '@spectrum-icons/workflow/Image'; import Edit from '@spectrum-icons/workflow/Edit'; import Delete from '@spectrum-icons/workflow/Delete'; import {Text} from '@react-spectrum/text'; -import {Collection, TreeView, TreeViewItem, TreeItemContent} from '@react-spectrum/tree'; +import {Collection, TreeView, TreeViewItem, TreeViewItemContent} from '@react-spectrum/tree'; import {JSX} from "react"; import {Key} from "@react-types/shared"; import {ActionGroup, Item} from '@react-spectrum/actiongroup'; @@ -54,57 +54,57 @@ keywords: [tree, grid] ```tsx example - + Documents - + - + Project A - + - + Weekly Report - + - + Document 1 - + - + Document 2 - + - + Photos - + - + Image 1 - + - + Image 2 - + - + Image 3 - + @@ -146,10 +146,10 @@ const DynamicTreeItem = (props) => { return ( <> - + {props.name} {props.icon} - + {(item: any) => ( - + Bookmarks - + - + Adobe - + - + Google - + - + New York Times - + @@ -421,7 +421,7 @@ The `` component works with frameworks and client side routers lik {(item: MyItem) => ( - + {item.name} {item.icon} alert(`Item: ${item.id}, Action: ${key}`)}> @@ -434,7 +434,7 @@ The `` component works with frameworks and client side routers lik Delete - + )} @@ -446,7 +446,7 @@ The `` component works with frameworks and client side routers lik {(item: MyItem) => ( - + {item.name} {item.icon} alert(`Item: ${item.id}, Action: ${key}`)}> @@ -459,7 +459,7 @@ The `` component works with frameworks and client side routers lik Delete - + )} @@ -471,9 +471,9 @@ The `` component works with frameworks and client side routers lik -### TreeItemContent props +### TreeViewItemContent props - + ### TreeViewItem props diff --git a/packages/@react-spectrum/tree/src/TreeView.tsx b/packages/@react-spectrum/tree/src/TreeView.tsx index a42d0152a93..56ebaf2f718 100644 --- a/packages/@react-spectrum/tree/src/TreeView.tsx +++ b/packages/@react-spectrum/tree/src/TreeView.tsx @@ -11,7 +11,18 @@ */ import {AriaTreeGridListProps} from '@react-aria/tree'; -import {ButtonContext, TreeItemContentProps, TreeItemContentRenderProps, TreeItemProps, TreeItemRenderProps, TreeRenderProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, useContextProps} from 'react-aria-components'; +import { + ButtonContext, + Tree, + TreeItem, + TreeItemContent, + TreeItemContentProps, + TreeItemContentRenderProps, + TreeItemProps, + TreeItemRenderProps, + TreeRenderProps, + useContextProps +} from 'react-aria-components'; import {Checkbox} from '@react-spectrum/checkbox'; import ChevronLeftMedium from '@spectrum-icons/ui/ChevronLeftMedium'; import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; @@ -97,9 +108,9 @@ export const TreeView = React.forwardRef(function TreeView(pro return ( - tree({isEmpty})} selectionBehavior={selectionBehavior as SelectionBehavior} ref={domRef}> + tree({isEmpty})} selectionBehavior={selectionBehavior as SelectionBehavior} ref={domRef}> {props.children} - + ); }) as (props: SpectrumTreeViewProps & {ref?: DOMRef}) => ReactElement; @@ -223,7 +234,7 @@ export const TreeViewItem = (props: SpectrumTreeViewItemProps< } = props; return ( - treeRow({ ...renderProps, @@ -233,13 +244,13 @@ export const TreeViewItem = (props: SpectrumTreeViewItemProps< }; -export const TreeItemContent = (props: Omit & {children: ReactNode}) => { +export const TreeViewItemContent = (props: Omit & {children: ReactNode}) => { let { children } = props; return ( - + {({isExpanded, hasChildItems, level, selectionMode, selectionBehavior, isDisabled, isSelected, isFocusVisible}) => (
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( @@ -275,7 +286,7 @@ export const TreeItemContent = (props: Omit &
)} - + ); }; diff --git a/packages/@react-spectrum/tree/src/index.ts b/packages/@react-spectrum/tree/src/index.ts index c6b9cae2689..b74185a6188 100644 --- a/packages/@react-spectrum/tree/src/index.ts +++ b/packages/@react-spectrum/tree/src/index.ts @@ -12,6 +12,6 @@ /// -export {TreeViewItem, TreeView, TreeItemContent} from './TreeView'; +export {TreeViewItem, TreeView, TreeViewItemContent} from './TreeView'; export {Collection} from 'react-aria-components'; export type {SpectrumTreeViewProps, SpectrumTreeViewItemProps} from './TreeView'; diff --git a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx index 2d68536f804..2c99783ec8e 100644 --- a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx @@ -22,7 +22,7 @@ import {Heading, Text} from '@react-spectrum/text'; import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; import {Link} from '@react-spectrum/link'; import React from 'react'; -import {SpectrumTreeViewProps, TreeItemContent, TreeView, TreeViewItem} from '../src'; +import {SpectrumTreeViewProps, TreeView, TreeViewItem, TreeViewItemContent} from '../src'; export default { title: 'TreeView', @@ -48,7 +48,7 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => (
- + Photos @@ -61,10 +61,10 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + - + Projects @@ -77,9 +77,9 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + - + Projects-1 @@ -92,9 +92,9 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + - + Projects-1A @@ -107,11 +107,11 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + - + Projects-2 @@ -124,10 +124,10 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + - + Projects-3 @@ -140,7 +140,7 @@ export const TreeExampleStatic = (args: SpectrumTreeViewProps) => ( Delete - + @@ -209,7 +209,7 @@ const DynamicTreeItem = (props) => { return ( <> - + {name} {icon} @@ -222,7 +222,7 @@ const DynamicTreeItem = (props) => { Delete - + {(item: any) => ( ( - + Photos @@ -46,10 +46,10 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + - + Projects @@ -62,9 +62,9 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + - + Projects-1 @@ -77,9 +77,9 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + - + Projects-1A @@ -92,11 +92,11 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + - + Projects-2 @@ -109,10 +109,10 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + - + Projects-3 @@ -125,7 +125,7 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Delete - + @@ -166,7 +166,7 @@ const DynamicTreeItem = (props) => { return ( <> - + {name} @@ -178,7 +178,7 @@ const DynamicTreeItem = (props) => { Delete - + {(item: any) => ( { let {getAllByRole} = render( - + Test - + ); @@ -488,9 +488,9 @@ describe('Tree', () => { let {getAllByRole} = render( - + Test - + ); @@ -1238,9 +1238,9 @@ describe('Tree', () => { let tree = render( - + Test - + ); @@ -1252,9 +1252,9 @@ describe('Tree', () => { tree, - + Test - + ); @@ -1266,9 +1266,9 @@ describe('Tree', () => { tree, - + Test - + ); diff --git a/packages/dev/docs/pages/assets/component-illustrations/Tree.svg b/packages/dev/docs/pages/assets/component-illustrations/Tree.svg new file mode 100644 index 00000000000..11283245e7a --- /dev/null +++ b/packages/dev/docs/pages/assets/component-illustrations/Tree.svg @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + Documents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 12 items + + + + + Onboarding + PDF + + + + + Budget + XLS + + + + + Sales Pitch + PPT + + + + + + + + + + + + + + + + + + + + + + + + + + Documents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 12 items + + + + + Onboarding + PDF + + + + + Budget + XLS + + + + + Sales Pitch + PPT + + + + + + + + + + + + + + + + + + + + diff --git a/packages/dev/docs/pages/react-aria/components.mdx b/packages/dev/docs/pages/react-aria/components.mdx index d16f953fa8b..7b9af915e0a 100644 --- a/packages/dev/docs/pages/react-aria/components.mdx +++ b/packages/dev/docs/pages/react-aria/components.mdx @@ -38,6 +38,7 @@ import Menu from '../assets/component-illustrations/Menu.svg'; import ListBox from '../assets/component-illustrations/ListBox.svg'; import ListView from '../assets/component-illustrations/ListView.svg'; import Table from '../assets/component-illustrations/Table.svg'; +import Tree from '../assets/component-illustrations/Tree.svg'; import Calendar from '../assets/component-illustrations/Calendar.svg'; import RangeCalendar from '../assets/component-illustrations/RangeCalendar.svg'; import DateField from '../assets/component-illustrations/DateField.svg'; @@ -162,6 +163,13 @@ order: 5 + + + +
- + + + + + !prop.optional); } - let showSelector = props.some(prop => prop.selector); + let showSelector = !hideSelector && props.some(prop => prop.selector); return (
diff --git a/packages/react-aria-components/docs/TableAnatomy.svg b/packages/react-aria-components/docs/TableAnatomy.svg index 0a4fd051714..b760f97603b 100644 --- a/packages/react-aria-components/docs/TableAnatomy.svg +++ b/packages/react-aria-components/docs/TableAnatomy.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index ff9253ed617..fadb53a78c0 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -14,77 +14,86 @@ import docs from 'docs:react-aria-components'; import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI, VersionBadge} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; +import Anatomy from './TreeAnatomy.svg'; import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; -import {InlineAlert, Content, Heading} from '@adobe/react-spectrum'; +import {Divider} from '@react-spectrum/divider'; +import {ExampleCard} from '@react-spectrum/docs/src/ExampleCard'; +import {ExampleList} from '@react-spectrum/docs/src/ExampleList'; +import {Keyboard} from '@react-spectrum/text'; +import Collections from '@react-spectrum/docs/pages/assets/component-illustrations/Collections.svg'; +import Selection from '@react-spectrum/docs/pages/assets/component-illustrations/Selection.svg'; +import Checkbox from '@react-spectrum/docs/pages/assets/component-illustrations/Checkbox.svg'; +import Button from '@react-spectrum/docs/pages/assets/component-illustrations/Button.svg'; import treeUtils from 'docs:@react-aria/test-utils/src/tree.ts'; +import {StarterKits} from '@react-spectrum/docs/src/StarterKits'; --- category: Collections -keywords: [disclosure, collapse, expand, aria] +keywords: [disclosure, collapse, expand, aria, tree, grid] type: component -preRelease: beta --- # Tree -{docs.exports.UNSTABLE_Tree.description} +{docs.exports.Tree.description} - - Under construction - This component is in beta. More documentation is coming soon! - - ## Example +This example's MyTreeItemContent is from the [Reusable Wrappers](#reusable-wrappers) section below. + ```tsx example import { - UNSTABLE_Tree as Tree, - UNSTABLE_TreeItem as TreeItem, - UNSTABLE_TreeItemContent as TreeItemContent, + Tree, + TreeItem, + TreeItemContent, Button, Collection } from 'react-aria-components'; -import {MyCheckbox} from './Checkbox'; - -let items = [ - {id: 1, title: 'Documents', children: [ - {id: 2, title: 'Project', children: [ - {id: 3, title: 'Weekly Report', children: []} - ]} - ]}, - {id: 4, title: 'Photos', children: [ - {id: 5, title: 'Image 1', children: []}, - {id: 6, title: 'Image 2', children: []} - ]} -]; - - {function renderItem(item) { - return ( - - - {item.children.length ? : null} - - {item.title} + + + + Documents + + + + + Project + + + + + Weekly Report - - - {renderItem} - + - ); - }} + + + + + Photos + + + + + Image 1 + + + + + + Image 2 + + + + ``` @@ -94,6 +103,7 @@ let items = [ ```css hidden @import "@react-aria/example-theme"; @import './Button.mdx' layer(button); +@import './ToggleButton.mdx' layer(togglebutton); @import './Checkbox.mdx' layer(checkbox); ``` @@ -124,8 +134,7 @@ let items = [ gap: 0.571rem; min-height: 28px; padding: 0.286rem 0.286rem 0.286rem 0.571rem; - --padding: 20px; - padding-left: calc((var(--tree-item-level) - 1) * 20px + 0.571rem + var(--padding)); + --padding: 8px; border-radius: 6px; outline: none; cursor: default; @@ -134,17 +143,15 @@ let items = [ position: relative; transform: translateZ(0); - &[data-has-child-items] { - --padding: 0px; - } - .react-aria-Button[slot=chevron] { all: unset; display: flex; + visibility: hidden; align-items: center; justify-content: center; width: 1.143rem; height: 1.143rem; + padding-left: calc((var(--tree-item-level) - 1) * var(--padding)); svg { rotate: 0deg; @@ -157,6 +164,10 @@ let items = [ } } + &[data-has-child-items] .react-aria-Button[slot=chevron] { + visibility: visible; + } + &[data-expanded] .react-aria-Button[slot=chevron] svg { rotate: 90deg; } @@ -237,19 +248,524 @@ let items = [ +## Features + +A tree can be built using the [<ul>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ul), [<li>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li), + and [<ol>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ol), but is very limited in functionality especially when it comes to user interactions. +HTML lists are meant for static content, rather than heirarchies with rich interactions like focusable elements within cells, keyboard navigation, item selection, sorting, etc. +`Tree` helps achieve accessible and interactive tree components that can be styled as needed. + +* **Item selection** – Single or multiple selection, with optional checkboxes, disabled items, and both `toggle` and `replace` selection behaviors. +* **Interactive children** – Tree items may include interactive elements such as buttons, menus, etc. +* **Actions** – Items support optional actions such as navigation via click, tap, double click, or Enter key. +* **Keyboard navigation** – Tree items and focusable children can be navigated using the arrow keys, along with page up/down, home/end, etc. Typeahead, auto scrolling, and selection modifier keys are supported as well. +* **Touch friendly** – Selection and actions adapt their behavior depending on the device. For example, selection is activated via long press on touch when item actions are present. +* **Accessible** – Follows the [ARIA treegrid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/), with additional selection announcements via an ARIA live region. Extensively tested across many devices and [assistive technologies](accessibility.html#testing) to ensure announcements and behaviors are consistent. + +## Anatomy + + + +A Tree consists of a container element, with items containing data inside. The items within a tree may contain focusable elements or plain text content. Each item may also contain a button to toggle the expandable state of that item. + +If the tree supports item selection, each item can optionally include a selection checkbox. + +```tsx render=false +import {Tree, TreeItem, TreeItemContent, Button, Checkbox} from 'react-aria-components'; + + + + + + {props.children} + + )} + + ); +} +``` + +The `TreeItem` can also be wrapped. This example accepts a `title` prop and renders the `TreeItemContent` automatically. + +```tsx example export=true render=false +import {TreeItemProps} from 'react-aria-components'; + +interface MyTreeItemProps extends Partial { + title: string +} + +function MyTreeItem(props: MyTreeItemProps) { + return ( + + + {props.title} + + {props.children} + + ); +} +``` + +Now we can render a Tree using far less code. + +```tsx example + + + + + + + + + + + +``` + +## Content + +So far, our examples have shown static collections where the data is hard coded. +Dynamic collections, as shown below, can be used when the tree data comes from an external data source such as an API, +or updates over time. In the example below, data for each item is provided to the tree via a render function. + +```tsx example export=true +import type {TreeProps} from 'react-aria-components'; +import {MyCheckbox} from './Checkbox'; + +let items = [ + {id: 1, title: 'Documents', children: [ + {id: 2, title: 'Project', children: [ + {id: 3, title: 'Weekly Report', children: []} + ]} + ]}, + {id: 4, title: 'Photos', children: [ + {id: 5, title: 'Image 1', children: []}, + {id: 6, title: 'Image 2', children: []} + ]} +]; + +interface FileType { + id: number, + title: string, + children: FileType[] +} + +function FileTree(props: TreeProps) { + return ( + + {/*- begin highlight -*/} + {function renderItem(item) { + ///- end highlight -/// + return ( + + + {item.title} + + + + {/*- begin highlight -*/} + {/* recursively render children */} + {renderItem} + {/*- end highlight -*/} + + + ); + }} + + ) +} + +``` + +## Selection + +### Single selection + +By default, `Tree` doesn't allow item selection but this can be enabled using the `selectionMode` prop. Use `defaultSelectedKeys` to provide a default set of selected items. +Note that the value of the selected keys must match the `id` prop of the item. + +The example below enables single selection mode and uses `defaultSelectedKeys` to select the item with id equal to `2`. +A user can click on a different item to change the selection or click on the same item again to deselect it entirely. + +```tsx example +// Using the example above + +``` + +### Multiple selection + +Multiple selection can be enabled by setting `selectionMode` to `multiple`. + +```tsx example +// Using the example above + +``` + +### Disallow empty selection + +Tree also supports a `disallowEmptySelection` prop which forces the user to have at least one item in the Tree selected at all times. +In this mode, if a single item is selected and the user presses it, it will not be deselected. + +```tsx example +// Using the example above + +``` + + +### Controlled selection + +To programmatically control item selection, use the `selectedKeys` prop paired with the `onSelectionChange` callback. The `id` prop from the selected items will +be passed into the callback when the item is pressed, allowing you to update state accordingly. + +```tsx example export=true +import type {Selection} from 'react-aria-components'; + +interface Pokemon { + id: number, + name: string, + children?: Pokemon[] +} + +interface PokemonEvolutionTreeProps extends TreeProps { + items?: T[], + renderEmptyState?: () => string +} + +function PokemonEvolutionTree( + props: PokemonEvolutionTreeProps +) { + let items: Pokemon[] = props.items ?? [ + {id: 1, name: 'Bulbasaur', children: [ + {id: 2, name: 'Ivysaur', children: [ + {id: 3, name: 'Venusaur'} + ]} + ]}, + {id: 4, name: 'Charmander', children: [ + {id: 5, name: 'Charmeleon', children: [ + {id: 6, name: 'Charizard'} + ]} + ]}, + {id: 7, name: 'Squirtle', children: [ + {id: 8, name: 'Wartortle', children: [ + {id: 9, name: 'Blastoise'} + ]} + ]} + ]; + + ///- begin highlight -/// + let [selectedKeys, setSelectedKeys] = + React.useState(new Set()); + ///- end highlight -/// + + return ( + + {function renderItem(item) { + return ( + + + {renderItem} + + + ); + }} + + ); +} + + +``` + +### Selection behavior + +By default, `Tree` uses the `"toggle"` selection behavior, which behaves like a checkbox group: clicking, tapping, or pressing the Space or Enter keys toggles selection for the focused item. Using the arrow keys moves focus but does not change selection. The `"toggle"` selection mode is often paired with checkboxes in each item as an explicit affordance for selection. + +When the `selectionBehavior` prop is set to `"replace"`, clicking an item with the mouse _replaces_ the selection with only that item. Using the arrow keys moves both focus and selection. To select multiple items, modifier keys such as Ctrl, Cmd, and Shift can be used. To move focus without moving selection, the Ctrl key on Windows or the Option key on macOS can be held while pressing the arrow keys. Holding this modifier while pressing the Space key toggles selection for the focused item, which allows multiple selection of non-contiguous items. On touch screen devices, selection always behaves as toggle since modifier keys may not be available. This behavior emulates native platforms such as macOS and Windows and is often used when checkboxes in each item are not desired. + +```tsx example + +``` + +## Item actions + +`Tree` supports item actions via the `onAction` prop, which is useful for functionality such as navigation. In the default `"toggle"` selection behavior, when nothing is selected, clicking or tapping the item triggers the item action. +When at least one item is selected, the tree is in selection mode, and clicking or tapping an item toggles the selection. Actions may also be triggered via the Enter key, and selection using the Space key. + +This behavior is slightly different in the `"replace"` selection behavior, where single clicking selects the item and actions are performed via double click. On touch devices, the action becomes the primary tap interaction, +and a long press enters into selection mode, which temporarily swaps the selection behavior to `"toggle"` to perform selection (you may wish to display checkboxes when this happens). Deselecting all items exits selection mode +and reverts the selection behavior back to `"replace"`. Keyboard behaviors are unaffected. + +```tsx example +
+ alert(`Opening item ${key}...`)} + ///- end highlight -/// + selectionMode="multiple" /> + alert(`Opening item ${key}...`)} + selectionBehavior="replace" + ///- end highlight -/// + selectionMode="multiple" /> +
+``` + +Items may also have an action specified by directly applying `onAction` on the `TreeItem` itself. This may be especially convenient in static collections. If `onAction` is also provided to the `Tree`, both the tree's and the item's `onAction` are called. + +```tsx example + + alert(`Opening Bulbasaur...`)} + /*- end highlight -*/ + id="bulbasaur" + title="Bulbasaur"> + alert(`Opening Ivysaur...`)} + id="ivysaur" + title="Ivysaur"> + alert(`Opening Venisaur...`)} + id="venisaur" + title="Venisaur" /> + + + +``` + + +### Links + +Tree items may also be links to another page or website. This can be achieved by passing the `href` prop to the `` component. Links behave the same way as described above for item actions depending on the `selectionMode` and `selectionBehavior`. + +```tsx example + + + + + + + +``` + +```css hidden +.react-aria-TreeItem[data-href] { + cursor: pointer; +} +``` + +#### Client side routing + +The `` component works with frameworks and client side routers like [Next.js](https://nextjs.org/) and [React Router](https://reactrouter.com/en/main). As with other React Aria components that support links, this works via the component at the root of your app. See the [client side routing guide](routing.html) to learn how to set this up. + +## Disabled items + +A `TreeItem` can be disabled with the `isDisabled` prop. This will disable all interactions on the item +unless the `disabledBehavior` prop on `Tree` is used to change this behavior. +Note that you are responsible for the styling of disabled items, however, the selection checkbox will be automatically disabled. + +```tsx example + + + {/*- begin highlight -*/} + + {/*- end highlight -*/} + + + + +``` + +When `disabledBehavior` is set to `selection`, interactions such as focus, dragging, or actions can still be performed on disabled rows. + +```tsx example + + + {/*- begin highlight -*/} + + {/*- end highlight -*/} + + + + +``` + +In dynamic collections, it may be more convenient to use the `disabledKeys` prop at the `Tree` level instead of `isDisabled` on individual items. +This accepts a list of item ids that are disabled. An item is considered disabled if its key exists in `disabledKeys` or if it has `isDisabled`. + +```tsx example +// Using the same tree as above + +``` + +## Empty state + +Use the `renderEmptyState` prop to customize what the `Tree` will display if there are no items. + +```tsx example + 'No results found.'} style={{height: '100px'}}> + {[]} + +``` + +
+ Show CSS + +```css +.react-aria-Tree { + &[data-empty] { + display: flex; + align-items: center; + justify-content: center; + font-style: italic; + } +} +``` + +
+ + ## Props ### Tree - + ### TreeItem - + ### TreeItemContent - + ## Styling @@ -292,10 +808,10 @@ Render props may also be used as children to alter what elements are rendered ba ```jsx {({selectionMode}) => ( - <> + {selectionMode !== 'none' && } Item - + )} ``` @@ -310,7 +826,7 @@ A `Tree` can be targeted with the `.react-aria-Tree` CSS selector, or by overrid ### TreeItem -A `TreeItem` can be targeted with the `.react-aria-TreeItem` CSS selector, or by overriding with a custom `className`. It supports the following states and render props: +A `TreeItem` can be targeted with the `.react-aria-TreeItem` CSS selector, or by overriding with a custom `className`. It supports the following states: @@ -322,6 +838,108 @@ TreeItem also exposes a `--tree-item-level` CSS custom property, which you can u } ``` +### TreeItemContent + +`TreeItemContent` does not render a DOM node. It supports the following render props: + + + +## Advanced customization + +### Contexts + +All React Aria Components export a corresponding context that can be used to send props to them from a parent element. This enables you to build your own compositional APIs similar to those found in React Aria Components itself. You can send any prop or ref via context that you could pass to the corresponding component. The local props and ref on the component are merged with the ones passed via context, with the local props taking precedence (following the rules documented in [mergeProps](mergeProps.html)). + + + +This example shows a component that accepts a `Tree` and a [ToggleButton](ToggleButton.html) as children, and allows the user to turn selection mode for the tree on and off by pressing the button. + +```tsx example render=false export=true +import type {SelectionMode} from 'react-aria-components'; +import {ToggleButtonContext, TreeContext} from 'react-aria-components'; + +function Selectable({children}) { + let [isSelected, onChange] = React.useState(false); + let selectionMode: SelectionMode = isSelected ? 'multiple' : 'none'; + return ( + + {/*- begin highlight -*/} + + {/*- end highlight -*/} + {children} + + + ); +} +``` + +The `Selectable` component can be reused to make the selection mode of any nested `Tree` controlled by a `ToggleButton`. + +```tsx example +import {ToggleButton} from 'react-aria-components'; + + + Select + + +``` + +
+ Show CSS + +```css +.react-aria-ToggleButton { + margin-bottom: 8px; +} +``` +
+ + +### Custom children + +Tree passes props to its child components, such as the selection checkboxes, via their associated contexts. These contexts are exported so you can also consume them in your own custom components. This enables you to reuse existing components from your app or component library together with React Aria Components. + + + +This example consumes from `CheckboxContext` in an existing styled checkbox component to make it compatible with React Aria Components. The hook merges the local props and ref with the ones provided via context by Tree. See [useCheckbox](useCheckbox.html) for more details about the hooks used in this example. + +```tsx +import type {CheckboxProps, useContextProps} from 'react-aria-components'; +import {CheckboxContext} from 'react-aria-components'; +import {useToggleState} from 'react-stately'; +import {useCheckbox} from 'react-aria'; + +const MyCustomCheckbox = React.forwardRef((props: CheckboxProps, ref: React.ForwardedRef) => { + // Merge the local props and ref with the ones provided via context. + ///- begin highlight -/// + [props, ref] = useContextProps(props, ref, CheckboxContext); + ///- end highlight -/// + + let state = useToggleState(props); + let {inputProps} = useCheckbox(props, state, ref); + return ; +}); +``` + +Now you can use `MyCustomCheckbox` within a `Tree`, in place of the builtin React Aria Components `Checkbox`. + +```tsx + + + + {/*- begin highlight -*/} + + {/*- end highlight -*/} + {/* ... */} + + + +``` + +{/* ### Hooks +TODO: add back once hooks docs are written with new collections +If you need to customize things even further, such as accessing internal state or customizing DOM structure, you can drop down to the lower level Hook-based API. See [useTreeGridList](useTreeGridList.html) for more details. */} + ## Testing ### Test utils @@ -337,7 +955,7 @@ import {User} from '@react-aria/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); // ... -it('Tree can select a row via keyboard', async function () { +it('Tree can select a item via keyboard', async function () { // Render your test component/app and initialize the Tree tester let {getByTestId} = render( diff --git a/packages/react-aria-components/docs/TreeAnatomy.svg b/packages/react-aria-components/docs/TreeAnatomy.svg new file mode 100644 index 00000000000..767accca3f3 --- /dev/null +++ b/packages/react-aria-components/docs/TreeAnatomy.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + Documents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TreeItem + Checkbox (optional) + + + + + + + + 12 items + + + + + Onboarding + PDF + + + + + Budget + XLS + + + + + Sales Pitch + PPT + + + + + + + + + + + + + + Collapse and expand button + + + + + + + + + + + + + + + + + + Tree + + + + + + + + + + diff --git a/packages/react-aria-components/docs/examples/file-system.mdx b/packages/react-aria-components/docs/examples/file-system.mdx new file mode 100644 index 00000000000..94596cb6730 --- /dev/null +++ b/packages/react-aria-components/docs/examples/file-system.mdx @@ -0,0 +1,163 @@ +{/* 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 {ExampleLayout} from '@react-spectrum/docs'; +export default ExampleLayout; + +import docs from 'docs:react-aria-components'; +import {TypeLink} from '@react-spectrum/docs'; +import styles from '@react-spectrum/docs/src/docs.css'; +import Tree from '@react-spectrum/docs/pages/assets/component-illustrations/Tree.svg'; +import {ExampleCard} from '@react-spectrum/docs/src/ExampleCard'; +import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; + +--- +keywords: [example, tree, aria, accessibility, react, component] +type: component +image: file-system.png +description: A tree with multiple selection and nested items. +--- + +# File System Tree + +A file system [Tree](../Tree.html) featuring multiple selection and styled with [Tailwind CSS](https://tailwindcss.com/). + +## Example + +```tsx import +import './tailwind.global.css'; + +const filesystem = [ + // mock up a file system with 50 items total and nested children up to 4 levels deep + {'id': 'documents', name: 'Documents', children: [ + {'id': 'photos', name: 'Photos', children: [ + {'id': 'summer', name: 'Summer', children: [ + {'id': 'beach', name: 'Beach'}, + {'id': 'mountains', name: 'Mountains'}, + {'id': 'forest', name: 'Forest'}, + {'id': 'desert', name: 'Desert'} + ]}, + {'id': 'winter', name: 'Winter', children: [ + {'id': 'skiing', name: 'Skiing'}, + {'id': 'snowboarding', name: 'Snowboarding'}, + {'id': 'snowmobiling', name: 'Snowmobiling'}, + {'id': 'snowshoeing', name: 'Snowshoeing'} + ]} + ]}, + {'id': 'videos', name: 'Videos', children: [ + {'id': 'family', name: 'Family'}, + {'id': 'friends', name: 'Friends'}, + {'id': 'pets', name: 'Pets'}, + {'id': 'vacations', name: 'Vacations'} + ]}, + {'id': 'music', name: 'Music', children: [ + {'id': 'rock', name: 'Rock', children: [ + {'id': 'classic', name: 'Classic'}, + {'id': 'alternative', name: 'Alternative'}, + {'id': 'punk', name: 'Punk'}, + {'id': 'metal', name: 'Metal'} + ]}, + {'id': 'pop', name: 'Pop', children: [ + {'id': 'dance', name: 'Dance'}, + {'id': 'hip-hop', name: 'Hip Hop'}, + {'id': 'r&b', name: 'R&B'}, + {'id': 'soul', name: 'Soul'} + ]} + ]}, + {'id': 'movies', name: 'Movies', children: [ + {'id': 'action', name: 'Action'}, + {'id': 'comedy', name: 'Comedy'}, + {'id': 'drama', name: 'Drama'}, + {'id': 'horror', name: 'Horror'} + ]} + ]} +]; +``` + +```tsx example standalone +import {Button, Collection, Tree, TreeItem, TreeItemContent} from 'react-aria-components'; +import ChevronIcon from '@spectrum-icons/ui/ChevronRightMedium'; + +function FileSystemExample() { + return ( +
+ + {function renderItem(item) { + return ( + + + {({hasChildItems}) => ( +
+ {hasChildItems ? :
} +
{item.name}
+
+ )} + + + {renderItem} + + + ) + }} + +
+ ); +} +``` + +### Tailwind config + +This example uses the [tailwindcss-react-aria-components](../styling.html#plugin) plugin. When using Tailwind v4, add it to your CSS: + +```css render=false +@import "tailwindcss"; +@plugin "tailwindcss-react-aria-components"; +``` + +
+ + Tailwind v3 + +When using Tailwind v3, add the plugin to your `tailwind.config.js` instead: + +```tsx +module.exports = { + // ... + plugins: [ + require('tailwindcss-react-aria-components') + ] +}; +``` + +**Note**: When using Tailwind v3, install `tailwindcss-react-aria-components` version 1.x instead of 2.x. + +
+ +## Components + +
+ + + + + +
diff --git a/packages/react-aria-components/docs/examples/file-system.png b/packages/react-aria-components/docs/examples/file-system.png new file mode 100644 index 00000000000..ada85e3e2dc Binary files /dev/null and b/packages/react-aria-components/docs/examples/file-system.png differ diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 24c4a24e80b..abfa0250656 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -16,7 +16,7 @@ import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; -import {DisabledBehavior, Expandable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; +import {DisabledBehavior, Expandable, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection, RefObject} from '@react-types/shared'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; import {FocusScope, mergeProps, useFocusRing, useGridListSelectionCheckbox, useHover} from 'react-aria'; import {Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; @@ -120,7 +120,7 @@ export interface TreeRenderProps { export interface TreeEmptyStateRenderProps extends Omit {} -export interface TreeProps extends Omit, 'children'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, Expandable { +export interface TreeProps extends Omit, 'children'>, MultipleSelection, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, Expandable { /** How multiple selection should behave in the tree. */ selectionBehavior?: SelectionBehavior, /** Provides content to display when there are no items in the list. */ @@ -133,16 +133,16 @@ export interface TreeProps extends Omit, 'children'> } -export const UNSTABLE_TreeContext = createContext, HTMLDivElement>>(null); -export const UNSTABLE_TreeStateContext = createContext | null>(null); +export const TreeContext = createContext, HTMLDivElement>>(null); +export const TreeStateContext = createContext | null>(null); /** * A tree provides users with a way to navigate nested hierarchical information, with support for keyboard navigation * and selection. */ -export const UNSTABLE_Tree = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tree(props: TreeProps, ref: ForwardedRef) { +export const Tree = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tree(props: TreeProps, ref: ForwardedRef) { // Render the portal first so that we have the collection by the time we render the DOM in SSR. - [props, ref] = useContextProps(props, ref, UNSTABLE_TreeContext); + [props, ref] = useContextProps(props, ref, TreeContext); return ( }> @@ -245,7 +245,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne data-focus-visible={isFocusVisible || undefined}> ({props, collection, treeRef: ref}: TreeInne // TODO: readd the rest of the render props when tree supports them export interface TreeItemRenderProps extends Omit { - /** Whether the tree item is expanded. */ - isExpanded: boolean, - // TODO: api discussion, how do we feel about the below? This is so we can still style the row as grey when a child element within is focused - // Maybe should have this for the other collection item render props - /** Whether the tree item's children have keyboard focus. */ - isFocusVisibleWithin: boolean -} - -export interface TreeItemContentRenderProps extends ItemRenderProps { - // Whether the tree item is expanded. + /** + * Whether the tree item is expanded. + * @selector [data-expanded] + */ isExpanded: boolean, - // Whether the tree item has child tree items. + /** + * Whether the tree item has child tree items. + * @selector [data-has-child-items] + */ hasChildItems: boolean, - // What level the tree item has within the tree. + /** + * What level the tree item has within the tree. + * @selector [data-level="number"] + */ level: number, - // Whether the tree item's children have keyboard focus. + /** + * Whether the tree item's children have keyboard focus. + * @selector [data-focus-visible-within] + */ isFocusVisibleWithin: boolean, - // The state of the tree. + /** The state of the tree. */ state: TreeState, - // The unique id of the tree row. + /** The unique id of the tree row. */ id: Key } +export interface TreeItemContentRenderProps extends TreeItemRenderProps {} + // The TreeItemContent is the one that accepts RenderProps because we would get much more complicated logic in TreeItem otherwise since we'd // need to do a bunch of check to figure out what is the Content and what are the actual collection elements (aka child rows) of the TreeItem export interface TreeItemContentProps extends Pick, 'children'> {} -export const UNSTABLE_TreeItemContent = /*#__PURE__*/ createLeafComponent('content', function TreeItemContent(props: TreeItemContentProps) { +export const TreeItemContent = /*#__PURE__*/ createLeafComponent('content', function TreeItemContent(props: TreeItemContentProps) { let values = useContext(TreeItemContentContext)!; let renderProps = useRenderProps({ children: props.children, @@ -312,14 +317,21 @@ export interface TreeItemProps extends StyleRenderProps void } /** * A TreeItem represents an individual item in a Tree. */ -export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', (props: TreeItemProps, ref: ForwardedRef, item: Node) => { - let state = useContext(UNSTABLE_TreeStateContext)!; +export const TreeItem = /*#__PURE__*/ createBranchComponent('item', (props: TreeItemProps, ref: ForwardedRef, item: Node) => { + let state = useContext(TreeStateContext)!; ref = useObjectRef(ref); // TODO: remove this when we support description in tree row // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -345,6 +357,8 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', (() => ({ ...states, isHovered, @@ -352,12 +366,12 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', , StyleRenderProps {} -export const UNSTABLE_TreeLoadingIndicator = createLeafComponent('loader', function TreeLoader(props: TreeLoaderProps, ref: ForwardedRef, item: Node) { - let state = useContext(UNSTABLE_TreeStateContext); +export const TreeLoadingIndicator = createLeafComponent('loader', function TreeLoader(props: TreeLoaderProps, ref: ForwardedRef, item: Node) { + let state = useContext(TreeStateContext); // This loader row is is non-interactable, but we want the same aria props calculated as a typical row // @ts-ignore let {rowProps} = useTreeGridListItem({node: item}, state, ref); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index b5ef9640136..6b96789d5f4 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -74,7 +74,7 @@ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext, ToggleGroupStateContext} from './ToggleButtonGroup'; export {Toolbar, ToolbarContext} from './Toolbar'; export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip'; -export {UNSTABLE_TreeLoadingIndicator, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeContext, UNSTABLE_TreeItemContent, UNSTABLE_TreeStateContext} from './Tree'; +export {TreeLoadingIndicator, Tree, TreeItem, TreeContext, TreeItemContent, TreeStateContext} from './Tree'; export {useDragAndDrop} from './useDragAndDrop'; export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop'; export {Virtualizer} from './Virtualizer'; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index c907ec87100..7423d26731a 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -11,12 +11,12 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Checkbox, CheckboxProps, Collection, Key, ListLayout, Menu, MenuTrigger, Popover, Text, TreeItemProps, TreeProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, Virtualizer} from 'react-aria-components'; +import {Button, Checkbox, CheckboxProps, Collection, Key, ListLayout, Menu, MenuTrigger, Popover, Text, Tree, TreeItem, TreeItemContent, TreeItemProps, TreeProps, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import {MyMenuItem} from './utils'; import React, {ReactNode, useMemo} from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_TreeLoadingIndicator} from '../src/Tree'; +import {TreeLoadingIndicator} from '../src/Tree'; export default { title: 'React Aria Components' @@ -48,7 +48,7 @@ function MyCheckbox({children, ...props}: CheckboxProps) { const StaticTreeItem = (props: StaticTreeItemProps) => { return ( - classNames(styles, 'tree-item', { focused: isFocused, @@ -56,7 +56,7 @@ const StaticTreeItem = (props: StaticTreeItemProps) => { selected: isSelected, hovered: isHovered })}> - + {({isExpanded, hasChildItems, level, selectionMode, selectionBehavior}) => ( <> {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( @@ -81,14 +81,14 @@ const StaticTreeItem = (props: StaticTreeItemProps) => {
)} - + {props.title && props.children} - +
); }; const TreeExampleStaticRender = (args) => ( - + Photos @@ -103,7 +103,7 @@ const TreeExampleStaticRender = (args) => ( Projects-3 - classNames(styles, 'tree-item', { @@ -112,11 +112,11 @@ const TreeExampleStaticRender = (args) => ( selected: isSelected, hovered: isHovered })}> - + Reports - - - +
+ classNames(styles, 'tree-item', { @@ -125,13 +125,13 @@ const TreeExampleStaticRender = (args) => ( selected: isSelected, hovered: isHovered })}> - + {({isFocused}) => ( {`${isFocused} Tests`} )} - - - + + +
); export const TreeExampleStatic = { @@ -157,7 +157,7 @@ export const TreeExampleStatic = { }, parameters: { description: { - data: 'Note that the last two items are just to test bare minimum UNSTABLE_TreeItem and thus dont have the checkbox or any of the other contents that the other items have. The last item tests the isFocused renderProp' + data: 'Note that the last two items are just to test bare minimum TreeItem and thus dont have the checkbox or any of the other contents that the other items have. The last item tests the isFocused renderProp' } } }; @@ -194,7 +194,7 @@ let rows = [ const MyTreeLoader = () => { return ( - + {({level}) => { let message = `Level ${level} loading spinner`; if (level === 1) { @@ -206,7 +206,7 @@ const MyTreeLoader = () => { ); }} - + ); }; @@ -221,7 +221,7 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { let {childItems, renderLoader} = props; return ( <> - classNames(styles, 'tree-item', { focused: isFocused, @@ -229,7 +229,7 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { selected: isSelected, hovered: isHovered })}> - + {({isExpanded, hasChildItems, level, selectionBehavior, selectionMode}) => ( <> {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( @@ -252,7 +252,7 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { )} - + {(item: any) => ( @@ -260,7 +260,7 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { )} - + {/* TODO this would need to check if the parent was loading and then the user would insert this tree loader after last row of that section. theoretically this would look like (loadingKeys.includes(parentKey) && props.id === last key of parent) &&.... both the parentKey of a given item as well as checking if the current tree item is the last item of said parent would need to be done by the user outside of this tree item? @@ -273,13 +273,13 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { let defaultExpandedKeys = new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB']); const TreeExampleDynamicRender = (args: TreeProps) => ( - + {(item) => ( {item.name} )} - + ); export const TreeExampleDynamic = { @@ -294,23 +294,23 @@ export const WithActions = { onAction: action('onAction'), ...TreeExampleDynamic.args }, - name: 'UNSTABLE_Tree with actions' + name: 'Tree with actions' }; const WithLinksRender = (args: TreeProps) => ( - + {(item) => ( {item.name} )} - + ); export const WithLinks = { ...TreeExampleDynamic, render: WithLinksRender, - name: 'UNSTABLE_Tree with links', + name: 'Tree with links', parameters: { description: { data: 'every tree item should link to adobe.com' @@ -323,7 +323,7 @@ function renderEmptyLoader({isLoading}) { } const EmptyTreeStatic = (args: {isLoading: boolean}) => ( - ( )} - + ); export const EmptyTreeStaticStory = { @@ -348,7 +348,7 @@ export const EmptyTreeStaticStory = { function LoadingStoryDepOnCollection(args) { return ( - + {(item) => ( id === 'project-2C'} isLoading={args.isLoading} id={item.id} childItems={item.childItems} textValue={item.name}> @@ -357,7 +357,7 @@ function LoadingStoryDepOnCollection(args) { )} {args.isLoading && } - + ); } @@ -376,13 +376,13 @@ export const LoadingStoryDepOnCollectionStory = { function LoadingStoryDepOnTop(args: TreeProps & {isLoading: boolean}) { return ( - + {(item) => ( (id === 'reports' || id === 'project-2C')} isLoading={args.isLoading} id={item.id} childItems={item.childItems} textValue={item.name}> {item.name} )} - + ); } @@ -420,7 +420,7 @@ const DynamicTreeItemWithButtonLoader = (props: DynamicTreeItemProps) => { return ( <> - classNames(styles, 'tree-item', { focused: isFocused, @@ -428,7 +428,7 @@ const DynamicTreeItemWithButtonLoader = (props: DynamicTreeItemProps) => { selected: isSelected, hovered: isHovered })}> - + {({isExpanded, hasChildItems, level, selectionBehavior, selectionMode}) => ( <> {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( @@ -451,7 +451,7 @@ const DynamicTreeItemWithButtonLoader = (props: DynamicTreeItemProps) => { )} - + {(item: any) => ( @@ -459,20 +459,20 @@ const DynamicTreeItemWithButtonLoader = (props: DynamicTreeItemProps) => { )} - + ); }; function ButtonLoadingIndicator(args: TreeProps & {isLoading: boolean}) { return ( - + {(item) => ( (id === 'project-2' || id === 'project-5')} isLoading={args.isLoading} id={item.id} childItems={item.childItems} textValue={item.name}> {item.name} )} - + ); } diff --git a/packages/react-aria-components/test/Tree.ssr.test.js b/packages/react-aria-components/test/Tree.ssr.test.js new file mode 100644 index 00000000000..c8ddaa9a542 --- /dev/null +++ b/packages/react-aria-components/test/Tree.ssr.test.js @@ -0,0 +1,96 @@ +/* + * 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 {fireEvent, screen, testSSR} from '@react-spectrum/test-utils-internal'; + +describe('Tree SSR', function () { + it('should render without errors', async function () { + await testSSR(__filename, ` + import {Button, Tree, TreeItem, TreeItemContent} from '../'; + + function MyTreeItemContent(props) { + return ( + + {({hasChildItems}) => ( + <> + + {props.children} + + )} + + ); + } + function Test() { + let [show, setShow] = React.useState(false); + return ( + <> + + + + + Documents + + + + Project + + {show && + + Weekly Report + + } + + + + + Photos + + + + Image 1 + + + + + Image 2 + + + + + + ); + } + + + + + `, () => { + // Assert that server rendered stuff into the HTML. + let rows = screen.getAllByRole('row'); + expect(rows.map(o => o.textContent)).toEqual(['Documents', 'Project', 'Photos', 'Image 1', 'Image 2']); + }); + + // Assert that hydrated UI matches what we expect. + let button = screen.getAllByRole('button', {name: 'Show'})[0]; + let rows = screen.getAllByRole('row'); + expect(rows.map(o => o.textContent)).toEqual(['Documents', 'Project', 'Photos', 'Image 1', 'Image 2']); + + // And that it updates correctly. + fireEvent.click(button); + rows = screen.getAllByRole('row'); + expect(rows.map(o => o.textContent)).toEqual(['Documents', 'Project', 'Weekly Report', 'Photos', 'Image 1', 'Image 2']); + }); +}); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 12084913313..42b8f7fde3c 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -12,7 +12,7 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaTreeTests} from './AriaTree.test-util'; -import {Button, Checkbox, Collection, ListLayout, Text, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, Virtualizer} from '../'; +import {Button, Checkbox, Collection, ListLayout, Text, Tree, TreeItem, TreeItemContent, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import React from 'react'; import * as stories from '../stories/Tree.stories'; @@ -29,8 +29,8 @@ let onExpandedChange = jest.fn(); let StaticTreeItem = (props) => { return ( - - + + {({isExpanded, hasChildItems, selectionMode, selectionBehavior}) => ( <> {(selectionMode !== 'none' || props.href != null) && selectionBehavior === 'toggle' && ( @@ -42,14 +42,14 @@ let StaticTreeItem = (props) => { )} - + {props.title && props.children} - + ); }; let StaticTree = ({treeProps = {}, rowProps = {}}) => ( - + Photos @@ -64,7 +64,7 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( Projects-3 - + ); let rows = [ @@ -99,8 +99,8 @@ let rows = [ let DynamicTreeItem = (props) => { return ( - - + + {({isExpanded, hasChildItems, selectionMode, selectionBehavior}) => ( <> {(selectionMode !== 'none' || props.href != null) && selectionBehavior === 'toggle' && ( @@ -112,7 +112,7 @@ let DynamicTreeItem = (props) => { )} - + {(item: any) => ( @@ -120,18 +120,18 @@ let DynamicTreeItem = (props) => { )} - + ); }; let DynamicTree = ({treeProps = {}, rowProps = {}}) => ( - + {(item: any) => ( {item.name} )} - + ); describe('Tree', () => { @@ -1016,19 +1016,19 @@ describe('Tree', () => { describe('empty state', () => { it('should allow the user to tab to the empty tree', async () => { let {getAllByRole, getByRole} = render( - `isFocused: ${isFocused}, isFocusVisible: ${isFocusVisible}`} aria-label="test empty tree" items={[]} renderEmptyState={({isFocused, isFocusVisible}) => {`Nothing in tree, isFocused: ${isFocused}, isFocusVisible: ${isFocusVisible}`}}> {() => ( - - + + Dummy Value - - + + )} - + ); let tree = getByRole('treegrid'); @@ -1180,7 +1180,7 @@ AriaTreeTests({ prefix: 'rac-static', renderers: { standard: () => render( - + Photos @@ -1208,10 +1208,10 @@ AriaTreeTests({ Homework-3 - + ), singleSelection: () => render( - + Photos @@ -1239,10 +1239,10 @@ AriaTreeTests({ Homework-3 - + ), allInteractionsDisabled: () => render( - + Photos @@ -1270,7 +1270,7 @@ AriaTreeTests({ Homework-3 - + ) } }); @@ -1295,8 +1295,8 @@ let controlledRows = [ let ControlledDynamicTreeItem = (props) => { return ( - - + + {({isExpanded, hasChildItems, selectionMode, selectionBehavior}) => ( <> {(selectionMode !== 'none' || props.href != null) && selectionBehavior === 'toggle' && ( @@ -1308,7 +1308,7 @@ let ControlledDynamicTreeItem = (props) => { )} - + {(item: any) => ( @@ -1316,7 +1316,7 @@ let ControlledDynamicTreeItem = (props) => { )} - + ); }; @@ -1324,13 +1324,13 @@ function ControlledDynamicTree(props) { let [expanded, setExpanded] = React.useState(new Set([])); return ( - + {(item: any) => ( {item.name} )} - + ); } diff --git a/scripts/extractStarter.mjs b/scripts/extractStarter.mjs index 90ae5b01cc5..1f75ba5ca59 100644 --- a/scripts/extractStarter.mjs +++ b/scripts/extractStarter.mjs @@ -29,9 +29,10 @@ fs.mkdirSync(`starters/docs/src`, {recursive: true}); fs.mkdirSync(`starters/docs/stories`, {recursive: true}); for (let file of glob.sync('packages/react-aria-components/docs/*.mdx')) { - if (!/^[A-Z]/.test(basename(file)) || /^Tree|^Autocomplete/.test(basename(file))) { + if (!/^[A-Z]/.test(basename(file)) || /^Autocomplete/.test(basename(file))) { continue; } + console.log('Processing ' + file); // Parse the MDX file, and extract the CSS and Reusable wrappers section. let contents = fs.readFileSync(file); @@ -78,6 +79,18 @@ for (let file of glob.sync('packages/react-aria-components/docs/*.mdx')) { function MyColumn`); } + if (name === 'Tree') { + // Special case for Tree which doesn't have a wrapper component in the docs. + // We need one for the Storybook auto-generated docs to work. + reusableWrapper = reusableWrapper + .replace('', '/MyTree>') + .replace('function MyTreeItemContent', `function MyTree(props: TreeProps) { + return +} + +function MyTreeItemContent`); + } + let usedClasses = new Set(); if (reusableWrapper) { fs.writeFileSync(`starters/docs/src/${name}.tsx`, processJS(file, imports + reusableWrapper, usedClasses)); diff --git a/starters/tailwind/src/Tree.tsx b/starters/tailwind/src/Tree.tsx new file mode 100644 index 00000000000..105f8745661 --- /dev/null +++ b/starters/tailwind/src/Tree.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + Tree as AriaTree, + TreeItem as AriaTreeItem, + TreeItemContent as AriaTreeItemContent, + Button, + TreeItemProps, + TreeItemContentProps as AriaTreeItemContentProps, + TreeProps +} from 'react-aria-components'; +import { ChevronRight } from "lucide-react"; +import { tv } from 'tailwind-variants'; +import { Checkbox } from './Checkbox'; +import { composeTailwindRenderProps, focusRing } from './utils'; + +const itemStyles = tv({ + extend: focusRing, + base: 'relative flex group gap-3 cursor-default select-none py-2 px-3 text-sm text-gray-900 dark:text-zinc-200 border-y dark:border-y-zinc-700 border-transparent first:border-t-0 last:border-b-0 -mb-px last:mb-0 -outline-offset-2', + variants: { + isSelected: { + false: 'hover:bg-gray-100 dark:hover:bg-zinc-700/60', + true: 'bg-blue-100 dark:bg-blue-700/30 hover:bg-blue-200 dark:hover:bg-blue-700/40 border-y-blue-200 dark:border-y-blue-900 z-20' + }, + isDisabled: { + true: 'text-slate-300 dark:text-zinc-600 forced-colors:text-[GrayText] z-10' + } + } +}); + +export function Tree( + { children, ...props }: TreeProps +) { + return ( + + {children} + + ); +} + +export function TreeItem(props: TreeItemProps) { + return ( + + ) +} +interface TreeItemContentProps extends Omit { + children: React.ReactNode; +} + +const expandButton = tv({ + extend: focusRing, + base: "shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-start cursor-default", + variants: { + isDisabled: { + true: 'text-gray-300 dark:text-zinc-600 forced-colors:text-[GrayText]' + } + } +}); + +const chevron = tv({ + base: "w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ease-in-out", + variants: { + isExpanded: { + true: "transform rotate-90", + }, + isDisabled: { + true: 'text-gray-300 dark:text-zinc-600 forced-colors:text-[GrayText]' + } + } +}); + +export function TreeItemContent({ children, ...props }: TreeItemContentProps) { + return ( + + {({ selectionMode, selectionBehavior, hasChildItems, isExpanded, isDisabled }) => ( +
+ {selectionMode === 'multiple' && selectionBehavior === 'toggle' && ( + + )} +
+ {hasChildItems ? ( + + ) :
} + {children} +
+ )} + + ); +} diff --git a/starters/tailwind/stories/Tree.stories.tsx b/starters/tailwind/stories/Tree.stories.tsx new file mode 100644 index 00000000000..e8cf5aed26e --- /dev/null +++ b/starters/tailwind/stories/Tree.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta } from '@storybook/react'; +import { Tree, TreeItem, TreeItemContent } from '../src/Tree'; +import React from 'react'; + +const meta: Meta = { + component: Tree, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +}; + +export default meta; + +export const Example = (args: any) => ( + + + + Documents + + + + Project + + + + Weekly Report + + + + + + + Photos + + + + Image 1 + + + + + Image 2 + + + + +); + +Example.args = { + onAction: null, + defaultExpandedKeys: ['documents', 'photos', 'project'], + selectionMode: 'multiple', + defaultSelectedKeys: ['project'] +}; + +export const DisabledItems = (args: any) => ; +DisabledItems.args = { + ...Example.args, + disabledKeys: ['photos'] +};