diff --git a/.changeset/curly-birds-argue.md b/.changeset/curly-birds-argue.md new file mode 100644 index 00000000000..24a6b9ea1cd --- /dev/null +++ b/.changeset/curly-birds-argue.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Add draft TreeView component diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx new file mode 100644 index 00000000000..5d463a0a195 --- /dev/null +++ b/docs/content/TreeView.mdx @@ -0,0 +1,173 @@ +--- +title: TreeView +componentId: tree_view +status: Draft +description: A hierarchical list of items where nested items can be expanded and collapsed. +--- + +## Examples + +### File tree navigation without directory links + +```jsx live drafts + +``` + +### File tree navigation with directory links + +```jsx live drafts + +``` + +## Props + +### TreeView + + + + {/* */} + + +### TreeView.Item + + + + + + {/* */} + + +### TreeView.LinkItem + + + + + The URL that the item navigates to. href is passed to the underlying{' '} + <a> element. If as is specified, the component may need + different props. If the item contains a sub-nav, the item is rendered as a{' '} + <button> and href is ignored. + + } + /> + + + {/* */} + + +### TreeView.SubTree + + + + {/* */} + + + + + + + + +## Status + + diff --git a/src/TreeView/TreeView.stories.tsx b/src/TreeView/TreeView.stories.tsx new file mode 100644 index 00000000000..711655ab888 --- /dev/null +++ b/src/TreeView/TreeView.stories.tsx @@ -0,0 +1,81 @@ +import {Meta, Story} from '@storybook/react' +import {TreeView} from './TreeView' +import React from 'react' +import Box from '../Box' + +const meta: Meta = { + title: 'Composite components/TreeView', + component: TreeView, + parameters: { + layout: 'fullscreen' + } +} + +export const FileTreeWithDirectoryLinks: Story = () => ( + + + +) + +export const FileTreeWithoutDirectoryLinks: Story = () => ( + + + +) + +export default meta diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx new file mode 100644 index 00000000000..5fa55c0f674 --- /dev/null +++ b/src/TreeView/TreeView.test.tsx @@ -0,0 +1,50 @@ +import {render} from '@testing-library/react' +import React from 'react' +import {TreeView} from './TreeView' + +it('uses tree role', () => { + const {queryByRole} = render( + + Item 1 + Item 2 + Item 3 + + ) + + const root = queryByRole('tree') + + expect(root).toHaveAccessibleName('Test tree') +}) + +it('uses treeitem role', () => { + const {queryAllByRole} = render( + + Item 1 + Item 2 + Item 3 + + ) + + const items = queryAllByRole('treeitem') + + expect(items).toHaveLength(3) +}) + +it('hides subtrees by default', () => { + const {queryByRole} = render( + + + Parent + + Child + + + + ) + + const parentItem = queryByRole('treeitem', {name: 'Parent'}) + const subtree = queryByRole('group') + + expect(parentItem).toHaveAttribute('aria-expanded', 'false') + expect(subtree).toBeNull() +}) diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx new file mode 100644 index 00000000000..28e90096b2d --- /dev/null +++ b/src/TreeView/TreeView.tsx @@ -0,0 +1,217 @@ +import {ChevronDownIcon, ChevronRightIcon} from '@primer/octicons-react' +import React from 'react' +import Box from '../Box' + +// ---------------------------------------------------------------------------- +// Context + +const ItemContext = React.createContext<{ + level: number + isExpanded: boolean +}>({ + level: 1, + isExpanded: false +}) + +// ---------------------------------------------------------------------------- +// TreeView + +export type TreeViewProps = { + 'aria-label'?: React.AriaAttributes['aria-label'] + 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'] + children: React.ReactNode +} + +const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => { + return ( + + {children} + + ) +} + +// ---------------------------------------------------------------------------- +// TreeView.Item + +export type TreeViewItemProps = { + children: React.ReactNode + onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void + onToggle?: (isExpanded: boolean) => void +} + +const Item: React.FC = ({onSelect, onToggle, children}) => { + const {level} = React.useContext(ItemContext) + const [isExpanded, setIsExpanded] = React.useState(false) + const {hasSubTree, subTree, childrenWithoutSubTree} = useSubTree(children) + + // Expand or collapse the subtree + function toggle() { + onToggle?.(!isExpanded) + setIsExpanded(!isExpanded) + } + + return ( + +
  • { + if (event.key === ' ' || event.key === 'Enter') { + onSelect?.(event) + } + }} + > + { + if (onSelect) { + onSelect(event) + } else { + toggle() + } + }} + sx={{ + display: 'grid', + gridTemplateColumns: `calc(${level - 1} * 8px) 16px 1fr`, + gridTemplateAreas: `"spacer toggle content"`, + width: '100%', + height: 32, + fontSize: 1, + color: 'fg.default', + borderRadius: 2, + cursor: 'pointer', + transition: 'background 33.333ms linear', + '&:hover': { + backgroundColor: 'actionListItem.default.hoverBg' + } + }} + > + {hasSubTree ? ( + { + if (onSelect) { + toggle() + event.stopPropagation() + } + }} + sx={{ + gridArea: 'toggle', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: 'fg.muted', + borderTopLeftRadius: level === 1 ? 2 : 0, + borderBottomLeftRadius: level === 1 ? 2 : 0, + '&:hover': { + backgroundColor: onSelect ? 'actionListItem.default.hoverBg' : null + } + }} + > + {isExpanded ? : } + + ) : null} + + {childrenWithoutSubTree} + + + {subTree} +
  • +
    + ) +} + +// ---------------------------------------------------------------------------- +// TreeView.LinkItem + +export type TreeViewLinkItemProps = TreeViewItemProps & { + href?: string +} + +// TODO: Use an element to enable native browser behavior like opening links in a new tab +const LinkItem: React.FC = ({href, onSelect, ...props}) => { + return ( + { + // Navigate by clicking or pressing enter + if (event.type === 'click' || ('key' in event && event.key === 'Enter')) { + window.open(href) + } + + onSelect?.(event) + }} + {...props} + /> + ) +} + +// ---------------------------------------------------------------------------- +// TreeView.SubTree + +export type TreeViewSubTreeProps = { + children?: React.ReactNode +} + +const SubTree: React.FC = ({children}) => { + const {isExpanded} = React.useContext(ItemContext) + return ( + + ) +} + +function useSubTree(children: React.ReactNode) { + return React.useMemo(() => { + const subTree = React.Children.toArray(children).find( + child => React.isValidElement(child) && child.type === SubTree + ) + + const childrenWithoutSubTree = React.Children.toArray(children).filter( + child => !(React.isValidElement(child) && child.type === SubTree) + ) + + return { + subTree, + childrenWithoutSubTree, + hasSubTree: Boolean(subTree) + } + }, [children]) +} + +// ---------------------------------------------------------------------------- +// Export + +export const TreeView = Object.assign(Root, { + Item, + LinkItem, + SubTree +}) diff --git a/src/TreeView/index.ts b/src/TreeView/index.ts new file mode 100644 index 00000000000..e1e4aea33b1 --- /dev/null +++ b/src/TreeView/index.ts @@ -0,0 +1 @@ +export * from './TreeView' diff --git a/src/drafts/index.ts b/src/drafts/index.ts index c375241e03a..23bd8a7d9ba 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -4,10 +4,7 @@ * But, they are published on npm and you can import them for experimentation/feedback. * example: import {ActionList} from '@primer/react/drafts */ -export * from '../NavList' export * from '../Dialog/Dialog' -export * from '../SegmentedControl' // TODO: remove from drafts bundle in next major release -export * from '../SplitPageLayout' export {default as InlineAutocomplete} from './InlineAutocomplete' export type { @@ -27,3 +24,10 @@ export * from './MarkdownEditor' export * from '../UnderlineNav2' export * from './hooks' + +export * from '../TreeView' + +// TODO: Remove these components from the drafts bundle in the next major release +export * from '../NavList' +export * from '../SegmentedControl' +export * from '../SplitPageLayout'