diff --git a/apps/www/content/docs/ui/file-tree.mdx b/apps/www/content/docs/ui/file-tree.mdx new file mode 100644 index 00000000..86a495f0 --- /dev/null +++ b/apps/www/content/docs/ui/file-tree.mdx @@ -0,0 +1,123 @@ +--- +title: File Tree +description: Renders a hierarchical structure of files and folders. +links: + doc: https://www.radix-ui.com/docs/primitives/components/collapsible + api: https://www.radix-ui.com/docs/primitives/components/collapsible#api-reference +dependencies: ['@radix-ui/react-collapsible'] +--- + + +```json doc-gen:file +{ + "file": "./src/components/demos/FileTree/FileTree.tsx", + "codeblock": true +} +``` + + +## Installation + + + + ### Install the primitive + + Install the `@radix-ui/react-collapsible` package. + + ```package-install + @radix-ui/react-collapsible + ``` + + + + + ### Copy-paste the component + + Copy and paste the component code in a `.tsx` file. + + ```json doc-gen:file + { + "file": "../../packages/ui/src/file-tree.tsx", + "codeblock": true + } + ``` + + + + + ### Update import paths + + Update the `@kosori/ui` import paths to fit your project structure, for example, using `~/components/ui`. + + + + + ### Add keyframes animation + + Add the `collapsible-down` and `collapsible-up` keyframes animation to your Tailwind CSS config. + + ```ts title="tailwind.config.ts" + import type { Config } from 'tailwindcss'; + + const config: Config = { + // ..., + keyframes: { + // [!code highlight:9] + 'collapsible-down': { + from: { height: '0' }, + to: { height: 'var(--radix-collapsible-content-height)' }, + }, + 'collapsible-up': { + from: { height: 'var(--radix-collapsible-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + // [!code highlight:3] + 'collapsible-down': 'collapsible-down 0.15s ease-out', + 'collapsible-up': 'collapsible-up 0.15s ease-out', + } + }; + ``` + + + + + +## Usage + +```ts +import { + File, + FileTree, + Folder, + FolderFiles, + FolderName, +} from '~/components/ui/file-tree'; +``` + +```tsx + + + app + + layout.tsx + page.tsx + + + +``` + +## Reference + +### File + +`, + description: 'Optional icon to display next to the file name.', + type: 'ReactNode', + }, + }} +/> diff --git a/apps/www/src/components/ComponentPreview/Components.tsx b/apps/www/src/components/ComponentPreview/Components.tsx index 3027a36d..0fbc4a83 100644 --- a/apps/www/src/components/ComponentPreview/Components.tsx +++ b/apps/www/src/components/ComponentPreview/Components.tsx @@ -547,6 +547,15 @@ export const Components: Record = { })), ), }, + 'file-tree': { + name: 'file-tree', + type: 'component:example', + component: lazy(() => + import('../demos/FileTree').then((module) => ({ + default: module.FileTreeDemo, + })), + ), + }, 'dropdown-menu-checkboxes': { name: 'dropdown-menu-checkboxes', type: 'component:example', diff --git a/apps/www/src/components/demos/FileTree/FileTree.tsx b/apps/www/src/components/demos/FileTree/FileTree.tsx new file mode 100644 index 00000000..9332c821 --- /dev/null +++ b/apps/www/src/components/demos/FileTree/FileTree.tsx @@ -0,0 +1,43 @@ +import { + File, + FileTree, + Folder, + FolderFiles, + FolderName, +} from '@kosori/ui/file-tree'; + +export const FileTreeDemo = () => { + return ( + + + app + + + + components + + + button.tsx + card.tsx + + + + layout.tsx + global.css + page.tsx + + + + + public + + + favicon.ico + index.html + + + + package.json + + ); +}; diff --git a/apps/www/src/components/demos/FileTree/index.ts b/apps/www/src/components/demos/FileTree/index.ts new file mode 100644 index 00000000..8fc129e1 --- /dev/null +++ b/apps/www/src/components/demos/FileTree/index.ts @@ -0,0 +1 @@ +export * from './FileTree'; diff --git a/packages/ui/src/accordion.tsx b/packages/ui/src/accordion.tsx index f110d213..c7a7e55f 100644 --- a/packages/ui/src/accordion.tsx +++ b/packages/ui/src/accordion.tsx @@ -57,7 +57,6 @@ const { item, trigger, triggerIcon, content } = accordionStyles(); * * @see {@link https://dub.sh/ui-accordion Accordion Docs} for further information. */ - export const Accordion = Root; type AccordionItemRef = React.ElementRef; diff --git a/packages/ui/src/file-tree.tsx b/packages/ui/src/file-tree.tsx new file mode 100644 index 00000000..5098ee50 --- /dev/null +++ b/packages/ui/src/file-tree.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { forwardRef } from 'react'; +import { clsx } from 'clsx/lite'; +import { FileIcon, FolderIcon, FolderOpenIcon } from 'lucide-react'; +import { tv } from 'tailwind-variants'; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@kosori/ui/collapsible'; + +const fileTreeStyles = tv({ + slots: { + fileTree: 'rounded-lg border bg-grey-bg-subtle p-2', + folder: 'w-full', + folderName: clsx( + 'group w-full inline-flex items-center gap-2 rounded-lg px-2 h-8', + 'hover:bg-grey-bg-hover', + ), + folderFiles: clsx( + 'overflow-hidden', + 'data-[state=closed]:animate-collapsible-up', + 'data-[state=open]:animate-collapsible-down', + ), + file: clsx( + 'w-full inline-flex h-8 select-none items-center gap-2 rounded-lg px-2 text-sm', + 'hover:bg-grey-bg-hover', + ), + }, +}); + +const { fileTree, folder, folderName, folderFiles, file } = fileTreeStyles(); + +type FileTreeRef = HTMLDivElement; +type FileTreeProps = React.ComponentPropsWithoutRef<'div'>; + +/** + * FileTree component that represents a hierarchical structure of files and folders. + * + * @param {FileTreeProps} props - Additional props to pass to the file tree container. + * + * @example + * + * + * Documents + * + * Resume.pdf + * CoverLetter.docx + * + * + * + * Images + * + * Vacation.jpg + * Profile.png + * + * + * + */ +export const FileTree = forwardRef( + ({ className, ...props }, ref) => ( +
+ ), +); + +FileTree.displayName = 'FileTree'; + +type FolderRef = React.ElementRef; +type FolderProps = React.ComponentPropsWithoutRef; + +/** + * Folder component that represents a collapsible folder in the file tree. + * + * @param {FolderProps} props - Additional props to pass to the folder component. + * + * @example + * + * My Folder + * + * File1.txt + * File2.txt + * + * + */ +export const Folder = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +Folder.displayName = 'Folder'; + +type FolderNameRef = React.ElementRef; +type FolderNameProps = React.ComponentPropsWithoutRef< + typeof CollapsibleTrigger +>; + +/** + * FolderName component that acts as the clickable header for a Folder. + * + * @param {FolderNameProps} props - Additional props to pass to the folder name. + * + * @example + * My Folder + */ +export const FolderName = forwardRef( + ({ className, children, ...props }, ref) => ( + + + + {children} + + ), +); + +FolderName.displayName = 'FolderName'; + +type FolderFilesRef = React.ElementRef; +type FolderFilesProps = React.ComponentPropsWithoutRef< + typeof CollapsibleContent +>; + +/** + * FolderFiles component that displays the contents of a Folder. + * + * @param {FolderFilesProps} props - Additional props to pass to the folder files container. + * + * @example + * + * File1.txt + * File2.txt + * + */ +export const FolderFiles = forwardRef( + ({ className, children, ...props }, ref) => ( + +
{children}
+
+ ), +); + +FolderFiles.displayName = 'FolderFiles'; + +type FileRef = HTMLDivElement; +type FileProps = React.ComponentPropsWithoutRef<'div'> & { + /** + * Optional icon to display next to the file name. + */ + icon?: React.ReactNode; +}; + +/** + * File component that represents a single file in the file tree. + * + * @param {FileProps} props - Additional props to pass to the file component. + * @param {React.ReactNode} [icon=] - Optional icon to display next to the file name. + * + * @example + * MyFile.txt + */ +export const File = forwardRef( + ( + { className, icon = , children, ...props }, + ref, + ) => ( +
+ {icon} + {children} +
+ ), +); + +File.displayName = 'File'; diff --git a/tooling/tailwind/web.ts b/tooling/tailwind/web.ts index 4e4dfb05..b1564add 100644 --- a/tooling/tailwind/web.ts +++ b/tooling/tailwind/web.ts @@ -39,6 +39,18 @@ export default { '0%,70%,100%': { opacity: '1' }, '20%,50%': { opacity: '0' }, }, + 'collapsible-down': { + from: { height: '0' }, + to: { + height: 'var(--radix-collapsible-content-height)', + }, + }, + 'collapsible-up': { + from: { + height: 'var(--radix-collapsible-content-height)', + }, + to: { height: '0' }, + }, flash: { '0%': { opacity: '0.2' }, '20%': { opacity: '1' }, @@ -85,6 +97,8 @@ export default { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', 'caret-blink': 'caret-blink 1.25s ease-out infinite', + 'collapsible-down': 'collapsible-down 0.15s ease-out', + 'collapsible-up': 'collapsible-up 0.15s ease-out', flash: 'flash 1.4s infinite linear', jump: 'jump 1s ease-in-out infinite', pulse2: 'pulse2 1.3s ease-in-out infinite',