Skip to content

Commit 126b926

Browse files
authored
feat(components): Add Tree component (#1727)
1 parent acb181e commit 126b926

File tree

5 files changed

+568
-0
lines changed

5 files changed

+568
-0
lines changed

.changeset/fifty-fans-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@launchpad-ui/components": minor
3+
---
4+
5+
Tree component
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { render, screen, userEvent } from '../../../test/utils';
4+
import { Tree, TreeItem, TreeItemContent } from '../src/Tree';
5+
6+
describe('a Tree', () => {
7+
const user = userEvent.setup();
8+
9+
describe('with default configuration', () => {
10+
it('is visible in the document', async () => {
11+
render(
12+
<Tree aria-label="Test tree">
13+
<TreeItem id="1" textValue="Item one">
14+
<TreeItemContent>Item one</TreeItemContent>
15+
</TreeItem>
16+
</Tree>,
17+
);
18+
19+
expect(await screen.findByRole('treegrid')).toBeVisible();
20+
});
21+
});
22+
23+
describe('with single selection mode', () => {
24+
it('has selected state when an item is clicked', async () => {
25+
render(
26+
<Tree aria-label="Test tree" selectionMode="single">
27+
<TreeItem id="1" textValue="Item one">
28+
<TreeItemContent>Item one</TreeItemContent>
29+
</TreeItem>
30+
</Tree>,
31+
);
32+
33+
const item = screen.getByText('Item one');
34+
await user.click(item);
35+
expect(item.closest('[role="row"]')).toHaveAttribute('aria-selected', 'true');
36+
});
37+
});
38+
39+
describe('with multiple selection mode and toggle behavior', () => {
40+
it('renders checkboxes for each item', () => {
41+
render(
42+
<Tree aria-label="Test tree" selectionMode="multiple">
43+
<TreeItem id="1" textValue="Item one">
44+
<TreeItemContent>Item one</TreeItemContent>
45+
</TreeItem>
46+
<TreeItem id="2" textValue="Item two">
47+
<TreeItemContent>Item two</TreeItemContent>
48+
</TreeItem>
49+
</Tree>,
50+
);
51+
52+
const items = screen.getAllByRole('row');
53+
items.forEach((item) => {
54+
expect(item.querySelector('[class*="checkbox"]')).toBeInTheDocument();
55+
});
56+
});
57+
58+
it('updates selection state when checkboxes are clicked', async () => {
59+
render(
60+
<Tree aria-label="Test tree" selectionMode="multiple">
61+
<TreeItem id="1" textValue="Item one">
62+
<TreeItemContent>Item one</TreeItemContent>
63+
</TreeItem>
64+
<TreeItem id="2" textValue="Item two">
65+
<TreeItemContent>Item two</TreeItemContent>
66+
</TreeItem>
67+
</Tree>,
68+
);
69+
70+
const itemOne = screen.getByText('Item one');
71+
const itemTwo = screen.getByText('Item two');
72+
const checkboxOne = itemOne.closest('[role="row"]')?.querySelector('[class*="checkbox"]');
73+
const checkboxTwo = itemTwo.closest('[role="row"]')?.querySelector('[class*="checkbox"]');
74+
75+
await user.click(checkboxOne as HTMLElement);
76+
await user.click(checkboxTwo as HTMLElement);
77+
78+
expect(itemOne.closest('[role="row"]')).toHaveAttribute('aria-selected', 'true');
79+
expect(itemTwo.closest('[role="row"]')).toHaveAttribute('aria-selected', 'true');
80+
});
81+
});
82+
83+
describe('with expandable items', () => {
84+
it('shows child items when expanded', async () => {
85+
render(
86+
<Tree aria-label="Test tree">
87+
<TreeItem id="1" textValue="Item one">
88+
<TreeItemContent>Item one</TreeItemContent>
89+
<TreeItem id="1-1" textValue="Item one-one">
90+
<TreeItemContent>Item one-one</TreeItemContent>
91+
</TreeItem>
92+
</TreeItem>
93+
</Tree>,
94+
);
95+
96+
const parentItem = screen.getByText('Item one');
97+
const chevron = parentItem.closest('[role="row"]')?.querySelector('button');
98+
99+
await user.click(chevron as HTMLElement);
100+
101+
expect(parentItem.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'true');
102+
expect(screen.getByText('Item one-one')).toBeVisible();
103+
});
104+
});
105+
106+
describe('with disabled items', () => {
107+
it('prevents selection of disabled items', async () => {
108+
render(
109+
<Tree aria-label="Test tree" selectionMode="multiple">
110+
<TreeItem id="1" textValue="Item one" isDisabled>
111+
<TreeItemContent>Item one</TreeItemContent>
112+
</TreeItem>
113+
</Tree>,
114+
);
115+
116+
const item = screen.getByText('Item one');
117+
const checkbox = item.closest('[role="row"]')?.querySelector('[class*="checkbox"]');
118+
119+
expect(item.closest('[role="row"]')).toHaveAttribute('aria-disabled', 'true');
120+
await user.click(checkbox as HTMLElement);
121+
expect(item.closest('[role="row"]')).not.toHaveAttribute('aria-selected', 'true');
122+
});
123+
});
124+
});

packages/components/src/Tree.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Ref } from 'react';
2+
import type {
3+
TreeItemProps as AriaTreeItemProps,
4+
TreeProps as AriaTreeProps,
5+
ContextValue,
6+
TreeItemContentProps,
7+
TreeItemContentRenderProps,
8+
} from 'react-aria-components';
9+
10+
import { Icon } from '@launchpad-ui/icons';
11+
import { cva } from 'class-variance-authority';
12+
import { createContext } from 'react';
13+
import {
14+
Tree as AriaTree,
15+
TreeItem as AriaTreeItem,
16+
TreeItemContent as AriaTreeItemContent,
17+
composeRenderProps,
18+
} from 'react-aria-components';
19+
20+
import { Button } from './Button';
21+
import { CheckboxIcon, checkboxStyles } from './Checkbox';
22+
import styles from './styles/Tree.module.css';
23+
import { useLPContextProps } from './utils';
24+
25+
const treeStyles = cva(styles.tree);
26+
const treeItemStyles = cva(styles.item);
27+
28+
interface TreeProps<T> extends AriaTreeProps<T> {
29+
ref?: Ref<HTMLDivElement>;
30+
}
31+
32+
interface TreeItemProps<T> extends AriaTreeItemProps<T> {
33+
ref?: Ref<HTMLDivElement>;
34+
}
35+
36+
// biome-ignore lint/suspicious/noExplicitAny: ignore
37+
const TreeContext = createContext<ContextValue<TreeProps<any>, HTMLDivElement>>(null);
38+
39+
/**
40+
* A tree displays a hierarchical list of items that can be expanded and collapsed.
41+
*/
42+
const Tree = <T extends object>({ ref, ...props }: TreeProps<T>) => {
43+
[props, ref] = useLPContextProps(props, ref, TreeContext);
44+
return (
45+
<AriaTree
46+
{...props}
47+
ref={ref}
48+
className={composeRenderProps(props.className, (className, renderProps) =>
49+
treeStyles({ ...renderProps, className }),
50+
)}
51+
/>
52+
);
53+
};
54+
55+
/**
56+
* A TreeItemContent wrapper component that handles the chevron button and layout.
57+
*/
58+
function TreeItemContent(
59+
props: Omit<TreeItemContentProps, 'children'> & { children?: React.ReactNode },
60+
) {
61+
return (
62+
<AriaTreeItemContent>
63+
{({
64+
hasChildItems,
65+
isExpanded,
66+
selectionBehavior,
67+
selectionMode,
68+
isSelected,
69+
isDisabled,
70+
}: TreeItemContentRenderProps) => (
71+
<>
72+
{hasChildItems && (
73+
<Button slot="chevron" variant="minimal" size="small" className={styles.chevron}>
74+
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="small" />
75+
</Button>
76+
)}
77+
{selectionBehavior === 'toggle' && selectionMode === 'multiple' && (
78+
<div
79+
className={checkboxStyles()}
80+
data-selected={isSelected || undefined}
81+
data-disabled={isDisabled || undefined}
82+
>
83+
<CheckboxIcon isSelected={isSelected} />
84+
</div>
85+
)}
86+
<div className={styles.content}>{props.children}</div>
87+
</>
88+
)}
89+
</AriaTreeItemContent>
90+
);
91+
}
92+
93+
/**
94+
* A TreeItem represents an individual item in a Tree.
95+
*/
96+
const TreeItem = <T extends object>({ ref, ...props }: TreeItemProps<T>) => {
97+
return (
98+
<AriaTreeItem
99+
{...props}
100+
ref={ref}
101+
className={composeRenderProps(props.className, (className, renderProps) =>
102+
treeItemStyles({ ...renderProps, className }),
103+
)}
104+
/>
105+
);
106+
};
107+
108+
export { Tree, TreeContext, TreeItem, TreeItemContent, treeStyles, treeItemStyles };
109+
export type { TreeProps, TreeItemProps, TreeItemContentProps, TreeItemContentRenderProps };
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
.tree {
2+
max-height: inherit;
3+
overflow: auto;
4+
outline: none;
5+
min-width: var(--lp-size-144);
6+
}
7+
8+
.item {
9+
composes: interactive from './base.module.css';
10+
padding-block: var(--lp-spacing-200);
11+
padding-inline: var(--lp-spacing-300);
12+
border-radius: var(--lp-border-radius-regular);
13+
outline: none;
14+
color: var(--lp-color-text-ui-primary-base);
15+
font: var(--lp-text-label-1-medium);
16+
position: relative;
17+
display: flex;
18+
column-gap: var(--lp-spacing-300);
19+
align-items: center;
20+
forced-color-adjust: none;
21+
text-decoration: none;
22+
23+
&:where([data-level]:not([data-level='1'])) {
24+
padding-left: calc(var(--tree-item-level, 1) * var(--lp-spacing-500));
25+
}
26+
27+
&[data-hovered] {
28+
background-color: var(--lp-color-bg-interactive-secondary-hover);
29+
}
30+
31+
&[data-pressed] {
32+
background-color: var(--lp-color-bg-interactive-secondary-active);
33+
}
34+
35+
&[data-focus-visible],
36+
&[data-focused] {
37+
background-color: var(--lp-color-bg-interactive-secondary-hover);
38+
}
39+
40+
&[data-disabled] {
41+
color: var(--lp-color-text-interactive-disabled);
42+
}
43+
44+
& .content {
45+
display: flex;
46+
align-items: center;
47+
column-gap: var(--lp-spacing-300);
48+
flex: 1;
49+
min-width: 0;
50+
}
51+
52+
& .chevron {
53+
padding: var(--lp-spacing-100);
54+
border-radius: var(--lp-border-radius-regular);
55+
color: var(--lp-color-text-ui-primary-base);
56+
57+
&[data-pressed] {
58+
background-color: var(--lp-color-bg-interactive-secondary-active);
59+
}
60+
}
61+
62+
&:has([slot='label']) {
63+
& .content {
64+
display: grid;
65+
grid-template-areas:
66+
'label'
67+
'desc';
68+
}
69+
}
70+
71+
& [slot='label'] {
72+
grid-area: label;
73+
display: flex;
74+
align-items: center;
75+
gap: var(--lp-spacing-300);
76+
}
77+
78+
& [slot='description'] {
79+
grid-area: desc;
80+
font: var(--lp-text-label-2-regular);
81+
color: var(--lp-color-text-ui-secondary-base);
82+
}
83+
}

0 commit comments

Comments
 (0)