Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-birds-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Add draft TreeView component
173 changes: 173 additions & 0 deletions docs/content/TreeView.mdx
Original file line number Diff line number Diff line change
@@ -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
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.Item>
src
<TreeView.SubTree>
<TreeView.LinkItem href="#">Avatar.tsx</TreeView.LinkItem>
<TreeView.Item>
Button
<TreeView.SubTree>
<TreeView.LinkItem href="#">Button.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">Button.test.tsx</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.Item>
public
<TreeView.SubTree>
<TreeView.LinkItem href="#">index.html</TreeView.LinkItem>
<TreeView.LinkItem href="#">favicon.ico</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.LinkItem href="#">package.json</TreeView.LinkItem>
</TreeView>
</nav>
```

### File tree navigation with directory links

```jsx live drafts
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.LinkItem href="#">
src
<TreeView.SubTree>
<TreeView.LinkItem href="#">Avatar.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">
Button
<TreeView.SubTree>
<TreeView.LinkItem href="#">Button.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">Button.test.tsx</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
<TreeView.LinkItem href="#">
public
<TreeView.SubTree>
<TreeView.LinkItem href="#">index.html</TreeView.LinkItem>
<TreeView.LinkItem href="#">favicon.ico</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
<TreeView.LinkItem href="#">package.json</TreeView.LinkItem>
</TreeView>
</nav>
```

## Props

### TreeView

<PropsTable>
<PropsTableRow name="children" type="React.ReactNode" required />
{/* <PropsTableSxRow /> */}
</PropsTable>

### TreeView.Item

<PropsTable>
<PropsTableRow name="children" type="React.ReactNode" required />
<PropsTableRow
name="onSelect"
type="(event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void"
/>
<PropsTableRow name="onToggle" type="(isExpanded: boolean) => void" />
{/* <PropsTableSxRow /> */}
</PropsTable>

### TreeView.LinkItem

<PropsTable>
<PropsTableRow name="children" type="React.ReactNode" required />
<PropsTableRow
name="href"
type="string"
description={
<>
The URL that the item navigates to. <InlineCode>href</InlineCode> is passed to the underlying{' '}
<InlineCode>&lt;a&gt;</InlineCode> element. If <InlineCode>as</InlineCode> is specified, the component may need
different props. If the item contains a sub-nav, the item is rendered as a{' '}
<InlineCode>&lt;button&gt;</InlineCode> and <InlineCode>href</InlineCode> is ignored.
</>
}
/>
<PropsTableRow
name="onSelect"
type="(event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void"
/>
<PropsTableRow name="onToggle" type="(isExpanded: boolean) => void" />
{/* <PropsTableSxRow /> */}
</PropsTable>

### TreeView.SubTree

<PropsTable>
<PropsTableRow name="children" type="React.ReactNode" />
{/* <PropsTableSxRow /> */}
</PropsTable>

<!-- TODO: Add leading and trailing visuals -->

<!-- ### TreeView.LeadingVisual

<PropsTable>
<PropsTableRow
name="children"
type={`| React.ReactNode
| (props: {isExpanded: boolean}) => React.ReactNode`}
/>
<PropsTableSxRow />
</PropsTable>

### TreeView.TrailingVisual

<PropsTable>
<PropsTableRow
name="children"
type={`| React.ReactNode
| (props: {isExpanded: boolean}) => React.ReactNode`}
/>
<PropsTableSxRow />
</PropsTable>

### TreeView.FolderIcon

<PropsTable>
<PropsTableSxRow />
</PropsTable> -->

<!-- TODO: Add components to support async behavior (e.g. LoadingItem) -->

## Status

<ComponentChecklist
items={{
propsDocumented: true,
noUnnecessaryDeps: false,
adaptsToThemes: false,
adaptsToScreenSizes: false,
fullTestCoverage: false,
usedInProduction: false,
usageExamplesDocumented: false,
hasStorybookStories: false,
designReviewed: false,
a11yReviewed: false,
stableApi: false,
addressedApiFeedback: false,
hasDesignGuidelines: false,
hasFigmaComponent: false
}}
/>
81 changes: 81 additions & 0 deletions src/TreeView/TreeView.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Box p={3}>
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.LinkItem href="#">
src
<TreeView.SubTree>
<TreeView.LinkItem href="#">Avatar.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">
Button
<TreeView.SubTree>
<TreeView.LinkItem href="#">Button.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">Button.test.tsx</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
<TreeView.LinkItem
href="#"
// eslint-disable-next-line no-console
onToggle={isExpanded => console.log(`${isExpanded ? 'Expanded' : 'Collapsed'} "public" folder `)}
>
public
<TreeView.SubTree>
<TreeView.LinkItem href="#">index.html</TreeView.LinkItem>
<TreeView.LinkItem href="#">favicon.ico</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
<TreeView.LinkItem href="#">package.json</TreeView.LinkItem>
</TreeView>
</nav>
</Box>
)

export const FileTreeWithoutDirectoryLinks: Story = () => (
<Box p={3}>
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.Item>
src
<TreeView.SubTree>
<TreeView.LinkItem href="#">Avatar.tsx</TreeView.LinkItem>
<TreeView.Item>
Button
<TreeView.SubTree>
<TreeView.LinkItem href="#">Button.tsx</TreeView.LinkItem>
<TreeView.LinkItem href="#">Button.test.tsx</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.Item
// eslint-disable-next-line no-console
onToggle={isExpanded => console.log(`${isExpanded ? 'Expanded' : 'Collapsed'} "public" folder `)}
>
public
<TreeView.SubTree>
<TreeView.LinkItem href="#">index.html</TreeView.LinkItem>
<TreeView.LinkItem href="#">favicon.ico</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.LinkItem href="#">package.json</TreeView.LinkItem>
</TreeView>
</nav>
</Box>
)

export default meta
50 changes: 50 additions & 0 deletions src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TreeView aria-label="Test tree">
<TreeView.Item>Item 1</TreeView.Item>
<TreeView.Item>Item 2</TreeView.Item>
<TreeView.Item>Item 3</TreeView.Item>
</TreeView>
)

const root = queryByRole('tree')

expect(root).toHaveAccessibleName('Test tree')
})

it('uses treeitem role', () => {
const {queryAllByRole} = render(
<TreeView aria-label="Test tree">
<TreeView.Item>Item 1</TreeView.Item>
<TreeView.Item>Item 2</TreeView.Item>
<TreeView.Item>Item 3</TreeView.Item>
</TreeView>
)

const items = queryAllByRole('treeitem')

expect(items).toHaveLength(3)
})

it('hides subtrees by default', () => {
const {queryByRole} = render(
<TreeView aria-label="Test tree">
<TreeView.Item>
Parent
<TreeView.SubTree>
<TreeView.Item>Child</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
)

const parentItem = queryByRole('treeitem', {name: 'Parent'})
const subtree = queryByRole('group')

expect(parentItem).toHaveAttribute('aria-expanded', 'false')
expect(subtree).toBeNull()
})
Loading