Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/components/src/components/Tree/Tree.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
}
}

.kbq-TreeItem:has([data-slot='list-item-addon']) {
[data-slot='chevron'],
[data-slot='checkbox'] {
margin-inline-end: var(--kbq-size-s);
}
}

.kbq-TreeLoader {
display: flex;
align-items: center;
Expand Down
25 changes: 17 additions & 8 deletions packages/components/src/components/Tree/Tree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Use these components to build hierarchical navigation, selection, and lazy loadi
- `Tree` — Root container for hierarchical items.
- `Tree.Item` — Defines a node in the tree (leaf or branch).
- `Tree.ItemContent` — Customizes row content (text, icons, slots).
- `Tree.ItemContentText` — Displays text and an optional caption in an item.
- `Tree.ItemContentAddon` — Displays an icon, badge, or other secondary content in an item.
- `Tree.LoadMoreItem` — Triggers and displays async loading state for additional items.

## Content
Expand All @@ -53,12 +55,23 @@ Use `selectionBehavior="toggle"` to render checkbox selection controls.

<Story of={Stories.MultipleSelection} />

## Slots
## Item content

Use `Tree.ItemContent` to customize item layout.
For example, you can add icons, typography, badges, and slot-specific props for chevron and checkbox controls.
The `Tree.ItemContent` can be composed with helper components:

<Story of={Stories.Slots} />
- `Tree.ItemContentText` for text and captions.
- `Tree.ItemContentAddon` for icons, badges, or other secondary content.

The `Tree.Item` also have layout props, such as `align`, which sets vertical alignment.
Use `align="start"` for captions or multi-line content.

<Story of={Stories.ItemContent} />

## Item actions

Add an extra action to an item, revealed on hover or focus.

<Story of={Stories.ItemActions} />

## Empty state

Expand Down Expand Up @@ -92,7 +105,3 @@ Tree supports progressive loading with `Tree.LoadMoreItem`.
Combine it with `useAsyncList` to fetch nested data on demand.

<Story of={Stories.AsyncLoading} />

## Other examples

<Story of={Stories.Examples} />
237 changes: 117 additions & 120 deletions packages/components/src/components/Tree/Tree.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useState } from 'react';

import { IconEllipsisVertical16, IconFolder16 } from '@koobiq/react-icons';
import {
IconCircle16,
IconEllipsisVertical16,
IconFolder16,
} from '@koobiq/react-icons';
import { Collection } from '@koobiq/react-primitives';
import type { Meta, StoryObj } from '@storybook/react';

import { FlexBox, useAsyncList } from '../../index';
import { useAsyncList } from '../../index';
import type { Selection } from '../../index';
import { Badge } from '../Badge';
import { IconButton } from '../IconButton';
import { spacing } from '../layout';
import { Menu } from '../Menu';
Expand All @@ -22,10 +27,12 @@ const meta = {
subcomponents: {
'Tree.Item': Tree.Item,
'Tree.ItemContent': Tree.ItemContent,
'Tree.ItemContentText': Tree.ItemContentText,
'Tree.ItemContentAddon': Tree.ItemContentAddon,
'Tree.LoadMoreItem': Tree.LoadMoreItem,
},
argTypes: {},
tags: ['status:new', 'date:2026-03-02'],
tags: ['status:updated', 'date:2026-07-03'],
} satisfies Meta<typeof Tree>;

export default meta;
Expand Down Expand Up @@ -222,7 +229,95 @@ export const Content: Story = {
},
};

export const Slots: Story = {
export const ItemContent: Story = {
name: 'Item content',
parameters: {
layout: 'padded',
},
render: function Render() {
const longText =
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. A at cupiditate dolor itaque molestias quasi quisquam quo. Deleniti ducimus fugit nulla repudiandae tenetur. Aliquid autem corporis culpa debitis exercitationem inventore labore nihil officia recusandae veniam. Beatae, doloribus, suscipit. Aut beatae consectetur consequuntur cum hic, obcaecati quia sunt temporibus unde vel!';

return (
<Tree
aria-label="Project files"
selectionMode="multiple"
defaultExpandedKeys={['app']}
>
<Tree.Item id="app" textValue="app">
<Tree.ItemContent>
<Tree.ItemContentAddon>
<IconFolder16 />
</Tree.ItemContentAddon>
<Tree.ItemContentText>app</Tree.ItemContentText>
</Tree.ItemContent>
<Tree.Item id="index-html" textValue="index.html" align="start">
<Tree.ItemContent>
<Tree.ItemContentText caption="Entry point">
index.html
</Tree.ItemContentText>
</Tree.ItemContent>
</Tree.Item>
</Tree.Item>
<Tree.Item id="gitignore-file" textValue=".gitignore" align="start">
<Tree.ItemContent>
<Tree.ItemContentAddon>
<IconCircle16 />
</Tree.ItemContentAddon>
<Tree.ItemContentText caption="Project documentation">
.gitignore
</Tree.ItemContentText>
<Tree.ItemContentAddon>
<Badge size="compact">Badge</Badge>
</Tree.ItemContentAddon>
</Tree.ItemContent>
</Tree.Item>
<Tree.Item id="readme" textValue="README.md" align="start">
<Tree.ItemContent>
<Tree.ItemContentAddon>
<IconCircle16 />
</Tree.ItemContentAddon>
<Tree.ItemContentText caption="Project documentation">
README.md
</Tree.ItemContentText>
</Tree.ItemContent>
</Tree.Item>

<Tree.Item id="lorem-1" textValue={longText} align="start">
<Tree.ItemContent>
<Tree.ItemContentAddon>
<IconCircle16 />
</Tree.ItemContentAddon>
<Tree.ItemContentText
caption={longText}
slotProps={{
caption: { ellipsis: true },
}}
>
{longText}
</Tree.ItemContentText>
</Tree.ItemContent>
</Tree.Item>
<Tree.Item id="lorem-2" textValue={longText} align="start">
<Tree.ItemContent>
<Tree.ItemContentAddon>
<IconCircle16 />
</Tree.ItemContentAddon>
<Tree.ItemContentText caption={longText}>
{longText}
</Tree.ItemContentText>
<Tree.ItemContentAddon>
<Badge size="compact">Badge</Badge>
</Tree.ItemContentAddon>
</Tree.ItemContent>
</Tree.Item>
</Tree>
);
},
};

export const ItemActions: Story = {
name: 'Item actions',
parameters: {
layout: 'padded',
},
Expand All @@ -240,22 +335,28 @@ export const Slots: Story = {
<Tree.ItemContent>
{({ isHovered, isFocusVisibleWithin }) => (
<>
{type === 'directory' && <IconFolder16 />}
{title}
{type === 'directory' && (
<Tree.ItemContentAddon>
<IconFolder16 />
</Tree.ItemContentAddon>
)}
<Tree.ItemContentText>{title}</Tree.ItemContentText>
{(isHovered || isFocusVisibleWithin || isMenuOpen) && (
<Menu
onOpenChange={setIsMenuOpen}
control={(props) => (
<IconButton
{...props}
size="l"
variant="fade-contrast"
aria-label="More actions"
className={spacing({ mis: 'auto' })}
isCompact
>
<IconEllipsisVertical16 />
</IconButton>
<Tree.ItemContentAddon>
<IconButton
{...props}
size="l"
variant="fade-contrast"
aria-label="More actions"
className={spacing({ mis: 'auto' })}
isCompact
>
<IconEllipsisVertical16 />
</IconButton>
</Tree.ItemContentAddon>
)}
>
<Menu.Item key="edit">Edit</Menu.Item>
Expand Down Expand Up @@ -547,107 +648,3 @@ export const AsyncLoading: Story = {
);
},
};

export const Examples: Story = {
parameters: {
layout: 'padded',
},
render: (args) => (
<Tree
aria-label="Project files"
selectionMode="single"
defaultExpandedKeys={['long-folder-1']}
{...args}
>
<Tree.Item id="app" textValue="app">
<Tree.ItemContent>app</Tree.ItemContent>
<Tree.Item id="http" textValue="Http">
<Tree.ItemContent>Http</Tree.ItemContent>
<Tree.Item id="index-html" textValue="index.html">
<Tree.ItemContent>index.html</Tree.ItemContent>
</Tree.Item>
</Tree.Item>
<Tree.Item id="providers" textValue="Providers">
<Tree.ItemContent>Providers</Tree.ItemContent>
<Tree.Item
id="event-service-provider-js"
textValue="EventServiceProvider.js"
>
<Tree.ItemContent>EventServiceProvider.js</Tree.ItemContent>
</Tree.Item>
</Tree.Item>
</Tree.Item>
<Tree.Item id="config" textValue="config">
<Tree.ItemContent>config</Tree.ItemContent>
<Tree.Item id="config-app-js" textValue="app.js">
<Tree.ItemContent>app.js</Tree.ItemContent>
</Tree.Item>
<Tree.Item id="database-js" textValue="database.js">
<Tree.ItemContent>database.js</Tree.ItemContent>
</Tree.Item>
</Tree.Item>
<Tree.Item
id="long-folder-1"
textValue="Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus
asperiores delectus doloremque fugiat illo laudantium nesciunt
omnis. Aliquam, earum, velit?"
>
<Tree.ItemContent>
<Typography ellipsis>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus
asperiores delectus doloremque fugiat illo laudantium nesciunt
omnis. Aliquam, earum, velit?
</Typography>
</Tree.ItemContent>
<Tree.Item
id="long-file-1"
textValue="Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus
asperiores delectus doloremque fugiat illo laudantium nesciunt omnis.
Aliquam, earum, velit?"
>
<Tree.ItemContent>
<Typography ellipsis>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Accusamus asperiores delectus doloremque fugiat illo laudantium
nesciunt omnis. Aliquam, earum, velit?
</Typography>
</Tree.ItemContent>
</Tree.Item>
</Tree.Item>
<Tree.Item id="env-file" textValue=".env">
<Tree.ItemContent>.env</Tree.ItemContent>
</Tree.Item>
<Tree.Item id="gitignore-file" textValue=".gitignore">
<Tree.ItemContent>.gitignore</Tree.ItemContent>
</Tree.Item>
<Tree.Item id="readme-file" textValue="README.md">
<Tree.ItemContent>README.md</Tree.ItemContent>
</Tree.Item>
<Tree.Item
style={{ alignItems: 'flex-start' }}
id="long-file-2"
textValue="Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus asperiores delectus doloremque fugiat illo laudantium nesciunt omnis. Aliquam, earum, velit?"
>
<Tree.ItemContent>
<FlexBox gap="3xs" direction="column" style={{ minWidth: 0 }}>
<Typography style={{ width: '100%' }} ellipsis>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Accusamus asperiores delectus doloremque fugiat illo laudantium
nesciunt omnis. Aliquam, earum, velit?
</Typography>
<Typography
style={{ width: '100%' }}
color="contrast-secondary"
variant="text-compact"
ellipsis
>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Accusamus asperiores delectus doloremque fugiat illo laudantium
nesciunt omnis. Aliquam, earum, velit?
</Typography>
</FlexBox>
</Tree.ItemContent>
</Tree.Item>
</Tree>
),
};
5 changes: 5 additions & 0 deletions packages/components/src/components/Tree/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Tree as AriaTree, composeRenderProps } from '@koobiq/react-primitives';

import './Tree.css';
import { utilClasses } from '../../styles/utility';
import { ListItemAddon, ListItemText } from '../List/components';

import { TreeItem, TreeItemContent, TreeLoadMoreItem } from './components';

Expand Down Expand Up @@ -41,6 +42,8 @@ TreeComponent.displayName = 'Tree';
type CompoundedComponent = typeof TreeComponent & {
Item: typeof TreeItem;
ItemContent: typeof TreeItemContent;
ItemContentText: typeof ListItemText;
ItemContentAddon: typeof ListItemAddon;
LoadMoreItem: typeof TreeLoadMoreItem;
};

Expand All @@ -52,4 +55,6 @@ export const Tree = TreeComponent as CompoundedComponent;

TreeComponent.Item = TreeItem;
TreeComponent.ItemContent = TreeItemContent;
TreeComponent.ItemContentText = ListItemText;
TreeComponent.ItemContentAddon = ListItemAddon;
TreeComponent.LoadMoreItem = TreeLoadMoreItem;
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ export function TreeItem({
children,
className,
textValue,
align = 'center',
...props
}: TreeItemProps) {
return (
<AriaTreeItem
{...props}
textValue={textValue ?? ''}
data-align={align}
className={composeRenderProps(className, (className) =>
clsx('kbq-TreeItem', listItem, textVariant['text-normal'], className)
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { DataAttributeProps } from '@koobiq/react-core';
import type { TreeItemProps as AriaTreeItemProps } from '@koobiq/react-primitives';

export type TreeItemProps = Partial<AriaTreeItemProps> & DataAttributeProps;
import type { ItemPropAlign } from '../../../Collections';

export type TreeItemProps = Partial<AriaTreeItemProps> &
DataAttributeProps & {
/**
* Vertical alignment of the item content.
* @default 'center'
*/
align?: ItemPropAlign;
};
Loading
Loading