Merge pull request #42 from unix/table

feat(table): add component
This commit is contained in:
witt
2020-04-01 06:47:09 +08:00
committed by GitHub
13 changed files with 598 additions and 41 deletions

View File

@@ -42,3 +42,4 @@ export { default as AutoComplete } from './auto-complete'
export { default as Collapse } from './collapse'
export { default as Loading } from './loading'
export { default as Textarea } from './textarea'
export { default as Table } from './table'

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import withDefaults from '../utils/with-defaults'
import { getRealShape } from '../utils/collections'
import useRealShape from '../utils/use-real-shape'
interface Props {
isExpanded?: boolean
@@ -24,23 +24,18 @@ const Expand: React.FC<React.PropsWithChildren<ExpandProps>> = ({
const entryTimer = useRef<number>()
const leaveTimer = useRef<number>()
const resetTimer = useRef<number>()
const setRealHeight = () => {
const { height: elHeight } = getRealShape(contentRef.current)
setHeight(`${elHeight}px`)
}
const [state, updateShape] = useRealShape<HTMLDivElement>(contentRef)
useEffect(() => setHeight(`${state.height}px`), [state.height])
useEffect(() => {
if (!contentRef || !contentRef.current) return
setRealHeight()
}, [contentRef])
useEffect(() => {
// show element or reset height
// show element or reset height.
// force an update once manually, even if the element does not change.
// (the height of the element might be "auto")
if (isExpanded) {
setVisible(isExpanded)
} else {
setRealHeight()
updateShape()
setHeight(`${state.height}px`)
}
// show expand animation

View File

@@ -0,0 +1,6 @@
import Table from './table'
import TableColumn from './table-column'
Table.Column = TableColumn
export default Table

View File

@@ -0,0 +1,85 @@
import React from 'react'
import withDefaults from '../utils/with-defaults'
import useTheme from '../styles/use-theme'
import TableCell from './table-cell'
import { useTableContext } from './table-context'
interface Props {
hover: boolean
emptyText: string
onRow: (row: any, index: number) => void
onCell: (cell: any, index: number, colunm: number) => void
data: Array<any>
className?: string
}
const defaultProps = {
className: '',
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type TableBodyProps = Props & typeof defaultProps & NativeAttrs
export type cellActions = {
remove: Function
}
export type cellData = {
row: number
column: number
value: any
}
const TableBody: React.FC<TableBodyProps> = React.memo(({
data, hover, emptyText, onRow, onCell
}) => {
const theme = useTheme()
const { columns } = useTableContext()
const rowClickHandler = (row: any, index: number) => {
onRow(row, index)
}
return (
<tbody>
{data.map((row, index) => {
return (
<tr key={`tbody-row-${index}`} className={hover ? 'hover' : ''}
onClick={() => rowClickHandler(row, index)}>
<TableCell columns={columns}
row={row}
rowIndex={index}
emptyText={emptyText}
onCellClick={onCell} />
</tr>
)
})}
<style jsx>{`
tr {
transition: background-color .25s ease;
}
tr.hover:hover {
background-color: ${theme.palette.accents_1};
}
tr :global(td) {
padding: 0 ${theme.layout.gapHalf};
border-bottom: 1px solid ${theme.palette.border};
color: ${theme.palette.accents_6};
font-size: 0.875rem;
text-align: left;
}
tr :global(.cell) {
min-height: 3.125rem;
display: flex;
-webkit-box-align: center;
align-items: center;
flex-flow: row wrap;
}
`}</style>
</tbody>
)
})
export default withDefaults(TableBody, defaultProps)

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { TableColumnItem, useTableContext } from './table-context'
interface Props {
columns: Array<TableColumnItem>
row: any
rowIndex: number
emptyText: string
onCellClick: (cell: any, rowIndex: number, colunmIndex: number) => void
}
export type cellActions = {
remove: Function
}
export type cellData = {
row: number
column: number
rowValue: any
}
const TableCell: React.FC<Props> = React.memo(({
columns, row, rowIndex, emptyText, onCellClick,
}) => {
const { removeRow } = useTableContext()
const actions: cellActions = {
remove: () => {
removeRow && removeRow(rowIndex)
},
}
/* eslint-disable react/jsx-no-useless-fragment */
return (
<>
{columns.map((column, index) => {
const data: cellData = {
row: rowIndex,
column: index,
rowValue: row,
}
const rowLabel = row[column.value]
const cellValue = !rowLabel ? emptyText
: (typeof rowLabel === 'function' ? rowLabel(actions, data) : rowLabel)
return (
<td key={`row-td-${index}-${column.value}`}
onClick={() => onCellClick(cellValue, rowIndex, index)}>
<div className="cell">{cellValue}</div>
</td>
)
})}
</>
)
/* eslint-enable */
})
export default TableCell

View File

@@ -0,0 +1,32 @@
import React, { useEffect } from 'react'
import { useTableContext } from './table-context'
import useWarning from '../utils/use-warning'
interface Props {
prop: string
label?: string
width?: number
}
export type TableColumnProps = Props
const TableColumn: React.FC<React.PropsWithChildren<TableColumnProps>> = ({
children, prop, label, width,
}) => {
const { appendColumn } = useTableContext()
if (!prop || prop.trim() === '') {
useWarning('The props "prop" is required.', 'Table.Column')
}
useEffect(() => {
appendColumn && appendColumn({
label: children || label,
value: `${prop}`.trim(),
width,
})
}, [])
return null
}
export default TableColumn

View File

@@ -0,0 +1,21 @@
import React from 'react'
export type TableColumnItem = {
value: string
label: React.ReactNode | string
width?: number
}
export interface TableConfig {
columns: Array<TableColumnItem>
appendColumn?: (column: TableColumnItem) => void
removeRow?: (rowIndex: number) => void
}
const defaultContext = {
columns: [],
}
export const TableContext = React.createContext<TableConfig>(defaultContext)
export const useTableContext = (): TableConfig => React.useContext<TableConfig>(TableContext)

View File

@@ -0,0 +1,106 @@
import React, { useMemo } from 'react'
import withDefaults from '../utils/with-defaults'
import useTheme from '../styles/use-theme'
import { TableColumnItem } from './table-context'
interface Props {
width: number
columns: Array<TableColumnItem>
className?: string
}
const defaultProps = {
className: '',
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type TableHeadProps = Props & typeof defaultProps & NativeAttrs
const makeColgroup = (width: number, columns: Array<TableColumnItem>) => {
const unsetWidthCount = columns.filter(c => !c.width).length
const customWidthTotal = columns.reduce((pre, current) => {
return current.width ? pre + current.width : pre
}, 0)
const averageWidth = (width - customWidthTotal) / unsetWidthCount
if (averageWidth <= 0) return <colgroup />
return (
<colgroup>
{columns.map((column, index) => (
<col key={`colgroup-${index}`} width={column.width || averageWidth} />
))}
</colgroup>
)
}
const TableHead: React.FC<TableHeadProps> = React.memo(({
columns, width,
}) => {
const theme = useTheme()
const isScalableWidth = useMemo(() => columns.find(item => !!item.width), [columns])
const colgroup = useMemo(() => {
if (!isScalableWidth) return <colgroup />
return makeColgroup(width, columns)
}, [isScalableWidth, width])
return (
<>
{colgroup}
<thead>
<tr>
{columns.map((column, index) => (
<th key={`table-th-${column.value}-${index}`}>
<div className="thead-box">{column.label}</div>
</th>
))}
</tr>
</thead>
<style jsx>{`
thead {
border-collapse: separate;
border-spacing: 0;
}
th {
padding: 0 ${theme.layout.gapHalf};
font-size: .75rem;
font-weight: normal;
text-align: left;
letter-spacing: 0;
vertical-align: center;
min-height: 2.5rem;
color: ${theme.palette.accents_5};
background: ${theme.palette.accents_1};
border-bottom: 1px solid ${theme.palette.border};
border-top: 1px solid ${theme.palette.border};
border-radius: 0;
}
th:nth-child(1) {
border-bottom: 1px solid ${theme.palette.border};
border-left: 1px solid ${theme.palette.border};
border-top: 1px solid ${theme.palette.border};
border-top-left-radius: ${theme.layout.radius};
border-bottom-left-radius: ${theme.layout.radius};
}
th:last-child {
border-bottom: 1px solid ${theme.palette.border};
border-right: 1px solid ${theme.palette.border};
border-top: 1px solid ${theme.palette.border};
border-top-right-radius: ${theme.layout.radius};
border-bottom-right-radius: ${theme.layout.radius};
}
.thead-box {
display: flex;
align-items: center;
-webkit-box-align: center;
min-height: 2.5rem;
text-transform: uppercase;
}
`}</style>
</>
)
})
export default withDefaults(TableHead, defaultProps)

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useMemo, useRef } from 'react'
import TableColumn from './table-column'
import TableHead from './table-head'
import TableBody from './table-body'
import useRealShape from '../utils/use-real-shape'
import useResize from '../utils/use-resize'
import { TableContext, TableColumnItem, TableConfig } from './table-context'
import useCurrentState from '../utils/use-current-state'
interface Props {
data?: Array<any>
emptyText?: string
hover?: boolean
onRow: (row: any, index: number) => void
onCell: (cell: any, index: number, colunm: number) => void
onChange: (data: any) => void
className?: string
}
const defaultProps = {
hover: true,
emptyText: '',
onRow: () => {},
onCell: () => {},
onChange: () => {},
className: '',
}
type NativeAttrs = Omit<React.TableHTMLAttributes<any>, keyof Props>
export type TableProps = Props & typeof defaultProps & NativeAttrs
const Table: React.FC<React.PropsWithChildren<TableProps>> = ({
children, data, hover, emptyText, onRow, onCell, onChange,
className, ...props
}) => {
const ref = useRef<HTMLTableElement>(null)
const [{ width }, updateShape] = useRealShape<HTMLTableElement>(ref)
const [columns, setColumns, columnsRef] = useCurrentState<Array<TableColumnItem>>([])
const [selfData, setSelfData, dataRef] = useCurrentState<Array<TableColumnItem>>([])
const appendColumn = (column: TableColumnItem) => {
const pureCurrent = columnsRef.current.filter(item => item.value !== column.value)
setColumns([...pureCurrent, column])
}
const removeRow = (rowIndex: number) => {
const next = dataRef.current.filter((_, index) => index !== rowIndex)
onChange(next)
setSelfData([...next])
}
const initialValue = useMemo<TableConfig>(() => ({
columns,
appendColumn,
removeRow,
}), [columns])
useEffect(() => {
if (!data) return
setSelfData(data)
}, [data])
useResize(() => updateShape())
return (
<TableContext.Provider value={initialValue}>
<table ref={ref} className={className} {...props}>
<TableHead columns={columns} width={width} />
<TableBody data={selfData} hover={hover} emptyText={emptyText}
onRow={onRow} onCell={onCell} />
{children}
<style jsx>{`
table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
`}</style>
</table>
</TableContext.Provider>
)
}
type TableComponent<P = {}> = React.FC<P> & {
Column: typeof TableColumn
}
type ComponentProps = Partial<typeof defaultProps> & Omit<Props, keyof typeof defaultProps>
(Table as TableComponent<ComponentProps>).defaultProps = defaultProps
export default Table as TableComponent<ComponentProps>

View File

@@ -101,29 +101,3 @@ export const setChildrenIndex = (
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

@@ -5,8 +5,8 @@ export type CurrentStateType<S> = [
]
const useCurrentState = <S,>(initialState: S): CurrentStateType<S> => {
const [state, setState] = useState<S>(initialState)
const ref = useRef<S>(initialState)
const [state, setState] = useState<S>(initialState as S)
const ref = useRef<S>(initialState as S)
useEffect(() => {
ref.current = state

View File

@@ -0,0 +1,45 @@
import { MutableRefObject, useEffect, useState } from 'react'
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),
}
}
export type ShapeResult = [ShapeType, () => void]
const useRealShape = <T extends HTMLElement>(ref: MutableRefObject<T | null>): ShapeResult => {
const [state, setState] = useState<ShapeType>({
width: 0,
height: 0,
})
const update = () => {
const { width, height } = getRealShape(ref.current)
setState({ width, height })
}
useEffect(() => update(), [ref.current])
return [state, update]
}
export default useRealShape

View File

@@ -0,0 +1,147 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { Table, Spacer, Code, Text, Button } from 'components'
export const meta = {
title: 'Table',
description: 'Table',
}
## Table
Display tabular data in format.
<Playground
scope={{ Table }}
code={`
() => {
const data = [
{ property: 'type', description: 'Content type', type: 'secondary | warning', default: '-' },
{ property: 'Component', description: 'DOM element to use', type: 'string', default: '-' },
{ property: 'bold', description: 'Bold style', type: 'boolean', default: 'true' },
]
return (
<Table data={data}>
<Table.Column prop="property" label="property" />
<Table.Column prop="description" label="description" />
<Table.Column prop="type" label="type" />
<Table.Column prop="default" label="default" />
</Table>
)
}
`} />
<Playground
title="compose"
scope={{ Table, Code, Text }}
code={`
() => {
const data = [
{ property: 'type', description: 'Content type', type: 'secondary | warning', default: '-' },
{ property: 'Component', description: 'DOM element to use', type: <Code>string</Code>, default: '-' },
{ property: <Text b>bold</Text>, description: 'Bold style', type: <Code>boolean</Code>, default: <Code>true</Code> },
]
return (
<Table data={data}>
<Table.Column prop="property" label="property" />
<Table.Column prop="description" label="description" />
<Table.Column prop="type" label="type" />
<Table.Column prop="default" label="default" />
</Table>
)
}
`} />
<Playground
title="width"
desc="Specifies the `width` of all or part of the columns."
scope={{ Table, Code, Text }}
code={`
() => {
const data = [
{ property: 'type', description: 'Content type', type: 'secondary | warning', default: '-' },
{ property: 'Component', description: 'DOM element to use', type: <Code>string</Code>, default: '-' },
{ property: <Text b>bold</Text>, description: 'Bold style', type: <Code>boolean</Code>, default: <Code>true</Code> },
]
return (
<Table data={data}>
<Table.Column prop="property" label="property" width={50} />
<Table.Column prop="description" label="description" />
<Table.Column prop="type" label="type" />
<Table.Column prop="default" label="default" />
</Table>
)
}
`} />
<Playground
title="actions"
scope={{ Table, Text, Button }}
code={`
() => {
const operation = (actions, rowData) => {
return <Button type="error" auto size="mini" onClick={() => actions.remove()}>Remove</Button>
}
const data = [
{ property: 'type', description: 'Content type', operation },
{ property: 'Component', description: 'DOM element to use', operation },
{ property: <Text b>bold</Text>, description: 'Bold style', operation },
]
return (
<Table data={data}>
<Table.Column prop="property" label="property" />
<Table.Column prop="description" label="description" />
<Table.Column prop="operation" label="operation" width={150} />
</Table>
)
}
`} />
<Playground
title="custom head"
scope={{ Table, Code, Text }}
code={`
() => {
const data = [
{ property: 'type', description: 'Content type', type: 'secondary | warning', default: '-' },
{ property: 'Component', description: 'DOM element to use', type: <Code>string</Code>, default: '-' },
{ property: <Text b>bold</Text>, description: 'Bold style', type: <Code>boolean</Code>, default: <Code>true</Code> },
]
return (
<Table data={data}>
<Table.Column prop="property" label="property" />
<Table.Column prop="description" label="description" />
<Table.Column prop="type">
<Code>type</Code>
</Table.Column>
<Table.Column prop="default">
<Text b>default</Text>
</Table.Column>
</Table>
)
}
`} />
<Attributes edit="/pages/docs/components/table.mdx">
<Attributes.Title>Table.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default
| ---------- | ---------- | ---- | -------------- | ------ |
| **data** | data source | `Array<any>` | - | - |
| **emptyText** | displayed text when table's content is empty | `string` | - | - |
| **hover** | table's hover effect | `boolean` | - | `true` |
| **onRow** | callback row's content by click | `(row: any, index: number) => void` | - | - |
| **onCell** | callback cell's content by click | `(cell: any, index: number, colunm: number) => void` | - | - |
| **onChange** | data change event | `(data: any) => void` | - | - |
| ... | native props | `TableHTMLAttributes` | `'id', 'name', 'className', ...` | - |
<Attributes.Title>Table.Column.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default
| ---------- | ---------- | ---- | -------------- | ------ |
| **prop**(required) | table-column's prop | `string` | - | - |
| **label** | table-column's label | `string` | - | - |
| **width** | width number (px) | `number` | - | - |
</Attributes>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>