diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index d40953132f6..7b479d46cfc 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -23,6 +23,8 @@ import { TreeItem, TreeItemContent, TreeItemContentProps, + TreeLoadMoreItem, + TreeLoadMoreItemProps, useContextProps, Virtualizer } from 'react-aria-components'; @@ -30,21 +32,27 @@ import {centerBaseline} from './CenterBaseline'; import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; import {colorMix, focusRing, fontRelative, lightDark, style} from '../style' with {type: 'macro'}; -import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; +import {DOMRef, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@react-types/shared'; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; import {TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLocale} from 'react-aria'; +import {useLocale, useLocalizedStringFormatter} from 'react-aria'; import {useScale} from './utils'; interface S2TreeProps { // Only detatched is supported right now with the current styles from Spectrum + // See https://github.com/adobe/react-spectrum/pull/7343 for what remaining combinations are left + /** Whether the tree should be displayed with a [detached style](https://spectrum.adobe.com/page/tree-view/#Detached). */ isDetached?: boolean, + /** Handler that is called when a user performs an action on a row. */ onAction?: (key: Key) => void, - // not fully supported yet + /** Whether the tree should be displayed with a [emphasized style](https://spectrum.adobe.com/page/tree-view/#Emphasis). */ isEmphasized?: boolean } @@ -58,6 +66,11 @@ export interface TreeViewItemProps extends Omit { + /** The current loading state of the TreeView or TreeView row. */ + loadingState?: LoadingState +} + interface TreeRendererContextValue { renderer?: (item) => ReactElement> } @@ -91,7 +104,10 @@ const tree = style({ } }, getAllowedOverrides({height: true})); -const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { +/** + * A tree view provides users with a way to navigate nested hierarchical information. + */ +export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { let {children, isDetached, isEmphasized, UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); @@ -180,7 +196,6 @@ const treeRow = style({ } }); - const treeCellGrid = style({ display: 'grid', width: 'full', @@ -346,7 +361,6 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode gridArea: 'level-padding', width: 'calc(calc(var(--tree-item-level, 0) - 1) * var(--indent))' })} /> - {/* 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 */} { + let {loadingState, onLoadMore} = props; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + return ( + + {() => { + return ( +
+ +
+ ); + }} +
+ ); +}; + interface ExpandableRowChevronProps { isExpanded?: boolean, isDisabled?: boolean, @@ -437,9 +478,3 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { ); } - -/** - * A tree view provides users with a way to navigate nested hierarchical information. - */ -const _TreeView: (props: TreeViewProps & React.RefAttributes>) => ReactElement | null = TreeView; -export {_TreeView as TreeView}; diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index bd022c26ddc..89b50e83e29 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -80,7 +80,7 @@ export {ToastContainer as UNSTABLE_ToastContainer, ToastQueue as UNSTABLE_ToastQ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext} from './ToggleButtonGroup'; export {Tooltip, TooltipTrigger} from './Tooltip'; -export {TreeView, TreeViewItem, TreeViewItemContent} from './TreeView'; +export {TreeView, TreeViewItem, TreeViewItemContent, TreeViewLoadMoreItem} from './TreeView'; export {pressScale} from './pressScale'; @@ -152,5 +152,5 @@ export type {ToastOptions, ToastContainerProps} from './Toast'; export type {ToggleButtonProps} from './ToggleButton'; export type {ToggleButtonGroupProps} from './ToggleButtonGroup'; export type {TooltipProps} from './Tooltip'; -export type {TreeViewProps, TreeViewItemProps, TreeViewItemContentProps} from './TreeView'; +export type {TreeViewProps, TreeViewItemProps, TreeViewItemContentProps, TreeViewLoadMoreItemProps} from './TreeView'; export type {AutocompleteProps, FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor} from 'react-aria-components'; diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index 156392d9e57..304610255fa 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -24,6 +24,8 @@ import { TreeViewItem, TreeViewItemContent, TreeViewItemProps, + TreeViewLoadMoreItem, + TreeViewLoadMoreItemProps, TreeViewProps } from '../src'; import {categorizeArgTypes} from './utils'; @@ -33,13 +35,13 @@ 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, StoryObj} from '@storybook/react'; -import React, {ReactElement} from 'react'; +import React, {ReactElement, useCallback, useState} from 'react'; +import {useAsyncList, useListData} from 'react-stately'; let onActionFunc = action('onAction'); let noOnAction = null; const onActionOptions = {onActionFunc, noOnAction}; - const meta: Meta = { component: TreeView, parameters: { @@ -184,6 +186,118 @@ export const Example: StoryObj = { render: TreeExampleStatic, args: { selectionMode: 'multiple' + }, + parameters: { + docs: { + source: { + transform: () => { + return ` +
+ + + + Photos + + + + + Edit + + + + Delete + + + + + + + Projects + + + + + Edit + + + + Delete + + + + + + Projects-1 + + + + + Edit + + + + Delete + + + + + + Projects-1A + + + + + Edit + + + + Delete + + + + + + + + Projects-2 + + + + + Edit + + + + Delete + + + + + + + Projects-3 + + + + + Edit + + + + Delete + + + + + + +
+ `; + } + } + } } }; @@ -239,13 +353,18 @@ export const ExampleNoActions: StoryObj = { render: TreeExampleStaticNoActions, args: { selectionMode: 'multiple' + }, + parameters: { + docs: { + disable: true + } } }; interface TreeViewItemType { - id: string, + id?: string, name: string, - icon: ReactElement, + icon?: ReactElement, childItems?: TreeViewItemType[] } @@ -280,8 +399,8 @@ let rows: TreeViewItemType[] = [ ]} ]; -const DynamicTreeItem = (props: Omit & TreeViewItemType): ReactElement => { - let {childItems, name, icon} = props; +const DynamicTreeItem = (props: Omit & TreeViewItemType & TreeViewLoadMoreItemProps): ReactElement => { + let {childItems, name, icon = , loadingState, onLoadMore} = props; return ( <> @@ -302,7 +421,7 @@ const DynamicTreeItem = (props: Omit & TreeViewIt {(item) => ( & TreeViewIt href={props.href} /> )} + {onLoadMore && loadingState && } ); @@ -335,6 +455,64 @@ export const Dynamic: StoryObj = { args: { ...Example.args, disabledKeys: ['project-2C', 'project-5'] + }, + parameters: { + docs: { + source: { + transform: () => { + return ` +const DynamicTreeItem = (props: Omit & TreeViewItemType & TreeViewLoadMoreItemProps): ReactElement => { + let {childItems, name, icon = , loadingState, onLoadMore} = props; + return ( + <> + + + {name} + {icon} + + + + Edit + + + + Delete + + + + + {(item) => ( + + )} + + {onLoadMore && loadingState && } + + + ); +}; + +
+ + {(item) => ( + + )} + +
+ `; + } + } + } } }; @@ -357,6 +535,42 @@ export const Empty: StoryObj = { args: { renderEmptyState, items: [] + }, + parameters: { + docs: { + source: { + transform: () => { + return ` +function renderEmptyState(): ReactElement { + return ( + + + + No results + + + No results found, press here for more info. + + + ); +} + +
+ + {(item) => ( + + )} + +
+ `; + } + } + } } }; @@ -383,6 +597,168 @@ export const WithLinks: StoryObj = { parameters: { description: { data: 'every tree item should link to adobe.com' + }, + docs: { + disable: true + } + } +}; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const AsyncTree = (args: TreeViewProps & {delay: number}): ReactElement => { + let root = [ + {id: 'photos-1', name: 'Photos 1'}, + {id: 'photos-2', name: 'Photos 2'}, + {id: 'photos-3', name: 'Photos 3'}, + {id: 'photos-4', name: 'Photos 4'}, + {id: 'photos-5', name: 'Photos 5'}, + {id: 'photos-6', name: 'Photos 6'} + ]; + + let rootData = useListData({ + initialItems: root + }); + + let starWarsList = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + action('starwars loading')(); + await new Promise(resolve => setTimeout(resolve, args.delay)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + let [isRootLoading, setRootLoading] = useState(false); + let onRootLoadMore = useCallback(() => { + if (!isRootLoading) { + action('root loading')(); + setRootLoading(true); + setTimeout(() => { + let dataToAppend: {id: string, name: string}[] = []; + let rootLength = rootData.items.length; + for (let i = 0; i < 5; i++) { + dataToAppend.push({id: `photos-${i + rootLength + 1}`, name: `Photos-${i + rootLength + 1}`}); + } + rootData.append(...dataToAppend); + setRootLoading(false); + }, args.delay); + } + }, [isRootLoading, rootData, args.delay]); + + return ( +
+ + } + name="Star Wars" + textValue="Star Wars" + childItems={starWarsList.items} + loadingState={starWarsList.loadingState} + onLoadMore={starWarsList.loadMore} /> + + {(item: any) => ( + } + name={item.name} + textValue={item.name} /> + )} + + + +
+ ); +}; + +export const AsyncLoading: StoryObj = { + render: AsyncTree, + name: 'Async loading', + args: { + selectionMode: 'multiple', + delay: 500 + }, + parameters: { + docs: { + source: { + transform: () => { + return ` +const DynamicTreeItem = (props: Omit & TreeViewItemType & TreeViewLoadMoreItemProps): ReactElement => { + let {childItems, name, icon = , loadingState, onLoadMore} = props; + return ( + <> + + + {name} + {icon} + + + + Edit + + + + Delete + + + + + {(item) => ( + + )} + + {onLoadMore && loadingState && } + + + ); +}; + +
+ + } + name="Star Wars" + textValue="Star Wars" + childItems={starWarsList.items} + loadingState={starWarsList.loadingState} + onLoadMore={starWarsList.loadMore} /> + + {(item: any) => ( + } + name={item.name} + textValue={item.name} /> + )} + + + +
+ `; + } + } } } }; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 584e2d923d8..778bb168065 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -498,7 +498,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent('item', 1;; + let hasChildItems = props.hasChildItems || [...state.collection.getChildren!(item.key)]?.length > 1; let level = rowProps['aria-level'] || 1; let {hoverProps, isHovered} = useHover({ @@ -720,6 +720,7 @@ export interface TreeLoadMoreItemProps extends Omit(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { + let {isVirtualized} = useContext(CollectionRendererContext); let state = useContext(TreeStateContext)!; let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; let sentinelRef = useRef(null); @@ -754,6 +755,11 @@ export const TreeLoadMoreItem = createLeafComponent('loader', function TreeLoadi level } }); + let style = {}; + + if (isVirtualized) { + style = {display: 'contents'}; + } return ( <> @@ -768,7 +774,7 @@ export const TreeLoadMoreItem = createLeafComponent('loader', function TreeLoadi {...mergeProps(filterDOMProps(props as any), ariaProps)} {...renderProps} data-level={level}> -
+
{renderProps.children}