diff --git a/components/file-tree/index.ts b/components/file-tree/index.ts new file mode 100644 index 0000000..d0b042a --- /dev/null +++ b/components/file-tree/index.ts @@ -0,0 +1,8 @@ +import Tree from './tree' +import TreeFile from './tree-file' +import TreeFolder from './tree-folder' + +Tree.File = TreeFile +Tree.Folder = TreeFolder + +export default Tree diff --git a/components/file-tree/tree-context.ts b/components/file-tree/tree-context.ts new file mode 100644 index 0000000..1096bde --- /dev/null +++ b/components/file-tree/tree-context.ts @@ -0,0 +1,17 @@ +import React from 'react' + +export interface TreeConfig { + onFileClick: (path: string) => void + initialExpand: boolean + isImperative: boolean +} + +const defaultContext = { + onFileClick: () => {}, + initialExpand: false, + isImperative: false, +} + +export const TreeContext = React.createContext(defaultContext) + +export const useTreeContext = (): TreeConfig => React.useContext(TreeContext) diff --git a/components/file-tree/tree-file-icon.tsx b/components/file-tree/tree-file-icon.tsx new file mode 100644 index 0000000..768937c --- /dev/null +++ b/components/file-tree/tree-file-icon.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import useTheme from '../styles/use-theme' +import withDefaults from '../utils/with-defaults' + +interface Props { + color?: string + width?: number + height?: number +} + +const defaultProps = { + width: 22, + height: 22, +} + +export type TreeFileIconProps = Props & typeof defaultProps + +const TreeFileIcon: React.FC = ({ + color, width, height +}) => { + const theme = useTheme() + return ( + + + + + + ) +} + +export default withDefaults(TreeFileIcon, defaultProps) diff --git a/components/file-tree/tree-file.tsx b/components/file-tree/tree-file.tsx new file mode 100644 index 0000000..08dabea --- /dev/null +++ b/components/file-tree/tree-file.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react' +import withDefaults from '../utils/with-defaults' +import useTheme from '../styles/use-theme' +import TreeFileIcon from './tree-file-icon' +import { useTreeContext } from './tree-context' +import TreeIndents from './tree-indents' +import { makeChildPath, stopPropagation } from './tree-help' + +interface Props { + name: string + extra?: string + parentPath?: string + level?: number + className?: string +} + +const defaultProps = { + level: 0, + className: '', + parentPath: '', +} + +export type TreeFileProps = Props & typeof defaultProps & React.HTMLAttributes + +const TreeFile: React.FC> = ({ + name, parentPath, level, extra, className, ...props +}) => { + const theme = useTheme() + const { onFileClick } = useTreeContext() + const currentPath = useMemo(() => makeChildPath(name, parentPath), []) + const clickHandler = (event: React.MouseEvent) => { + stopPropagation(event) + onFileClick(currentPath) + } + + return ( +
+
+ + + {name}{extra && {extra}} +
+ +
+ ) +} + +export default withDefaults(TreeFile, defaultProps) diff --git a/components/file-tree/tree-folder-icon.tsx b/components/file-tree/tree-folder-icon.tsx new file mode 100644 index 0000000..72e03b0 --- /dev/null +++ b/components/file-tree/tree-folder-icon.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import useTheme from '../styles/use-theme' +import withDefaults from '../utils/with-defaults' + +interface Props { + color?: string + width?: number + height?: number +} + +const defaultProps = { + width: 22, + height: 22, +} + +export type TreeFolderIconProps = Props & typeof defaultProps + +const TreeFolderIcon: React.FC = ({ + color, width, height, +}) => { + const theme = useTheme() + return ( + + + + + ) +} + +export default withDefaults(TreeFolderIcon, defaultProps) diff --git a/components/file-tree/tree-folder.tsx b/components/file-tree/tree-folder.tsx new file mode 100644 index 0000000..2bdb9cd --- /dev/null +++ b/components/file-tree/tree-folder.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useMemo, useState } from 'react' +import useTheme from '../styles/use-theme' +import withDefaults from '../utils/with-defaults' +import { setChildrenProps } from '../utils/collections' +import TreeFile from './tree-file' +import Expand from '../shared/expand' +import TreeIndents from './tree-indents' +import { useTreeContext } from './tree-context' +import TreeFolderIcon from './tree-folder-icon' +import TreeStatusIcon from './tree-status-icon' +import { sortChildren, makeChildPath, stopPropagation } from './tree-help' + +interface Props { + name: string + extra?: string + parentPath?: string + level?: number + className?: string +} + +const defaultProps = { + level: 0, + className: '', + parentPath: '', +} + +export type TreeFolderProps = Props & typeof defaultProps & React.HTMLAttributes + +const TreeFolder: React.FC> = ({ + name, children, parentPath, level: parentLevel, extra, className, ...props +}) => { + const theme = useTheme() + const { initialExpand, isImperative } = useTreeContext() + const [expanded, setExpanded] = useState(initialExpand) + useEffect(() => setExpanded(initialExpand), []) + + const currentPath = useMemo(() => makeChildPath(name, parentPath), []) + const clickHandler = () => setExpanded(!expanded) + + const nextChildren = setChildrenProps( + children, + { + parentPath: currentPath, + level: parentLevel + 1, + }, + [TreeFolder, TreeFile], + ) + + const sortedChildren = isImperative ? nextChildren : sortChildren(nextChildren, TreeFolder) + + return ( +
+
+ + + + {name}{extra && {extra}} +
+ +
{sortedChildren}
+
+ + +
+ ) +} + +export default withDefaults(TreeFolder, defaultProps) diff --git a/components/file-tree/tree-help.ts b/components/file-tree/tree-help.ts new file mode 100644 index 0000000..10fa9f4 --- /dev/null +++ b/components/file-tree/tree-help.ts @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react' + +export const sortChildren = ( + children: ReactNode | undefined, + folderComponentType: React.ElementType +) => { + return React.Children.toArray(children) + .sort((a, b) => { + if (!React.isValidElement(a) || !React.isValidElement(b)) return 0 + if (a.type !== b.type) return a.type !== folderComponentType ? 1 : -1 + return `${a.props.name}`.charCodeAt(0) - `${b.props.name}`.charCodeAt(0) + }) +} + +export const makeChildPath = (name: string, parentPath?: string) => { + if (!parentPath) return name + return `${parentPath}/${name}` +} + +export const stopPropagation = (event: React.MouseEvent) => { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() +} + diff --git a/components/file-tree/tree-indents.tsx b/components/file-tree/tree-indents.tsx new file mode 100644 index 0000000..e61be02 --- /dev/null +++ b/components/file-tree/tree-indents.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +interface Props { + count: number +} + +const TreeIndents: React.FC = ({ count }) => { + if (count === 0) return null + return ( + <> + {[...new Array(count)].map((_, index) => ( + + + + ))} + + ) +} + +export default TreeIndents diff --git a/components/file-tree/tree-status-icon.tsx b/components/file-tree/tree-status-icon.tsx new file mode 100644 index 0000000..6e4206d --- /dev/null +++ b/components/file-tree/tree-status-icon.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import useTheme from '../styles/use-theme' +import withDefaults from '../utils/with-defaults' + +interface Props { + color?: string + width?: number + height?: number + active?: boolean +} + +const defaultProps = { + width: 12, + height: 12, + active: false, +} + +export type TreeStatusIconProps = Props & typeof defaultProps + +const TreeStatusIcon: React.FC = ({ + color, width, height, active, +}) => { + const theme = useTheme() + return ( + + + {!active && } + + + + + ) +} + +export default withDefaults(TreeStatusIcon, defaultProps) diff --git a/components/file-tree/tree.tsx b/components/file-tree/tree.tsx new file mode 100644 index 0000000..1b8f870 --- /dev/null +++ b/components/file-tree/tree.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react' +import TreeFile from './tree-file' +import TreeFolder from './tree-folder' +import { TreeContext } from './tree-context' +import { tuple } from '../utils/prop-types' +import { sortChildren } from 'components/file-tree/tree-help' + +const FileTreeValueType = tuple( + 'directory', + 'file', +) + +const directoryType = FileTreeValueType[0] + +export type FileTreeValue = { + type: typeof FileTreeValueType[number] + name: string + extra?: string + files?: Array +} + +interface Props { + value?: Array + initialExpand?: boolean + onClick?: (path: string) => void + className?: string +} + +const defaultProps = { + initialExpand: false, + className: '', +} + +export type TreeProps = Props & typeof defaultProps & React.HTMLAttributes + +const makeChildren = (value: Array = []) => { + if (!value || !value.length) return null + return value + .sort((a, b) => { + if (a.type !== b.type) return a.type !== directoryType ? 1 : -1 + + return `${a.name}`.charCodeAt(0) - `${b.name}`.charCodeAt(0) + }) + .map((item, index) => { + if (item.type === directoryType) return ( + + {makeChildren(item.files)} + + ) + return + }) +} + +const Tree: React.FC> = ({ + children, onClick, initialExpand, value, className, ...props +}) => { + const isImperative = Boolean(value && value.length > 0) + const onFileClick = (path: string) => { + onClick && onClick(path) + } + + const initialValue = useMemo(() => ({ + onFileClick, + initialExpand, + isImperative, + }), [initialExpand]) + + const customChildren = isImperative ? makeChildren(value) : sortChildren(children, TreeFolder) + + return ( + +
+ {customChildren} + +
+
+ ) +} + +type TreeComponent

= React.FC

& { + File: typeof TreeFile + Folder: typeof TreeFolder +} + +type ComponentProps = Partial & Omit + +Tree.defaultProps = defaultProps + +export default Tree as TreeComponent diff --git a/components/index.ts b/components/index.ts index 608913f..e96894e 100644 --- a/components/index.ts +++ b/components/index.ts @@ -35,3 +35,4 @@ export { default as Radio } from './radio' export { default as Select } from './select' export { default as Tabs } from './tabs' export { default as Progress } from './progress' +export { default as Tree } from './file-tree' diff --git a/components/utils/collections.ts b/components/utils/collections.ts index 01a29dc..18979b9 100644 --- a/components/utils/collections.ts +++ b/components/utils/collections.ts @@ -61,3 +61,48 @@ export const pickChildrenFirst = ( ): ReactNode | undefined => { return React.Children.toArray(children)[0] } + +export const setChildrenProps = ( + children: ReactNode | undefined, + props: object = {}, + targetComponents: Array = [] +): ReactNode | undefined => { + if (React.Children.count(children) === 0) return [] + const allowAll = targetComponents.length === 0 + const clone = (child: React.ReactElement, props = {}) => React.cloneElement(child, props) + + return React.Children.map(children, item => { + if (!React.isValidElement(item)) return item + if (allowAll) return clone(item, props) + + const isAllowed = targetComponents.find(child => child === item.type) + if (isAllowed) return clone(item, props) + return item + }) +} + +export type ShapeType = { + width: number, + height: number, +} + +export const getRealShape = (el: HTMLElement | null): ShapeType => { + const defaultShape: ShapeType = { width: 0, height: 0 } + if (!el || typeof window === 'undefined') return defaultShape + + const rect = el.getBoundingClientRect() + const { width, height } = window.getComputedStyle(el) + + const getCSSStyleVal = (str: string, parentNum: number) => { + if (!str) return 0 + const strVal = str.includes('px') ? +str.split('px')[0] + : str.includes('%') ? +str.split('%')[0] * parentNum * 0.01 : str + + return Number.isNaN(+strVal) ? 0 : +strVal + } + + return { + width: getCSSStyleVal(`${width}`, rect.width), + height: getCSSStyleVal(`${height}`, rect.height), + } +} diff --git a/pages/docs/components/file-tree.mdx b/pages/docs/components/file-tree.mdx new file mode 100644 index 0000000..3151f06 --- /dev/null +++ b/pages/docs/components/file-tree.mdx @@ -0,0 +1,176 @@ +import { Layout, Playground, Attributes } from 'lib/components' +import { Tree, useToasts } from 'components' + +export const meta = { + title: 'File-Tree', + description: 'File-Tree', +} + +## File Tree + +Display a list of files and folders in a hierarchical tree structure. + + + + + + + + + + + + + + +`} /> + + + { + const files = [{ + type: 'directory', + name: 'bin', + files: [{ + type: 'file', + name: 'cs.js', + }], + }, { + type: 'directory', + name: 'docs', + files: [{ + type: 'file', + name: 'controllers.md', + }, { + type: 'file', + name: 'es6.md', + }, { + type: 'file', + name: 'production.md', + }, { + type: 'file', + name: 'views.md', + }], + }] + return +} +`} /> + + + { + const files = [{ + type: 'directory', + name: 'controllers', + extra: '1 file', + files: [{ + type: 'file', + name: 'cs.js', + extra: '1kb', + }], + }, { + type: 'directory', + name: 'docs', + extra: '2 files', + files: [{ + type: 'file', + name: 'controllers.md', + extra: '2.5kb', + }, { + type: 'file', + name: 'es6.md', + extra: '2.9kb', + }], + }, { + type: 'file', + name: 'production.md', + extra: '0.8kb', + }, { + type: 'file', + name: 'views.md', + extra: '8.1kb', + }] + return +} +`} /> + + { + const [_, setToast] = useToasts() + const handler = path => setToast({ text: path }) + return ( + + + + + + + + + + + + + + ) +} +`} /> + + + +Tree.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **value** | value of files | `Array` | - | - | +| **initialExpand** | expand by default | `boolean` | - | `false` | +| **onClick** | click file event | `(path: string) => void` | - | - | +| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - | + +Tree.File.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **name**(required) | file name | `string` | - | - | +| **extra** | extra message | `string` | - | - | +| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - | + +Tree.Folder.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **name**(required) | folder name | `string` | - | - | +| **extra** | extra message | `string` | - | - | +| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - | + +type FileTreeValue + +```ts +type FileTreeValue = { + type: 'directory' || 'file' + name: string + extra?: string + files?: Array +} +``` + + + +export default ({ children }) => {children}