๐ฐ๐ท ํ๊ตญ์ด | ๐บ๐ธ English
React Tree Component Library where both folders and files are clickable
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 | โ Full type safety |
# npm
npm install @happyhyep/tree-component
# yarn
yarn add @happyhyep/tree-component
# pnpm
pnpm add @happyhyep/tree-componentimport 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>
)}
/>
);
}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>
);
};function ExpandedTree() {
return (
<Tree
items={data}
defaultExpandAll={true} // ๐ Expand all folders by default
renderLabel={(data) => <span>{data.name}</span>}
/>
);
}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>
);
}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>
)}
/>
);
}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 |
All Tree props + additional:
| Props | Type | Required | Description |
|---|---|---|---|
searchFn |
(data: T, keyword: string) => boolean |
โ | Search function |
children |
ReactNode |
โ | Search input, etc. |
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);
}
};// 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());
};// Memoization for large datasets
const MemoizedTree = React.memo(() => (
<Tree
items={largeDataSet}
renderLabel={React.useCallback(
(data) => (
<span>{data.name}</span>
),
[],
)}
/>
));<Tree
className="my-custom-tree"
items={data}
renderLabel={(data) => <span className={`item-${data.type}`}>{data.name}</span>}
/>.my-custom-tree {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.item-folder {
font-weight: bold;
color: #4a90e2;
}
.item-file {
color: #666;
}# Clone repository
git clone https://github.com/happyhyep/tree-component.git
cd tree-component
# Install dependencies
pnpm install
# Run Storybook
pnpm run storybook
# Build
pnpm run build
# Lint
pnpm run lintComponent documentation and examples are available in Storybook:
pnpm run storybook- Fork this repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License.
- ๐ Bug Reports: GitHub Issues
- ๐ก Feature Requests: GitHub Discussions
- ๐ง Email: jhi2359@naver.com
ํด๋์ ํ์ผ ๋ชจ๋ ํด๋ฆญ ๊ฐ๋ฅํ React Tree ์ปดํฌ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ ๋๋ค.
๋ค๋ฅธ Tree ์ปดํฌ๋ํธ์ ๋ฌ๋ฆฌ ํด๋์ ํ์ผ ๋ชจ๋ ํด๋ฆญํ์ฌ ์ํธ์์ฉํ ์ ์๋ Tree ์ปดํฌ๋ํธ์ ๋๋ค.
| ๊ธฐ๋ฅ | ์ผ๋ฐ์ ์ธ Tree | ๐ณ Tree Component |
|---|---|---|
| ํด๋ ํด๋ฆญ | โ ํผ์น๊ธฐ/์ ๊ธฐ๋ง | โ ํด๋ฆญ ์ด๋ฒคํธ + ํผ์น๊ธฐ/์ ๊ธฐ |
| ํ์ผ ํด๋ฆญ | โ ํด๋ฆญ ๊ฐ๋ฅ | โ ํด๋ฆญ ๊ฐ๋ฅ |
| ๊ฒ์ ๊ธฐ๋ฅ | โ ๋ณ๋ ๊ตฌํ ํ์ | โ ๋ด์ฅ ๊ฒ์ + ํ์ด๋ผ์ดํธ |
| ๊ธฐ๋ณธ ํ์ฅ ์ํ | โ ์๋ ์ค์ | โ ํ ๋ฒ์ ๋ชจ๋ ํด๋ ํ์ฅ |
| TypeScript | โ ์์ ํ ํ์ ์์ ์ฑ |
# npm
npm install @happyhyep/tree-component
# yarn
yarn add @happyhyep/tree-component
# pnpm
pnpm add @happyhyep/tree-componentimport 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>
)}
/>
);
}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>
);
};function ExpandedTree() {
return (
<Tree
items={data}
defaultExpandAll={true} // ๐ ๋ชจ๋ ํด๋ ๊ธฐ๋ณธ ํ์ฅ
renderLabel={(data) => <span>{data.name}</span>}
/>
);
}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>
);
}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>
)}
/>
);
}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 |
โ | ๊ฒ์ ์ ๋ ฅ์ฐฝ ๋ฑ |
const handleClick = (item) => {
if (item.data.type === 'folder') {
// ํด๋ ํด๋ฆญ - ํน๋ณํ ๋ก์ง
if (item.data.permissions?.canAccess) {
navigateToFolder(item);
} else {
showPermissionError();
}
} else {
// ํ์ผ ํด๋ฆญ - ํ์ผ ์ด๊ธฐ
openFile(item);
}
};// ๋ค์ค ์กฐ๊ฑด ๊ฒ์
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());
};// ํฐ ๋ฐ์ดํฐ์
์ ์ํ ๋ฉ๋ชจ์ด์ ์ด์
const MemoizedTree = React.memo(() => (
<Tree
items={largeDataSet}
renderLabel={React.useCallback(
(data) => (
<span>{data.name}</span>
),
[],
)}
/>
));<Tree
className="my-custom-tree"
items={data}
renderLabel={(data) => <span className={`item-${data.type}`}>{data.name}</span>}
/>.my-custom-tree {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.item-folder {
font-weight: bold;
color: #4a90e2;
}
.item-file {
color: #666;
}# ์ ์ฅ์ ํด๋ก
git clone https://github.com/happyhyep/tree-component.git
cd tree-component
# ์์กด์ฑ ์ค์น
pnpm install
# Storybook ์คํ
pnpm run storybook
# ๋น๋
pnpm run build
# ๋ฆฐํธ
pnpm run lint์ปดํฌ๋ํธ ๋ฌธ์์ ์์ ๋ Storybook์์ ํ์ธํ ์ ์์ต๋๋ค:
pnpm run storybook- ์ด ์ ์ฅ์๋ฅผ ํฌํฌํ์ธ์
- ๊ธฐ๋ฅ ๋ธ๋์น๋ฅผ ๋ง๋์ธ์ (
git checkout -b feature/amazing-feature) - ๋ณ๊ฒฝ์ฌํญ์ ์ปค๋ฐํ์ธ์ (
git commit -m 'Add amazing feature') - ๋ธ๋์น์ ํธ์ํ์ธ์ (
git push origin feature/amazing-feature) - Pull Request๋ฅผ ์ด์ด์ฃผ์ธ์
์ด ํ๋ก์ ํธ๋ MIT ๋ผ์ด์ผ์ค ํ์ ๋ฐฐํฌ๋ฉ๋๋ค.
- ๐ ๋ฒ๊ทธ ๋ฆฌํฌํธ: GitHub Issues
- ๐ก ๊ธฐ๋ฅ ์์ฒญ: GitHub Discussions
- ๐ง ์ด๋ฉ์ผ: jhi2359@naver.com
