diff --git a/components/index.ts b/components/index.ts index c748911..b367db3 100644 --- a/components/index.ts +++ b/components/index.ts @@ -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' diff --git a/components/shared/expand.tsx b/components/shared/expand.tsx index d35b763..899a8ba 100644 --- a/components/shared/expand.tsx +++ b/components/shared/expand.tsx @@ -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> = ({ const entryTimer = useRef() const leaveTimer = useRef() const resetTimer = useRef() - - const setRealHeight = () => { - const { height: elHeight } = getRealShape(contentRef.current) - setHeight(`${elHeight}px`) - } + const [state, updateShape] = useRealShape(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 diff --git a/components/table/index.ts b/components/table/index.ts new file mode 100644 index 0000000..d859efa --- /dev/null +++ b/components/table/index.ts @@ -0,0 +1,6 @@ +import Table from './table' +import TableColumn from './table-column' + +Table.Column = TableColumn + +export default Table diff --git a/components/table/table-body.tsx b/components/table/table-body.tsx new file mode 100644 index 0000000..4874c74 --- /dev/null +++ b/components/table/table-body.tsx @@ -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 + className?: string +} + +const defaultProps = { + className: '', +} + +type NativeAttrs = Omit, 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 = React.memo(({ + data, hover, emptyText, onRow, onCell +}) => { + const theme = useTheme() + const { columns } = useTableContext() + const rowClickHandler = (row: any, index: number) => { + onRow(row, index) + } + + return ( + + {data.map((row, index) => { + return ( + rowClickHandler(row, index)}> + + + ) + })} + + + ) +}) + +export default withDefaults(TableBody, defaultProps) diff --git a/components/table/table-cell.tsx b/components/table/table-cell.tsx new file mode 100644 index 0000000..95f643c --- /dev/null +++ b/components/table/table-cell.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { TableColumnItem, useTableContext } from './table-context' + +interface Props { + columns: Array + 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 = 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 ( + onCellClick(cellValue, rowIndex, index)}> +
{cellValue}
+ + ) + })} + + ) + /* eslint-enable */ +}) + +export default TableCell diff --git a/components/table/table-column.tsx b/components/table/table-column.tsx new file mode 100644 index 0000000..3c76428 --- /dev/null +++ b/components/table/table-column.tsx @@ -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> = ({ + 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 diff --git a/components/table/table-context.ts b/components/table/table-context.ts new file mode 100644 index 0000000..26ec628 --- /dev/null +++ b/components/table/table-context.ts @@ -0,0 +1,21 @@ +import React from 'react' + +export type TableColumnItem = { + value: string + label: React.ReactNode | string + width?: number +} + +export interface TableConfig { + columns: Array + appendColumn?: (column: TableColumnItem) => void + removeRow?: (rowIndex: number) => void +} + +const defaultContext = { + columns: [], +} + +export const TableContext = React.createContext(defaultContext) + +export const useTableContext = (): TableConfig => React.useContext(TableContext) diff --git a/components/table/table-head.tsx b/components/table/table-head.tsx new file mode 100644 index 0000000..0292d10 --- /dev/null +++ b/components/table/table-head.tsx @@ -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 + className?: string +} + +const defaultProps = { + className: '', +} + +type NativeAttrs = Omit, keyof Props> +export type TableHeadProps = Props & typeof defaultProps & NativeAttrs + +const makeColgroup = (width: number, columns: Array) => { + 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 + return ( + + {columns.map((column, index) => ( + + ))} + + ) +} + +const TableHead: React.FC = React.memo(({ + columns, width, +}) => { + const theme = useTheme() + const isScalableWidth = useMemo(() => columns.find(item => !!item.width), [columns]) + const colgroup = useMemo(() => { + if (!isScalableWidth) return + return makeColgroup(width, columns) + }, [isScalableWidth, width]) + + return ( + <> + {colgroup} + + + {columns.map((column, index) => ( + +
{column.label}
+ + ))} + + + + + ) +}) + +export default withDefaults(TableHead, defaultProps) diff --git a/components/table/table.tsx b/components/table/table.tsx new file mode 100644 index 0000000..2c9d9fd --- /dev/null +++ b/components/table/table.tsx @@ -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 + 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, keyof Props> +export type TableProps = Props & typeof defaultProps & NativeAttrs + +const Table: React.FC> = ({ + children, data, hover, emptyText, onRow, onCell, onChange, + className, ...props +}) => { + const ref = useRef(null) + const [{ width }, updateShape] = useRealShape(ref) + const [columns, setColumns, columnsRef] = useCurrentState>([]) + const [selfData, setSelfData, dataRef] = useCurrentState>([]) + 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(() => ({ + columns, + appendColumn, + removeRow, + }), [columns]) + + useEffect(() => { + if (!data) return + setSelfData(data) + }, [data]) + useResize(() => updateShape()) + + return ( + + + + + {children} + + +
+
+ ) +} + +type TableComponent

= React.FC

& { + Column: typeof TableColumn +} +type ComponentProps = Partial & Omit + +(Table as TableComponent).defaultProps = defaultProps + +export default Table as TableComponent diff --git a/components/utils/collections.ts b/components/utils/collections.ts index 87a1dc0..04b6aae 100644 --- a/components/utils/collections.ts +++ b/components/utils/collections.ts @@ -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), - } -} diff --git a/components/utils/use-current-state.ts b/components/utils/use-current-state.ts index cc9308b..757f5cd 100644 --- a/components/utils/use-current-state.ts +++ b/components/utils/use-current-state.ts @@ -5,8 +5,8 @@ export type CurrentStateType = [ ] const useCurrentState = (initialState: S): CurrentStateType => { - const [state, setState] = useState(initialState) - const ref = useRef(initialState) + const [state, setState] = useState(initialState as S) + const ref = useRef(initialState as S) useEffect(() => { ref.current = state diff --git a/components/utils/use-real-shape.ts b/components/utils/use-real-shape.ts new file mode 100644 index 0000000..4f0b0ac --- /dev/null +++ b/components/utils/use-real-shape.ts @@ -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 = (ref: MutableRefObject): ShapeResult => { + const [state, setState] = useState({ + 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 diff --git a/pages/docs/components/table.mdx b/pages/docs/components/table.mdx new file mode 100644 index 0000000..8ffcc41 --- /dev/null +++ b/pages/docs/components/table.mdx @@ -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. + + { + 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 ( + + + + + +
+ ) +} +`} /> + + { + 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 ( + + + + + +
+ ) +} +`} /> + + { + 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 ( + + + + + +
+ ) +} +`} /> + + { + const operation = (actions, rowData) => { + return + } + const data = [ + { property: 'type', description: 'Content type', operation }, + { property: 'Component', description: 'DOM element to use', operation }, + { property: bold, description: 'Bold style', operation }, + ] + return ( + + + + +
+ ) +} +`} /> + + { + 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 ( + + + + + type + + + default + +
+ ) +} +`} /> + + +Table.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **data** | data source | `Array` | - | - | +| **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', ...` | - | + +Table.Column.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **prop**(required) | table-column's prop | `string` | - | - | +| **label** | table-column's label | `string` | - | - | +| **width** | width number (px) | `number` | - | - | + + + +export default ({ children }) => {children}