diff --git a/src/data-table/data-table.styles.tsx b/src/data-table/data-table.styles.tsx new file mode 100644 index 000000000..1e8808f0e --- /dev/null +++ b/src/data-table/data-table.styles.tsx @@ -0,0 +1,137 @@ +import styled, { css } from "styled-components"; +import { Color } from "../color"; +import { TextStyleHelper } from "../text"; + +// ============================================================================= +// STYLE INTERFACE, transient props are denoted with $ +// See more https://styled-components.com/docs/api#transient-props +// ============================================================================= +interface TableStyleProps { + $addMarginToFirstColumn?: boolean; +} +interface HeaderCellProps { + $clickable: boolean; + $maxWidth?: string; +} +interface BodyRowProps { + $alternating: boolean; + $isSelected?: boolean; + $isSelectable?: boolean; +} +interface BodyRowProps { + $alternating: boolean; + $isSelected?: boolean; + $isSelectable?: boolean; +} +interface BodyCellProps { + $width?: string; +} +// ============================================================================= +// STYLES +// ============================================================================= + +export const TableWrapper = styled.div` + width: 100%; + border: 0.125rem solid ${Color.Neutral[6]}; + border-radius: 0.5rem 0.5rem 0 0; + overflow: auto; +`; + +export const Table = styled.table` + width: 100%; + border-collapse: collapse; + tr, + td { + padding: 1.5rem 0; + } + th:last-child, + td:last-child { + padding-right: 1.5rem; + } + ${(props) => { + if (props.$addMarginToFirstColumn) { + return css` + th:first-child, + td:first-child { + padding-left: 1.5rem; + } + `; + } + }} +`; + +export const HeaderRow = styled.tr` + background-color: #f5f5f5; + height: 5rem; + border-bottom: 0.125rem solid ${Color.Neutral[6]}; +`; + +export const HeaderCell = styled.th` + padding: 1rem 0; + text-align: left; + cursor: ${(props) => (props.$clickable ? "pointer" : "default")}; + max-width: ${(props) => (props.$maxWidth ? props.$maxWidth : "auto")}; + vertical-align: middle; + ${TextStyleHelper.getFontFamily("H5", "bold")} + color: ${Color.Neutral[1]}; +`; + +export const HeaderCellWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + + svg { + color: ${Color.Neutral[1]}; + margin-left: 0.5rem; + } +`; + +export const BodyRow = styled.tr` + background-color: ${(props) => { + if (props.$isSelected) { + return css` + ${Color.Accent.Light[5]}; + `; + } else if (props.$alternating) { + return css` + ${Color.Neutral[7]}; + `; + } else { + return css` + ${Color.Neutral[8]}; + `; + } + }}; + border-top: 0.125rem solid ${Color.Neutral[6]}; + &:hover { + background-color: ${(props) => { + if (!props.$isSelected && props.$isSelectable) { + return css` + ${Color.Accent.Light[4]}; + `; + } + }}; + } + &:first-child { + border-top: none; + } +`; + +export const BodyCell = styled.td` + padding: 1.25rem 1rem; + width: ${(props) => (props.$width ? props.$width : "auto")}; + vertical-align: middle; +`; + +export const CheckBoxWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +export const LoaderWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; diff --git a/src/data-table/data-table.tsx b/src/data-table/data-table.tsx new file mode 100644 index 000000000..2e099854f --- /dev/null +++ b/src/data-table/data-table.tsx @@ -0,0 +1,306 @@ +import { LoadingDotsSpinner } from "../animations"; +import { + BodyCell, + BodyRow, + CheckBoxWrapper, + HeaderCell, + HeaderCellWrapper, + HeaderRow, + LoaderWrapper, + Table, + TableWrapper, +} from "./data-table.styles"; +import { DataTableProps, HeaderProps, RowProps } from "./types"; +import { ErrorDisplayAttributes } from "../error-display/types"; +import { ArrowDownIcon, ArrowUpIcon } from "@lifesg/react-icons"; +import { Checkbox } from "../checkbox"; +import { ErrorDisplay } from "../error-display"; + +export const DataTable = ({ + id, + headersConfig, + headers, + rowsConfig, + rows, + className, + selectionConfig, + selection, + sortIndicators, + // actionsConfig, + alternatingRows, + customEmptyView, + loadState = "success", + emptyView = DEFAULT_EMPTY_VIEW_OPTIONS, + ...otherProps +}: DataTableProps) => { + // =========================================================================== + // HELPER FUNCTIONS + // =========================================================================== + const isAllCheckBoxSelected = (): boolean => { + return selection?.length === rows.length; + }; + + const isRowSelected = (rowId: string): boolean => { + return !!selection?.includes(rowId); + }; + + const isAlternatingRow = (index: number): boolean => { + return !!alternatingRows && index % 2 === 0; + }; + + const getDataTestId = (subStr: string) => { + if (!otherProps["data-testid"]) return undefined; + return `${otherProps["data-testid"]}-${subStr}`; + }; + + const getTotalColumns = (): number => { + return ( + headers.length + (selectionConfig?.showCheckboxes ? 1 : 0) + // + (actionsConfig?.showActions ? 1 : 0) + ); + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderHeaders = () => ( + + {selectionConfig?.showCheckboxes && renderHeaderCheckBox()} + {headers.map(renderHeaderCell)} + {/* {actionsConfig?.showActions && ( + + {actionsConfig.headerLabel} + + )} */} + + ); + + const renderHeaderCell = (header: HeaderProps) => { + const { + fieldKey, + label, + clickable = false, + className: headerClassName, + style, + } = typeof header === "string" + ? { + fieldKey: header, + label: header, + className: undefined, + style: undefined, + } + : header; + + return ( + + clickable && headersConfig?.onClickHeader?.(fieldKey) + } + style={style} + > + + {label} + {renderSortedArrow(fieldKey)} + + + ); + }; + + const renderSortedArrow = (fieldKey: string) => { + const isSorted = sortIndicators?.[fieldKey]; + + if (!isSorted) { + return <>; + } else if (isSorted === "asc") { + return ( + + ); + } else { + return ( + + ); + } + }; + + const renderHeaderCheckBox = () => { + return ( + + + { + selectionConfig?.onClickSelectAll?.( + isAllCheckBoxSelected() + ); + }} + /> + + + ); + }; + + const renderRows = () => { + return rows?.length < 1 ? ( + + + {customEmptyView ? customEmptyView() : basicEmptyView()} + + + ) : ( + <> + {rows?.map((row: RowProps, index: number) => ( + + {selectionConfig?.showCheckboxes && + renderRowCheckBox(row.id.toString())} + + {headers.map((header) => renderRowCell(header, row))} + + {/* {actionsConfig?.showActions && + actionsConfig.actions && ( + + {actionsConfig?.actions( + row, + isRowSelected(row.id.toString()) + )} + + )} */} + + ))} + + ); + }; + + const renderRowCell = (header: HeaderProps, row: RowProps) => { + const style = typeof header !== "string" ? header.style : undefined; + const fieldKey = typeof header === "string" ? header : header.fieldKey; + const cellData = row[fieldKey]; + const cellId = `${row.id.toString()}-${fieldKey}`; + + return ( + + {cellData} + + ); + }; + + const renderRowCheckBox = (rowId: string) => { + return ( + + + { + selectionConfig?.onClickSelect?.( + "id", + rowId, + !isRowSelected(rowId) + ); + }} + /> + + + ); + }; + + const basicEmptyView = () => { + return ( + + ); + }; + + const renderLoader = () => { + return ( + + + + {loadState === "loading" && } + + + + ); + }; + + return ( + + + + {renderHeaders()} + {loadState === "success" && renderRows()} + {loadState === "loading" && renderLoader()} + +
+
+ ); +}; + +const DEFAULT_EMPTY_VIEW_OPTIONS: ErrorDisplayAttributes = { + title: "No results found", + description: "No matching rows", + actionButton: { + children: "Reload", + onClick: () => { + console.log("Clicked on Reload button"); + }, + }, +}; diff --git a/src/data-table/index.ts b/src/data-table/index.ts new file mode 100644 index 000000000..25aa4de39 --- /dev/null +++ b/src/data-table/index.ts @@ -0,0 +1,2 @@ +export * from "./data-table"; +export * from "./types"; diff --git a/src/data-table/types.ts b/src/data-table/types.ts new file mode 100644 index 000000000..a379864d6 --- /dev/null +++ b/src/data-table/types.ts @@ -0,0 +1,83 @@ +import { CSSProperties, ReactNode } from "react"; +import { ErrorDisplayAttributes } from "../error-display/types"; +export interface DataTableProps { + id?: string | undefined; + "data-testid"?: string | undefined; + headersConfig?: HeadersConfigProps | undefined; + headers: HeaderProps[]; + rowsConfig?: RowsConfigProps | undefined; + rows?: RowProps[] | undefined; + /** css class to put on table **/ + className?: string | undefined; + selectionConfig?: SelectionConfigProps | undefined; + /** ids of all selected rows **/ + selection?: string[] | undefined; + /** columns that want to show a sort indicator **/ + sortIndicators?: SortIndicatorsProps | undefined; + // actionsConfig?: ActionsConfigProps | undefined; + alternatingRows?: boolean | undefined; + customEmptyView?: () => ReactNode | string | undefined; + loadState: LoadingType | undefined; + emptyView?: ErrorDisplayAttributes | undefined; +} + +export type LoadingType = "loading" | "success"; + +export type SortIndicatorProps = "asc" | "desc"; + +export interface SortIndicatorsProps { + [fieldKey: string]: SortIndicatorProps; +} + +export interface HeadersConfigProps { + /** css class to put on header row **/ + className?: string | undefined; + onClickHeader?: ((fieldKey: string) => void) | undefined; +} + +/** label text. Rest defaults to fieldKey=label, clickable=false **/ +export type HeaderProps = string | HeaderItemProps; +interface HeaderItemProps { + fieldKey: string; + /** (technically ReactNode also includes string, but this makes it more obvious for devs) **/ + label: string | ReactNode; + clickable?: boolean | undefined; + /** css class to put on this header cell **/ + className?: string | undefined; + style?: CSSProperties | undefined; +} + +export interface RowsConfigProps { + /** css class to put on each row **/ + className?: string | undefined; + alternatingClassName?: string | undefined; +} + +export interface RowProps { + id: string | number; + /** data with keys matching fieldKey **/ + [fieldKey: string]: string | number | ReactNode; +} + +export interface SelectionConfigProps { + showCheckboxes: boolean; + showHeaderCheckbox?: boolean | undefined; + onClickSelect?: + | ((fieldKey: string, rowId: string, isSelected: boolean) => void) + | undefined; + onClickSelectAll?: ((isSelected: boolean) => void) | undefined; + /** css class to add to each cell containing the checkbox **/ + className?: string | undefined; + /** css class to add to the header cell **/ + headerClassName?: string | undefined; + headerWidth?: string | undefined; +} + +// export interface ActionsConfigProps { +// showActions: boolean; +// className?: string | undefined; +// headerClassName?: string | undefined; +// headerLabel?: string | ReactNode | undefined; +// actions?: ((row: RowProps, isSelected: boolean) => ReactNode) | undefined; +// headerWidth?: string | undefined; +// } diff --git a/src/index.ts b/src/index.ts index f7080da65..c52889bbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from "./calendar"; export * from "./card"; export * from "./checkbox"; export * from "./color"; +export * from "./data-table"; export * from "./date-input"; export * from "./date-range-input"; export * from "./design-token"; diff --git a/stories/data-table/data-table.stories.mdx b/stories/data-table/data-table.stories.mdx new file mode 100644 index 000000000..852ecf40b --- /dev/null +++ b/stories/data-table/data-table.stories.mdx @@ -0,0 +1,363 @@ +import { Canvas, Meta, Story } from "@storybook/addon-docs"; +import { DataTable } from "src/data-table"; +import { + Heading3, + Secondary, + StoryContainer, + Title, +} from "../storybook-common"; +import { PropsTable } from "./props-table"; +import { useState } from "react"; + + + +Data Table + +Overview + +A call to action component with an icon in it. + +```tsx +import { DataTable } from "@lifesg/react-design-system/data-table"; +``` + + + + + + + +Sorting Indicator + +Show sorting Indicator Table header. + + + + + + + +Checkbox selection + +Table Row with checkbox selection. + + + + {() => { + const [selected, setSelected] = useState([]); + const handleOnClickSelect = (rowId, isSelected) => { + console.log("isSelected :", isSelected); + if (isSelected) { + setSelected((selected) => [...selected, rowId]); + } else { + setSelected( + selected.filter((item) => { + return item !== rowId; + }) + ); + } + }; + const handleOnClickSelectAll = (select) => { + console.log("select :", select); + }; + return ( + { + handleOnClickSelect(rowId, isSelected); + }, + }} + /> + ); + }} + + + +Alternating Rows + +Table Alternating Row. + + + + {() => { + const [selected, setSelected] = useState(); + return ( + + ); + }} + + + +Empty Data View + +Table Empty Data View. + + + + { + alert("Clicked on Trigger button"); + }, + }, + }} + /> + + + +Data Loading View + +Table Empty Data View. + + + + + + + +Component API + + diff --git a/stories/data-table/doc-elements.tsx b/stories/data-table/doc-elements.tsx new file mode 100644 index 000000000..329dcf7aa --- /dev/null +++ b/stories/data-table/doc-elements.tsx @@ -0,0 +1,26 @@ +import styled, { keyframes } from "styled-components"; + +// ============================================================================= +// STYLING +// ============================================================================= + +export const EmptyDataElement = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + opacity: 0; + img { + width: 10rem; + height: 10rem; + flex: none; + order: 0; + flex-grow: 0; + } + animation: fade-in 1s ease-in forwards; + @keyframes fade-in { + 100% { + opacity: 1; + } + } +`; diff --git a/stories/data-table/props-table.tsx b/stories/data-table/props-table.tsx new file mode 100644 index 000000000..c2bdd39a2 --- /dev/null +++ b/stories/data-table/props-table.tsx @@ -0,0 +1,290 @@ +import React from "react"; +import { ApiTable } from "../storybook-common/api-table"; +import { ApiTableSectionProps } from "../storybook-common/api-table/types"; + +const DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "className", + description: + "The className of the component for custom styling.", + propTypes: ["string"], + }, + { + name: "data-testid", + description: "The test identifier of the component", + propTypes: ["string"], + }, + { + name: "id", + description: "The unique identifier of the component", + propTypes: ["string"], + }, + { + name: "headersConfig", + description: "The headers Config", + propTypes: ["HeadersConfigProps"], + }, + + { + name: "headers", + description: "The list of headers", + propTypes: ["HeaderProps[]"], + mandatory: true, + }, + { + name: "rowsConfig", + description: "The rows Config", + propTypes: ["RowsConfigProps"], + }, + { + name: "rows", + description: "The list of rows", + propTypes: ["RowProps"], + }, + + { + name: "selectionConfig", + description: "The selection config", + propTypes: ["SelectionConfigProps"], + }, + { + name: "selection", + description: "The selected item list", + propTypes: ["String[]"], + }, + { + name: "sortIndicators", + description: "The sort Indicators", + propTypes: ["SortIndicatorsProps"], + }, + // { + // name: "actionsConfig", + // description: "The selection actions Config", + // propTypes: ["ActionsConfigProps"], + // }, + { + name: "alternatingRows", + description: "The alternating Rows", + propTypes: ["boolean"], + defaultValue: "false", + }, + { + name: "customEmptyView", + description: "The custom Empty View", + propTypes: ["React.ReactNode"], + }, + { + name: "loadState", + description: "The is Loading Data indicator", + propTypes: ["LoadingType"], + defaultValue: "success", + }, + { + name: "emptyView", + description: "The is Loading Data indicator", + propTypes: ["ErrorDisplayAttributes"], + }, + ], + }, + // { + // name: "ActionsConfigProps", + // attributes: [ + // { + // name: "showActions", + // description: "The show Actions", + // propTypes: ["boolean"], + // defaultValue: "false", + // mandatory: true, + // }, + // { + // name: "className", + // description: + // "The className of the component for custom styling.", + // propTypes: ["string"], + // }, + // { + // name: "headerClassName", + // description: + // "The header className of the component for custom styling.", + // propTypes: ["string"], + // }, + // { + // name: "headerLabel", + // description: "The header Label.", + // propTypes: ["string"], + // }, + // { + // name: "actions", + // description: "Called when a selection happen", + // propTypes: [ + // "(row: RowProps, isSelected: boolean) => ReactNode", + // ], + // }, + // { + // name: "headerWidth", + // description: "The header Width.", + // propTypes: ["string"], + // }, + // ], + // }, + { + name: "HeaderProps", + attributes: [ + { + name: "HeaderItemProps", + description: "HeaderItemProps", + propTypes: ["HeaderItemProps"], + mandatory: true, + }, + ], + }, + { + name: "HeaderItemProps", + attributes: [ + { + name: "fieldKey", + description: "field Key Id", + propTypes: ["string"], + mandatory: true, + }, + { + name: "label", + description: "The column label", + propTypes: ["string"], + mandatory: true, + }, + { + name: "clickable", + description: "The column clickable or not", + propTypes: ["boolean"], + }, + { + name: "className", + description: "The column className ", + propTypes: ["string"], + }, + { + name: "style", + description: "The column style ", + propTypes: ["CSSProperties"], + }, + ], + }, + { + name: "HeadersConfigProps", + attributes: [ + { + name: "className", + description: "The class Name Props", + propTypes: ["string"], + mandatory: true, + }, + { + name: "onClickHeader", + description: "on Click Header click", + propTypes: ["(fieldKey: string) => void"], + }, + ], + }, + { + name: "LoadingType", + attributes: [ + { + name: "The type of Loading or success or error", + description: "", + propTypes: [`"loading"`, `"success",`], + defaultValue: "success", + }, + ], + }, + { + name: "RowsConfigProps", + attributes: [ + { + name: "className", + description: "The class Name", + propTypes: ["string"], + }, + { + name: "alternatingClassName", + description: "The alternating Class Name", + propTypes: ["string"], + }, + ], + }, + { + name: "SelectionConfigProps", + attributes: [ + { + name: "showCheckboxes", + description: "The show Check boxes Prop", + propTypes: ["boolean"], + mandatory: true, + }, + { + name: "showHeaderCheckbox", + description: "The show Header Check box Prop", + propTypes: ["boolean"], + mandatory: false, + }, + { + name: "onClickSelect", + description: "on Click Select", + propTypes: [ + "(fieldKey: string, rowId: string, isSelected: boolean) => void", + ], + }, + { + name: "onClickSelectAll", + description: "on Click Select All", + propTypes: ["(isSelected: boolean) => void"], + }, + { + name: "headerClassName", + description: "The header Class Name", + propTypes: ["string"], + }, + { + name: "className", + description: "The class Name", + propTypes: ["string"], + }, + { + name: "headerWidth", + description: "The header Width", + propTypes: ["string"], + }, + ], + }, + { + name: "SortIndicatorsProps", + attributes: [ + { + name: "fieldKey", + description: "The field key", + propTypes: ["string | SortIndicatorProps"], + }, + ], + }, + { + name: "SortIndicatorProps", + attributes: [ + { + name: "asc", + description: "The asc order icon", + propTypes: ["string"], + defaultValue: "asc", + }, + { + name: "desc", + description: "The desc order icon", + propTypes: ["string"], + defaultValue: "desc", + }, + ], + }, +]; + +export const PropsTable = () => ;