FileTree Component with interactions using TailwindCSS
6 min read
What we'll build
File Tree
- data.json
- package.json
- .eslintrc.json
- tailwind.config.js
We pass a directories object which is an array of Directory
which has the ff. shape:
export type Directory = {
path: string;
name: string;
subfolders?: Directory[];
};
const directories: Directory[] = [
{
path: '/components',
name: 'components',
subfolders: [
{
path: '/components/features',
name: 'features',
},
],
},
];
Creating a <FileList />
Component
import { Directory } from './directory';
import { cn } from '@lib/utils';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
<ul className={cn('pl-6 select-none overflow-hidden text-sm')}>
{fileList.map((file) => (
<li>....</li>
))}
</ul>
);
}
The <FileList />
receives a prop called fileList
which is an array of type Directory
.
Creating a <FileNode />
Component
Next, we create the <FileNode />
which is responsible for rendering each directory.
import { Directory } from './directory';
import { FileIcon, FolderIcon } from 'lucide-react';
export function FileNode({ directory }: { directory: Directory }) {
const isDirectory = directory.subfolders ? true : false;
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
{isDirectory ? (
<FolderIcon className="size-4 text-amber-500" />
) : (
<FileIcon className="size-4 text-sky-300" />
)}
<span>{directory.name}</span>
</div>
</li>
);
}
- It receives a
directory
prop which is of typeDirectory
; - It renders a Folder icon besides the name if the directory has subfolder(s), a File icon is rendered otherwise.
So then, we can replace the li
in the FileList
component with the FileNode
component:
import { FileNode } from './file-node';
import { Directory } from './directory';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
<ul className={cn('pl-6 select-none overflow-hidden text-sm')}>
{fileList.map((directory) => (
<FileNode key={directory.path} directory={directory} />
))}
</ul>
);
}
Add Toggle behavior if FileNode has Subfolder(s)
We aim to bring in a toggling functionality but we will implement it without JavaScript. We will leverage the power of TailwindCSS and HTML label and input:checkbox elements. So, if the directory has subfolders, we render a label within which is a checkbox.
import { FileIcon, FolderIcon } from 'lucide-react';
import { Directory } from './directory';
import { FileList } from './file-list';
export function FileNode({ directory }: { directory: Directory }) {
if (directory.subfolders) {
const nodeId = [directory.path, directory.name].join('-');
return (
<li>
<label
htmlFor={nodeId}
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-800 cursor-pointer"
>
<FolderIcon className="size-4 text-amber-500" />
<span>{directory.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>
</li>
);
}
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
<FileIcon className="size-4 text-sky-300" />
<span>{directory.name}</span>
</div>
</li>
);
}
Note:
- the
nodeId
in line 7 is defined to make sure the id of the checkbox is unique since we can have directories with the same file name. - the
htmlFor
of the label element should point to the value of theid
of the checkbox element. - the checkbox has a
hidden
attribute so it won't be visible.
Let us leave this for now. We will get back to this toggle behavior later.
Render nested directories and/or files (Recursion!)
Our implementation so far only renders the first-level directories and files relative to the root path we provided ('components', in this case). To be able to render the nested directories and files, we render a FileList
next to the label element.
import { FileIcon, FolderIcon } from 'lucide-react';
import { Directory } from './directory';
import { FileList } from './file-list';
export function FileNode({ directory }: { directory: Directory }) {
if (directory.subfolders) {
const nodeId = [directory.path, directory.name].join('-');
return (
<li>
<label
htmlFor={nodeId}
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-800 cursor-pointer"
>
<FolderIcon className="size-4 text-amber-500" />
<span>{directory.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>
<FileList fileList={directory.subfolders} />
</li>
);
}
return (
<li>
<div className="flex items-center gap-3 px-3 py-2 rounded">
<FileIcon className="size-4 text-sky-300" />
<span>{directory.name}</span>
</div>
</li>
);
}
<FileList />
will now render the nested directories and files in a recursive manner.
Enter TailwindCSS peer
and has
Now, going back to the toggle behavior. The first step is to add TailwindCSS' peer
class to the label element:
<label className="peer"></label>
By this change we can style its FileList
sibling (which has a ul
as the root element) based on the state of the checkbox within the label.
import { cn } from '@lib/utils';
import { Directory } from './directory';
import { FileNode } from './file-node';
export function FileList({ fileList }: { fileList: Directory[] }) {
return (
// other classes are omitted for brevity
<ul className={cn('hidden peer-has-[:checked]:block')}>
{fileList.map((directory) => (
<FileNode key={directory.path} directory={directory} />
))}
</ul>
);
}
By default the nested <FileList />
is hidden
but if its peer, which means its label sibling, has a checkbox in it that is checked, then we display it with the class block
.
Enter TailwindCSS group
Next, let us render an open folder icon if the directory is toggled "open".
<label htmlFor={nodeId} className="peer group">
<FolderIcon className="size-4 text-amber-500 group-has-[:checked]:hidden" />
<FolderOpenIcon className="size-4 text-amber-500 hidden group-has-[:checked]:inline-block" />
<span>{file.name}</span>
<input
defaultChecked={false}
type="checkbox"
name={nodeId}
id={nodeId}
hidden
/>
</label>
- We add the
group
class to the label element; - We render a
FolderIcon
but when its parent which is the label has a checked descendant, thehidden
class will be applied to it to hide it; - We render a
FolderOpenIcon
which is hidden by default but when its parent which is the label has a checked descendant, theinline-block
class will be applied to it to display it;
Final Touch
For aesthetic purposes, let us that line that's visible when a directory is expanded.
<li className="relative before:absolute before:top-9 before:bottom-3 before:left-5 before:w-px before:bg-gray-800">
<label>.....</label>
<FileList fileList={fileList} />
</li>
This is just using the li
element's pseudo ::before
element.
Result
File Tree
- data.json
- package.json
- .eslintrc.json
- tailwind.config.js
Conclusion
Interactive UIs can be achieved even without JavaScript by leveraging the web platform's features which are made available to us by TailwindCSS and HTML itself.