refactor: export all hooks functions directly from main module

refactor: rename the modules to make sure tree-shaking works
This commit is contained in:
unix
2020-05-15 23:10:02 +08:00
parent b93acc8d75
commit 4e7b4cc57d
46 changed files with 82 additions and 57 deletions

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tree should mount correctly 1`] = `
<TreeFile
className=""
level={0}
name="package.json"
parentPath=""
/>
`;
exports[`Tree should mount correctly 2`] = `
<TreeFolder
className=""
level={0}
name="components"
parentPath=""
/>
`;
exports[`Tree should mount correctly 3`] = `ReactWrapper {}`;

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { mount } from 'enzyme'
import { Tree } from 'components'
import { nativeEvent } from 'tests/utils'
import { FileTreeValue } from 'components/tree/tree'
const mockFiles: Array<FileTreeValue> = [
{
type: 'file',
name: 'cs.js',
},
{
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',
},
],
},
{
type: 'file',
name: 'views.md',
},
]
describe('Tree', () => {
it('should mount correctly', () => {
const wrapper = mount(
<Tree>
<Tree.File name="package.json" />
<Tree.Folder name="components">
<Tree.File name="layout.js" />
<Tree.File name="header.js" />
</Tree.Folder>
<Tree.File name="readme.md" />
</Tree>,
)
expect(<Tree.File name="package.json" />).toMatchSnapshot()
expect(<Tree.Folder name="components" />).toMatchSnapshot()
expect(wrapper).toMatchSnapshot()
expect(() => wrapper.unmount()).not.toThrow()
})
it('should show extra messages', () => {
const files = mockFiles.map(item => ({ ...item, extra: 'extra' }))
const wrapper = mount(<Tree value={files} />)
const firstName = wrapper.find('.name').at(0)
expect(firstName.text()).toContain('extra')
expect(() => wrapper.unmount()).not.toThrow()
})
it('should trigger event when file clicked', () => {
const callback = jest.fn()
const wrapper = mount(<Tree value={mockFiles} onClick={callback} />)
wrapper.find('.file').at(0).simulate('click', nativeEvent)
expect(callback).toHaveBeenCalled()
})
it('should be work when value is empty', () => {
const wrapper = mount(<Tree value={[]} />)
expect(() => wrapper.unmount()).not.toThrow()
})
})

8
components/tree/index.ts Normal file
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,16 @@
import React from 'react'
export interface TreeConfig {
onFileClick?: (path: string) => void
initialExpand: boolean
isImperative: boolean
}
const defaultContext = {
initialExpand: false,
isImperative: false,
}
export const TreeContext = React.createContext<TreeConfig>(defaultContext)
export const useTreeContext = (): TreeConfig => React.useContext<TreeConfig>(TreeContext)

View File

@@ -0,0 +1,44 @@
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>
)
}
const MemoTreeFileIcon = React.memo(TreeFileIcon)
export default withDefaults(MemoTreeFileIcon, defaultProps)

View File

@@ -0,0 +1,109 @@
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: '',
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type TreeFileProps = Props & typeof defaultProps & NativeAttrs
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 && 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: 0.5rem;
}
.name {
transition: opacity 100ms ease 0ms;
color: ${theme.palette.accents_8};
white-space: nowrap;
font-size: 0.875rem;
}
.extra {
font-size: 0.75rem;
align-self: baseline;
padding-left: 4px;
color: ${theme.palette.accents_5};
}
.name:hover {
opacity: 0.7;
}
`}</style>
</div>
)
}
export default withDefaults(TreeFile, defaultProps)

View File

@@ -0,0 +1,43 @@
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>
)
}
const MemoTreeFolderIcon = React.memo(TreeFolderIcon)
export default withDefaults(MemoTreeFolderIcon, defaultProps)

View File

@@ -0,0 +1,156 @@
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: '',
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type TreeFolderProps = Props & typeof defaultProps & NativeAttrs
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: 0.875rem;
height: 0.875rem;
z-index: 10;
background-color: ${theme.palette.background};
}
.icon {
width: 1.5rem;
height: 100%;
margin-right: 0.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: 0.875rem;
}
.extra {
font-size: 0.75rem;
align-self: baseline;
padding-left: 4px;
color: ${theme.palette.accents_5};
}
.name:hover {
opacity: 0.7;
}
.content {
display: flex;
flex-direction: column;
height: auto;
}
`}</style>
</div>
)
}
export default withDefaults(TreeFolder, defaultProps)

View File

@@ -0,0 +1,22 @@
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,26 @@
import React from 'react'
interface Props {
count: number
}
const TreeIndents: React.FC<Props> = ({ count }) => {
if (count === 0) return null
return (
/* eslint-disable react/jsx-no-useless-fragment */
<>
{[...new Array(count)].map((_, index) => (
<span className="indent" key={`indent-${index}`}>
<style jsx>{`
span.indent {
left: calc(-1.875rem * ${index + 1} + 0.75rem);
}
`}</style>
</span>
))}
</>
/* eslint-enable */
)
}
export default TreeIndents

View File

@@ -0,0 +1,48 @@
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>
)
}
const MemoTreeStatusIcon = React.memo(TreeStatusIcon)
export default withDefaults(MemoTreeStatusIcon, defaultProps)

102
components/tree/tree.tsx Normal file
View File

@@ -0,0 +1,102 @@
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 './/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: '',
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type TreeProps = Props & typeof defaultProps & NativeAttrs
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> &
NativeAttrs
Tree.defaultProps = defaultProps
export default Tree as TreeComponent<ComponentProps>