@happyhyep/tree-component
Version:
React Tree Component with Search functionality
774 lines (621 loc) β’ 20.1 kB
Markdown
[π°π· νκ΅μ΄](
> **React Tree Component Library** where **both folders and files are clickable**
[](https://www.npmjs.com/package/@happyhyep/tree-component)
[](https://opensource.org/licenses/MIT)
Unlike other Tree components, this **revolutionary Tree component** allows **both folders and files to be clickable** for interaction.
| Feature | Typical Tree | **π³ Tree Component** |
| -------------------- | ----------------------------------- | ------------------------------------ |
| Folder Click | β Expand/collapse only | β
**Click event + expand/collapse** |
| File Click | β
Clickable | β
Clickable |
| Search Feature | β Requires separate implementation | β
**Built-in search + highlight** |
| Default Expand State | β Manual setup | β
**Expand all folders at once** |
| TypeScript | β οΈ Limited support | β
**Full type safety** |

```bash
npm install @happyhyep/tree-component
yarn add @happyhyep/tree-component
pnpm add @happyhyep/tree-component
```
```tsx
import React, { useState } from 'react';
import { Tree, TreeItem } from '@happyhyep/tree-component';
interface FileData {
name: string;
type: 'folder' | 'file';
size?: number;
}
const data: TreeItem<FileData>[] = [
{
id: '1',
parentId: null,
canOpen: true,
data: { name: 'Documents', type: 'folder' },
},
{
id: '2',
parentId: '1',
canOpen: false,
data: { name: 'report.pdf', type: 'file', size: 1024 },
},
{
id: '3',
parentId: '1',
canOpen: true,
data: { name: 'Projects', type: 'folder' },
},
];
function MyApp() {
const [selectedId, setSelectedId] = useState<string>();
return (
<Tree
items={data}
selectedId={selectedId}
onItemClick={(item) => {
setSelectedId(item.id);
console.log('Clicked item:', item.data);
// π‘ Both folders and files are clickable!
if (item.data.type === 'folder') {
console.log('Folder clicked:', item.data.name);
} else {
console.log('File clicked:', item.data.name);
}
}}
renderLabel={(data) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{data.type === 'folder' ? 'π' : 'π'}</span>
<span>{data.name}</span>
{data.size && <span>({data.size}KB)</span>}
</div>
)}
/>
);
}
```
```tsx
import { TreeWithSearch } from '@happyhyep/tree-component';
function SearchableTree() {
const [selectedId, setSelectedId] = useState<string>();
return (
<TreeWithSearch
items={data}
selectedId={selectedId}
onItemClick={(item) => setSelectedId(item.id)}
renderLabel={(data, highlight) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{data.type === 'folder' ? 'π' : 'π'}</span>
{/* π Automatic search term highlighting */}
<HighlightText text={data.name} highlight={highlight} />
</div>
)}
searchFn={(data, keyword) => data.name.toLowerCase().includes(keyword.toLowerCase())}
>
{/* π― Built-in search input */}
<TreeWithSearch.Input placeholder="Search files..." />
</TreeWithSearch>
);
}
// Highlight helper component
const HighlightText = ({ text, highlight }) => {
if (!highlight) return <span>{text}</span>;
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === highlight.toLowerCase() ? <mark key={i}>{part}</mark> : part,
)}
</span>
);
};
```
```tsx
function ExpandedTree() {
return (
<Tree
items={data}
defaultExpandAll={true} // π Expand all folders by default
renderLabel={(data) => <span>{data.name}</span>}
/>
);
}
```
```tsx
import { Tree } from '@happyhyep/tree-component';
function FileExplorer() {
const [selectedFile, setSelectedFile] = useState(null);
const handleItemClick = (item) => {
if (item.data.type === 'file') {
// File click - open file
openFile(item.data);
} else {
// Folder click - select folder (expand/collapse is automatic)
setSelectedFolder(item.data);
}
};
return (
<div style={{ display: 'flex' }}>
<Tree
items={fileSystemData}
onItemClick={handleItemClick}
renderLabel={(data) => <FileIcon type={data.type} name={data.name} />}
/>
{selectedFile && <FilePreview file={selectedFile} />}
</div>
);
}
```
```tsx
function OrganizationChart() {
return (
<Tree
items={orgData}
defaultExpandAll={true}
onItemClick={(item) => {
// Both departments and employees are clickable
showPersonDetails(item.data);
}}
renderLabel={(data) => (
<div>
<strong>{data.name}</strong>
<span>({data.position})</span>
</div>
)}
/>
);
}
```
```tsx
interface TreeItem<T = unknown> {
id: string; // Unique identifier
parentId: string | null; // Parent ID (null for root)
data: T; // User data
canOpen?: boolean; // Whether it can be expanded
hasLeaf?: boolean; // Whether it's a leaf node
children?: TreeItem<T>[]; // Child nodes (auto-generated)
}
```
| Props | Type | Required | Default | Description |
| ------------------ | ----------------------------- | -------- | ------- | -------------------------------------- |
| `items` | `TreeItem<T>[]` | β
| - | Tree data |
| `renderLabel` | `(data: T) => ReactNode` | β
| - | Label rendering function |
| `onItemClick` | `(item: TreeItem<T>) => void` | β | - | **Click handler (both folders/files)** |
| `selectedId` | `string` | β | - | Selected item ID |
| `defaultExpandAll` | `boolean` | β | `false` | **Expand all folders by default** |
| `className` | `string` | β | `""` | CSS class |
### TreeWithSearch Props
All Tree props + additional:
| Props | Type | Required | Description |
| ---------- | --------------------------------------- | -------- | ------------------- |
| `searchFn` | `(data: T, keyword: string) => boolean` | β
| **Search function** |
| `children` | `ReactNode` | β | Search input, etc. |
```tsx
const handleClick = (item) => {
if (item.data.type === 'folder') {
// Folder click - special logic
if (item.data.permissions?.canAccess) {
navigateToFolder(item);
} else {
showPermissionError();
}
} else {
// File click - open file
openFile(item);
}
};
```
```tsx
// Multi-condition search
const advancedSearch = (data, keyword) => {
return (
data.name.toLowerCase().includes(keyword.toLowerCase()) ||
data.tags?.some((tag) => tag.includes(keyword)) ||
data.content?.includes(keyword)
);
};
// Extension search
const extensionSearch = (data, keyword) => {
const extension = data.name.split('.').pop();
return extension?.toLowerCase().includes(keyword.toLowerCase());
};
```
```tsx
// Memoization for large datasets
const MemoizedTree = React.memo(() => (
<Tree
items={largeDataSet}
renderLabel={React.useCallback(
(data) => (
<span>{data.name}</span>
),
[],
)}
/>
));
```
```tsx
<Tree
className="my-custom-tree"
items={data}
renderLabel={(data) => <span className={`item-${data.type}`}>{data.name}</span>}
/>
```
```css
.my-custom-tree {
border: 1px solid
border-radius: 8px;
padding: 16px;
}
.item-folder {
font-weight: bold;
color:
}
.item-file {
color:
}
```
```bash
git clone https://github.com/happyhyep/tree-component.git
cd tree-component
pnpm install
pnpm run storybook
pnpm run build
pnpm run lint
```
Component documentation and examples are available in Storybook:
```bash
pnpm run storybook
```
1. Fork this repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## π License
This project is licensed under the [MIT License](LICENSE).
## πββοΈ Support
- π Bug Reports: [GitHub Issues](https://github.com/happyhyep/tree-component/issues)
- π‘ Feature Requests: [GitHub Discussions](https://github.com/happyhyep/tree-component/discussions)
- π§ Email: happyhyep@example.com
---
## π°π· νκ΅μ΄ ver
**ν΄λμ νμΌ λͺ¨λ ν΄λ¦ κ°λ₯ν** React Tree μ»΄ν¬λνΈ λΌμ΄λΈλ¬λ¦¬ μ
λλ€.
[](https://www.npmjs.com/package/@happyhyep/tree-component)
[](https://opensource.org/licenses/MIT)
## β¨ μ£Όμ μ°¨λ³μ
λ€λ₯Έ Tree μ»΄ν¬λνΈμ λ¬λ¦¬ **ν΄λμ νμΌ λͺ¨λ ν΄λ¦**νμ¬ μνΈμμ©ν μ μλ Tree μ»΄ν¬λνΈμ
λλ€.
| κΈ°λ₯ | μΌλ°μ μΈ Tree | **π³ Tree Component** |
| -------------- | ----------------- | -------------------------------- |
| ν΄λ ν΄λ¦ | β νΌμΉκΈ°/μ κΈ°λ§ | β
**ν΄λ¦ μ΄λ²€νΈ + νΌμΉκΈ°/μ κΈ°** |
| νμΌ ν΄λ¦ | β
ν΄λ¦ κ°λ₯ | β
ν΄λ¦ κ°λ₯ |
| κ²μ κΈ°λ₯ | β λ³λ ꡬν νμ | β
**λ΄μ₯ κ²μ + νμ΄λΌμ΄νΈ** |
| κΈ°λ³Έ νμ₯ μν | β μλ μ€μ | β
**ν λ²μ λͺ¨λ ν΄λ νμ₯** |
| TypeScript | β οΈ μ νμ μ§μ | β
**μμ ν νμ
μμ μ±** |
## π¬ λ°λͺ¨
### Tree μ»΄ν¬λνΈ μ¬μ© μμ

## π¦ μ€μΉ
```bash
# npm
npm install @happyhyep/tree-component
# yarn
yarn add @happyhyep/tree-component
# pnpm
pnpm add @happyhyep/tree-component
```
## π λΉ λ₯Έ μμ
### 1οΈβ£ κΈ°λ³Έ Tree μ»΄ν¬λνΈ
```tsx
import React, { useState } from 'react';
import { Tree, TreeItem } from '@happyhyep/tree-component';
interface FileData {
name: string;
type: 'folder' | 'file';
size?: number;
}
const data: TreeItem<FileData>[] = [
{
id: '1',
parentId: null,
canOpen: true,
data: { name: 'Documents', type: 'folder' },
},
{
id: '2',
parentId: '1',
canOpen: false,
data: { name: 'report.pdf', type: 'file', size: 1024 },
},
{
id: '3',
parentId: '1',
canOpen: true,
data: { name: 'Projects', type: 'folder' },
},
];
function MyApp() {
const [selectedId, setSelectedId] = useState<string>();
return (
<Tree
items={data}
selectedId={selectedId}
onItemClick={(item) => {
setSelectedId(item.id);
console.log('ν΄λ¦λ νλͺ©:', item.data);
// π‘ ν΄λλ νμΌμ΄λ λͺ¨λ ν΄λ¦ κ°λ₯!
if (item.data.type === 'folder') {
console.log('ν΄λ ν΄λ¦:', item.data.name);
} else {
console.log('νμΌ ν΄λ¦:', item.data.name);
}
}}
renderLabel={(data) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{data.type === 'folder' ? 'π' : 'π'}</span>
<span>{data.name}</span>
{data.size && <span>({data.size}KB)</span>}
</div>
)}
/>
);
}
```
```tsx
import { TreeWithSearch } from '@happyhyep/tree-component';
function SearchableTree() {
const [selectedId, setSelectedId] = useState<string>();
return (
<TreeWithSearch
items={data}
selectedId={selectedId}
onItemClick={(item) => setSelectedId(item.id)}
renderLabel={(data, highlight) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{data.type === 'folder' ? 'π' : 'π'}</span>
{/* π κ²μμ΄ μλ νμ΄λΌμ΄νΈ */}
<HighlightText text={data.name} highlight={highlight} />
</div>
)}
searchFn={(data, keyword) => data.name.toLowerCase().includes(keyword.toLowerCase())}
>
{/* π― λ΄μ₯ κ²μ μ
λ ₯μ°½ */}
<TreeWithSearch.Input placeholder="νμΌλͺ
κ²μ..." />
</TreeWithSearch>
);
}
// νμ΄λΌμ΄νΈ ν¬νΌ μ»΄ν¬λνΈ
const HighlightText = ({ text, highlight }) => {
if (!highlight) return <span>{text}</span>;
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === highlight.toLowerCase() ? <mark key={i}>{part}</mark> : part,
)}
</span>
);
};
```
```tsx
function ExpandedTree() {
return (
<Tree
items={data}
defaultExpandAll={true} // π λͺ¨λ ν΄λ κΈ°λ³Έ νμ₯
renderLabel={(data) => <span>{data.name}</span>}
/>
);
}
```
```tsx
import { Tree } from '@happyhyep/tree-component';
function FileExplorer() {
const [selectedFile, setSelectedFile] = useState(null);
const handleItemClick = (item) => {
if (item.data.type === 'file') {
// νμΌ ν΄λ¦ μ - νμΌ μ΄κΈ°
openFile(item.data);
} else {
// ν΄λ ν΄λ¦ μ - ν΄λ μ ν (νΌμΉκΈ°/μ κΈ°λ μλ)
setSelectedFolder(item.data);
}
};
return (
<div style={{ display: 'flex' }}>
<Tree
items={fileSystemData}
onItemClick={handleItemClick}
renderLabel={(data) => <FileIcon type={data.type} name={data.name} />}
/>
{selectedFile && <FilePreview file={selectedFile} />}
</div>
);
}
```
```tsx
function OrganizationChart() {
return (
<Tree
items={orgData}
defaultExpandAll={true}
onItemClick={(item) => {
// λΆμλ μ§μμ΄λ ν΄λ¦ κ°λ₯
showPersonDetails(item.data);
}}
renderLabel={(data) => (
<div>
<strong>{data.name}</strong>
<span>({data.position})</span>
</div>
)}
/>
);
}
```
```tsx
interface TreeItem<T = unknown> {
id: string; // κ³ μ μλ³μ
parentId: string | null; // λΆλͺ¨ ID (루νΈλ null)
data: T; // μ¬μ©μ λ°μ΄ν°
canOpen?: boolean; // νΌμΉ μ μλμ§ μ¬λΆ
hasLeaf?: boolean; // 리ν λ
Έλ μ¬λΆ
children?: TreeItem<T>[]; // μμ λ
Έλ (μλ μμ±)
}
```
| Props | νμ
| νμ | κΈ°λ³Έκ° | μ€λͺ
|
| ------------------ | ----------------------------- | ---- | ------- | -------------------------------- |
| `items` | `TreeItem<T>[]` | β
| - | νΈλ¦¬ λ°μ΄ν° |
| `renderLabel` | `(data: T) => ReactNode` | β
| - | λΌλ²¨ λ λλ§ |
| `onItemClick` | `(item: TreeItem<T>) => void` | β | - | **ν΄λ¦ νΈλ€λ¬ (ν΄λ/νμΌ λͺ¨λ)** |
| `selectedId` | `string` | β | - | μ νλ νλͺ© ID |
| `defaultExpandAll` | `boolean` | β | `false` | **λͺ¨λ ν΄λ κΈ°λ³Έ νμ₯** |
| `className` | `string` | β | `""` | CSS ν΄λμ€ |
Treeμ λͺ¨λ props + μΆκ°:
| Props | νμ
| νμ | μ€λͺ
|
| ---------- | --------------------------------------- | ---- | -------------- |
| `searchFn` | `(data: T, keyword: string) => boolean` | β
| **κ²μ ν¨μ** |
| `children` | `ReactNode` | β | κ²μ μ
λ ₯μ°½ λ± |
```tsx
const handleClick = (item) => {
if (item.data.type === 'folder') {
// ν΄λ ν΄λ¦ - νΉλ³ν λ‘μ§
if (item.data.permissions?.canAccess) {
navigateToFolder(item);
} else {
showPermissionError();
}
} else {
// νμΌ ν΄λ¦ - νμΌ μ΄κΈ°
openFile(item);
}
};
```
```tsx
// λ€μ€ 쑰건 κ²μ
const advancedSearch = (data, keyword) => {
return (
data.name.toLowerCase().includes(keyword.toLowerCase()) ||
data.tags?.some((tag) => tag.includes(keyword)) ||
data.content?.includes(keyword)
);
};
// νμ₯μ κ²μ
const extensionSearch = (data, keyword) => {
const extension = data.name.split('.').pop();
return extension?.toLowerCase().includes(keyword.toLowerCase());
};
```
```tsx
// ν° λ°μ΄ν°μ
μ μν λ©λͺ¨μ΄μ μ΄μ
const MemoizedTree = React.memo(() => (
<Tree
items={largeDataSet}
renderLabel={React.useCallback(
(data) => (
<span>{data.name}</span>
),
[],
)}
/>
));
```
```tsx
<Tree
className="my-custom-tree"
items={data}
renderLabel={(data) => <span className={`item-${data.type}`}>{data.name}</span>}
/>
```
```css
.my-custom-tree {
border: 1px solid
border-radius: 8px;
padding: 16px;
}
.item-folder {
font-weight: bold;
color:
}
.item-file {
color:
}
```
```bash
git clone https://github.com/happyhyep/tree-component.git
cd tree-component
pnpm install
pnpm run storybook
pnpm run build
pnpm run lint
```
μ»΄ν¬λνΈ λ¬Έμμ μμ λ Storybookμμ νμΈν μ μμ΅λλ€:
```bash
pnpm run storybook
```
1. μ΄ μ μ₯μλ₯Ό ν¬ν¬νμΈμ
2. κΈ°λ₯ λΈλμΉλ₯Ό λ§λμΈμ (`git checkout -b feature/amazing-feature`)
3. λ³κ²½μ¬νμ 컀λ°νμΈμ (`git commit -m 'Add amazing feature'`)
4. λΈλμΉμ νΈμνμΈμ (`git push origin feature/amazing-feature`)
5. Pull Requestλ₯Ό μ΄μ΄μ£ΌμΈμ
μ΄ νλ‘μ νΈλ [MIT λΌμ΄μΌμ€](LICENSE) νμ λ°°ν¬λ©λλ€.
- π λ²κ·Έ 리ν¬νΈ: [GitHub Issues](https://github.com/happyhyep/tree-component/issues)
- π‘ κΈ°λ₯ μμ²: [GitHub Discussions](https://github.com/happyhyep/tree-component/discussions)
- π§ μ΄λ©μΌ: happyhyep@example.com