feat(file-tree): add component

This commit is contained in:
unix
2020-03-25 12:57:34 +08:00
parent eb41b01bd3
commit 42e47f3210
13 changed files with 737 additions and 0 deletions

View File

@@ -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

View File

@@ -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<TreeConfig>(defaultContext)
export const useTreeContext = (): TreeConfig => React.useContext<TreeConfig>(TreeContext)

View File

@@ -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<TreeFileIconProps> = ({
color, width, height
}) => {
const theme = useTheme()
return (
<svg viewBox="0 0 24 24" width={width} height={height} stroke="currentColor" strokeWidth="1" strokeLinecap="round"
strokeLinejoin="round" fill="none" shapeRendering="geometricPrecision">
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
<path d="M13 2v7h7" />
<style jsx>{`
svg {
color: ${color || theme.palette.accents_8};
}
`}</style>
</svg>
)
}
export default withDefaults(TreeFileIcon, defaultProps)

View File

@@ -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<any>
const TreeFile: React.FC<React.PropsWithChildren<TreeFileProps>> = ({
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 (
<div className={`file ${className}`} onClick={clickHandler} {...props}>
<div className="names">
<TreeIndents count={level} />
<span className="icon"><TreeFileIcon /></span>
<span className="name">{name}{extra && <span className="extra">{extra}</span>}</span>
</div>
<style jsx>{`
.file {
cursor: pointer;
line-height: 1;
user-select: none;
margin-left: calc(1.875rem * ${level});
}
.names {
display: flex;
height: 1.75rem;
align-items: center;
position: relative;
}
.names > :global(.indent) {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 100%;
background-color: ${theme.palette.accents_2};
margin-left: -1px;
}
.icon {
width: 1.5rem;
height: 100%;
display: inline-flex;
align-items: center;
margin-right: .5rem;
}
.name {
transition: opacity 100ms ease 0ms;
color: ${theme.palette.accents_8};
white-space: nowrap;
font-size: .875rem;
}
.extra {
font-size: .75rem;
align-self: baseline;
padding-left: 4px;
color: ${theme.palette.accents_5};
}
.name:hover {
opacity: .7;
}
`}</style>
</div>
)
}
export default withDefaults(TreeFile, defaultProps)

View File

@@ -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<TreeFolderIconProps> = ({
color, width, height,
}) => {
const theme = useTheme()
return (
<svg viewBox="0 0 24 24" width={width} height={height} stroke="currentColor" strokeWidth="1" strokeLinecap="round"
strokeLinejoin="round" fill="none" shapeRendering="geometricPrecision">
<path
d="M2.707 7.454V5.62C2.707 4.725 3.469 4 4.409 4h4.843c.451 0 .884.17 1.204.474l.49.467c.126.12.296.186.473.186h8.399c.94 0 1.55.695 1.55 1.59v.737m-18.661 0h-.354a.344.344 0 00-.353.35l.508 11.587c.015.34.31.609.668.609h17.283c.358 0 .652-.269.667-.61L22 7.805a.344.344 0 00-.353-.35h-.278m-18.662 0h18.662" />
<style jsx>{`
svg {
color: ${color || theme.palette.accents_8};
}
`}</style>
</svg>
)
}
export default withDefaults(TreeFolderIcon, defaultProps)

View File

@@ -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<any>
const TreeFolder: React.FC<React.PropsWithChildren<TreeFolderProps>> = ({
name, children, parentPath, level: parentLevel, extra, className, ...props
}) => {
const theme = useTheme()
const { initialExpand, isImperative } = useTreeContext()
const [expanded, setExpanded] = useState<boolean>(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 (
<div className={`folder ${className}`} onClick={clickHandler} {...props}>
<div className="names">
<TreeIndents count={parentLevel} />
<span className="status"><TreeStatusIcon active={expanded} /></span>
<span className="icon"><TreeFolderIcon /></span>
<span className="name">{name}{extra && <span className="extra">{extra}</span>}</span>
</div>
<Expand isExpanded={expanded}>
<div className="content" onClick={stopPropagation}>{sortedChildren}</div>
</Expand>
<style jsx>{`
.folder {
cursor: pointer;
line-height: 1;
user-select: none;
}
.names {
display: flex;
height: 1.75rem;
align-items: center;
margin-left: calc(1.875rem * ${parentLevel});
position: relative;
}
.names > :global(.indent) {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 100%;
background-color: ${theme.palette.accents_2};
margin-left: -1px;
}
.status {
position: absolute;
left: calc(-1.125rem);
top: 50%;
transform: translate(-50%, -50%);
width: .875rem;
height: .875rem;
z-index: 10;
background-color: ${theme.palette.background};
}
.icon {
width: 1.5rem;
height: 100%;
margin-right: .5rem;
}
.status, .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.name {
transition: opacity 100ms ease 0ms;
color: ${theme.palette.accents_8};
white-space: nowrap;
font-size: .875rem;
}
.extra {
font-size: .75rem;
align-self: baseline;
padding-left: 4px;
color: ${theme.palette.accents_5};
}
.name:hover {
opacity: .7;
}
.content {
display: flex;
flex-direction: column;
height: auto;
}
`}</style>
</div>
)
}
export default withDefaults(TreeFolder, defaultProps)

View File

@@ -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()
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
interface Props {
count: number
}
const TreeIndents: React.FC<Props> = ({ count }) => {
if (count === 0) return null
return (
<>
{[...new Array(count)].map((_, index) => (
<span className="indent" key={`indent-${index}`}>
<style jsx>{`
span.indent {
left: calc(-1.875rem * ${index + 1} + .75rem);
}
`}</style>
</span>
))}
</>
)
}
export default TreeIndents

View File

@@ -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<TreeStatusIconProps> = ({
color, width, height, active,
}) => {
const theme = useTheme()
return (
<svg viewBox="0 0 24 24" width={width} height={height} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
strokeLinejoin="round" fill="none" shapeRendering="geometricPrecision">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
{!active && <path d="M12 8v8" />}
<path d="M8 12h8" />
<style jsx>{`
svg {
color: ${color || theme.palette.accents_8};
}
`}</style>
</svg>
)
}
export default withDefaults(TreeStatusIcon, defaultProps)

View File

@@ -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<FileTreeValue>
}
interface Props {
value?: Array<FileTreeValue>
initialExpand?: boolean
onClick?: (path: string) => void
className?: string
}
const defaultProps = {
initialExpand: false,
className: '',
}
export type TreeProps = Props & typeof defaultProps & React.HTMLAttributes<any>
const makeChildren = (value: Array<FileTreeValue> = []) => {
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 (
<TreeFolder name={item.name} extra={item.extra} key={`folder-${item.name}-${index}`}>
{makeChildren(item.files)}
</TreeFolder>
)
return <TreeFile name={item.name} extra={item.extra} key={`file-${item.name}-${index}`} />
})
}
const Tree: React.FC<React.PropsWithChildren<TreeProps>> = ({
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 (
<TreeContext.Provider value={initialValue}>
<div className={`tree ${className}`} {...props}>
{customChildren}
<style jsx>{`
.tree {
padding-left: 1.625rem;
}
`}</style>
</div>
</TreeContext.Provider>
)
}
type TreeComponent<P = {}> = React.FC<P> & {
File: typeof TreeFile
Folder: typeof TreeFolder
}
type ComponentProps = Partial<typeof defaultProps> & Omit<Props, keyof typeof defaultProps>
Tree.defaultProps = defaultProps
export default Tree as TreeComponent<ComponentProps>

View File

@@ -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'

View File

@@ -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<React.ElementType> = []
): 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),
}
}

View File

@@ -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.
<Playground
title="Basic"
desc="All folders and files are sorted automatically."
scope={{ Tree }}
code={`
<Tree>
<Tree.File name="package.json" />
<Tree.Folder name="components">
<Tree.File name="layout.js" />
<Tree.Folder name="footer">
<Tree.File name="footer.js" />
<Tree.File name="footer-text.js" />
<Tree.File name="footer-license.js" />
</Tree.Folder>
<Tree.File name="header.js" />
</Tree.Folder>
<Tree.File name="readme.md" />
</Tree>
`} />
<Playground
title="Imperative"
desc="Use props `value` to show more complex file tree."
scope={{ Tree }}
code={`
() => {
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 <Tree value={files} />
}
`} />
<Playground
title="Extra Message"
desc="Use props `value` to show more complex file tree."
scope={{ Tree }}
code={`
() => {
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 <Tree value={files} />
}
`} />
<Playground
title="Event"
desc="Path will be reported when file is clicked."
scope={{ Tree, useToasts }}
code={`
() => {
const [_, setToast] = useToasts()
const handler = path => setToast({ text: path })
return (
<Tree onClick={handler}>
<Tree.Folder name="components">
<Tree.File name="layout.js" />
<Tree.File name="layout.js" />
<Tree.Folder name="footer">
<Tree.File name="footer.js" />
<Tree.File name="footer-text.js" />
<Tree.File name="footer-license.js" />
</Tree.Folder>
</Tree.Folder>
<Tree.File name="package.json" />
<Tree.File name="readme.md" />
</Tree>
)
}
`} />
<Attributes edit="/pages/docs/components/file-tree.mdx">
<Attributes.Title>Tree.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default
| ---------- | ---------- | ---- | -------------- | ------ |
| **value** | value of files | `Array<FileTreeValue>` | - | - |
| **initialExpand** | expand by default | `boolean` | - | `false` |
| **onClick** | click file event | `(path: string) => void` | - | - |
| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - |
<Attributes.Title>Tree.File.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default
| ---------- | ---------- | ---- | -------------- | ------ |
| **name**(required) | file name | `string` | - | - |
| **extra** | extra message | `string` | - | - |
| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - |
<Attributes.Title>Tree.Folder.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default
| ---------- | ---------- | ---- | -------------- | ------ |
| **name**(required) | folder name | `string` | - | - |
| **extra** | extra message | `string` | - | - |
| ... | native props | `HTMLAttributes` | `'id', 'title', 'className', ...` | - |
<Attributes.Title>type FileTreeValue</Attributes.Title>
```ts
type FileTreeValue = {
type: 'directory' || 'file'
name: string
extra?: string
files?: Array<FileTreeValue>
}
```
</Attributes>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>