├── Table.tsx ├── TableCellWithId.tsx ├── TableHeadInput.tsx ├── TableHeadSorter.tsx ├── TableSpinner.tsx ├── index.ts ├── styled └── index.tsx ├── types └── index.ts └── utils.ts /Table.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-unstable-nested-components */ 2 | /* eslint-disable react/no-array-index-key */ 3 | import type {ExpandedState} from '@tanstack/react-table'; 4 | import {flexRender, getCoreRowModel, getExpandedRowModel, useReactTable} from '@tanstack/react-table'; 5 | import React, {useState} from 'react'; 6 | import {useBottomScrollListener} from 'react-bottom-scroll-listener'; 7 | import {useVirtual} from 'react-virtual'; 8 | import {Flex} from 'reflexbox'; 9 | 10 | import { 11 | StyledChevron, 12 | StyledTable, 13 | StyledTableBody, 14 | StyledTableBodyCell, 15 | StyledTableBodyRow, 16 | StyledTableBodySubRow, 17 | StyledTableHead, 18 | StyledTableHeadCell, 19 | StyledTableWrap, 20 | } from './styled'; 21 | import {TableSpinner} from './TableSpinner'; 22 | import type {TableProps} from './types'; 23 | import {useEventListener} from '../../../react'; 24 | import {getTableCbxCellStyle, getTableCellStyle} from '../../lib'; 25 | import {Button} from '../Button'; 26 | import {Checkbox} from '../Checkbox'; 27 | import {EmptyContentPlaceholder} from '../EmptyContentPlaceholder'; 28 | import {Skeleton} from '../Skeleton'; 29 | import {Spacer} from '../Spacer'; 30 | 31 | export {type TableProps}; 32 | 33 | export {TableSpinner}; 34 | 35 | export function Table, ED extends Record>({ 36 | isLoading, 37 | isLoadingRows, 38 | isExpandable, 39 | className, 40 | loadingRows = 5, 41 | onBottom, 42 | renderExpandedRow, 43 | bottomOffset = 100, 44 | setRowSelection, 45 | rowSelection, 46 | extraData, 47 | updateExtraData = () => {}, 48 | offsetY, 49 | getRowStyles, 50 | disableOverflow, 51 | estimatedRowSize, 52 | ...props 53 | }: TableProps) { 54 | const [expanded, setExpanded] = useState({}); 55 | 56 | const {getHeaderGroups, getRowModel, getIsAllRowsSelected, getIsSomeRowsSelected, toggleAllRowsSelected} = 57 | useReactTable({ 58 | ...props, 59 | getCoreRowModel: getCoreRowModel(), 60 | getExpandedRowModel: getExpandedRowModel(), 61 | onExpandedChange: setExpanded, 62 | onRowSelectionChange: setRowSelection, 63 | state: { 64 | expanded, 65 | rowSelection, 66 | }, 67 | meta: { 68 | getExtraData: () => extraData ?? {}, 69 | updateExtraData, 70 | }, 71 | }); 72 | 73 | const handleBottom = () => { 74 | if (!isLoadingRows && !isLoading) { 75 | onBottom?.(); 76 | } 77 | }; 78 | 79 | const parentRef = useBottomScrollListener(handleBottom, { 80 | offset: bottomOffset, 81 | debounceOptions: { 82 | leading: false, 83 | }, 84 | }); 85 | 86 | useEventListener('keydown', e => { 87 | if (e.key === 'Tab' && rowSelection && setRowSelection) { 88 | e.preventDefault(); 89 | 90 | const selectionKeys = Object.entries(rowSelection) 91 | .filter(([, value]) => value) 92 | .map(([key]) => Number(key)); 93 | 94 | const start = Math.min(...selectionKeys); 95 | const end = Math.max(...selectionKeys); 96 | 97 | const newSelection = {} as Record; 98 | for (let i = start; i <= end; i += 1) { 99 | newSelection[i] = true; 100 | } 101 | 102 | setRowSelection(newSelection); 103 | } 104 | }); 105 | 106 | const isEmpty = !isLoading && getRowModel().rows.length === 0; 107 | 108 | const {virtualItems, totalSize} = useVirtual({ 109 | parentRef, 110 | size: getRowModel().rows.length, 111 | estimateSize: React.useCallback(() => estimatedRowSize, [estimatedRowSize]), 112 | }); 113 | 114 | const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0; 115 | const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0; 116 | 117 | return ( 118 | 125 | 126 | 127 | {getHeaderGroups().map(headerGroup => ( 128 | 129 | {setRowSelection && ( 130 | 131 | toggleAllRowsSelected(!!value)} 135 | /> 136 | 137 | )} 138 | 139 | {headerGroup.headers.map(header => { 140 | const {size, minSize, maxSize} = header.column.columnDef; 141 | const flexGrow = header.column.columnDef.meta?.flexGrow; 142 | 143 | return ( 144 | 145 | {flexRender(header.column.columnDef.header, header.getContext())} 146 | 147 | ); 148 | })} 149 | 150 | {isExpandable && } 151 | 152 | ))} 153 | 154 | 155 | 156 | {isEmpty && } 157 | 158 | {isLoading && 159 | new Array(loadingRows).fill(1).map((_, i) => ( 160 | 161 | {getHeaderGroups()[0].headers.map((__, cellI) => ( 162 | 163 | 164 | 165 | ))} 166 | 167 | ))} 168 | 169 |
170 | {!isLoading && 171 | virtualItems.map(virtualRow => { 172 | const row = getRowModel().rows[virtualRow.index]; 173 | 174 | return ( 175 | 176 | 177 | {setRowSelection && ( 178 | 179 | row.toggleSelected(!!value)} 183 | /> 184 | 185 | )} 186 | 187 | {row.getVisibleCells().map(cell => { 188 | const {size, maxSize, minSize} = cell.column.columnDef; 189 | const flexGrow = cell.column.columnDef.meta?.flexGrow; 190 | 191 | return ( 192 | 196 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 197 | 198 | ); 199 | })} 200 | 201 | {isExpandable && ( 202 | 203 | 213 | 214 | )} 215 | 216 | 217 | {row.getIsExpanded() && renderExpandedRow && ( 218 | <> 219 | 220 | {renderExpandedRow(row)} 221 | 222 | )} 223 | 224 | ); 225 | })} 226 |
227 | 228 | 229 |
230 |
231 |
232 | ); 233 | } 234 | -------------------------------------------------------------------------------- /TableCellWithId.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {I18n} from 'shared/i18n'; 4 | import {truncateString} from 'shared/string'; 5 | 6 | import {ButtonCopy} from '../ButtonCopy'; 7 | import {Flex} from '../Flex'; 8 | import {Spacer} from '../Spacer'; 9 | import {Typography} from '../Typography'; 10 | 11 | export const TableCellWithId = ({id, title}: {id: string; title: string}) => ( 12 | 13 | 14 | {title} 15 | 16 | 17 | 18 | {truncateString(id, 3)} 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /TableHeadInput.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ChangeEvent, useState, useEffect} from 'react'; 2 | import {useDebouncedCallback} from 'use-debounce'; 3 | 4 | import type {TableHeadInputProps} from './types'; 5 | import {NewInput} from '../NewInput'; 6 | 7 | export {type TableHeadInputProps}; 8 | 9 | export function TableHeadInput({debounceInterval = 600, value = '', onChange, ...props}: TableHeadInputProps) { 10 | const [inputValue, setInputValue] = useState(value); 11 | 12 | const onChangeDebounced = useDebouncedCallback((debounced: string) => { 13 | onChange?.(debounced); 14 | }, debounceInterval); 15 | 16 | const handleChange = (e: ChangeEvent) => { 17 | onChangeDebounced(e.target.value); 18 | setInputValue(e.target.value); 19 | }; 20 | 21 | useEffect(() => { 22 | setInputValue(value); 23 | }, [value]); 24 | 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /TableHeadSorter.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ReactNode} from 'react'; 2 | import {Flex} from 'reflexbox'; 3 | import styled from 'styled-components'; 4 | 5 | import {COLOR, TRANSITION} from '../../lib'; 6 | import {ArrowDownSvg, ArrowUpSvg, SorterSvg} from '../icons'; 7 | import {Spacer} from '../Spacer'; 8 | 9 | type TableHeadSorterProps = { 10 | text?: ReactNode; 11 | value?: boolean | null; 12 | onChange?: (value: boolean | null) => void; 13 | }; 14 | 15 | export function TableHeadSorter({text, value, onChange}: TableHeadSorterProps) { 16 | const handleClick = () => { 17 | if (value === null) { 18 | return onChange?.(true); 19 | } 20 | 21 | if (value === true) { 22 | return onChange?.(false); 23 | } 24 | 25 | onChange?.(null); 26 | }; 27 | 28 | return ( 29 | 30 | {text} 31 | 32 | 33 | 34 | {value === null && } 35 | {value === true && } 36 | {value === false && } 37 | 38 | 39 | ); 40 | } 41 | 42 | const StyledIcon = styled(Flex)` 43 | width: 16px; 44 | height: 16px; 45 | color: ${COLOR.gray[50]}; 46 | cursor: pointer; 47 | transition: color ${TRANSITION.basic}; 48 | 49 | &:hover { 50 | color: ${COLOR.gray[60]}; 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /TableSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React, {type RefObject, useEffect} from 'react'; 2 | 3 | import {StyledTableSpinner} from './styled'; 4 | import {Spinner} from '../Spinner'; 5 | 6 | export function TableSpinner({ 7 | scrollRef, 8 | isShown, 9 | }: { 10 | scrollRef: RefObject | null; 11 | isShown: boolean | undefined; 12 | }) { 13 | useEffect(() => { 14 | if (scrollRef?.current && isShown) { 15 | // eslint-disable-next-line no-param-reassign 16 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; 17 | } 18 | }, [scrollRef, isShown]); 19 | 20 | if (!isShown) return null; 21 | 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export {Table, TableSpinner, type TableProps} from './Table'; 2 | export {type TableHeadInputProps, TableHeadInput} from './TableHeadInput'; 3 | export {TableHeadSorter} from './TableHeadSorter'; 4 | export {TableCellWithId} from './TableCellWithId'; 5 | -------------------------------------------------------------------------------- /styled/index.tsx: -------------------------------------------------------------------------------- 1 | import styled, {css} from 'styled-components'; 2 | 3 | import {COLOR, FONT_SIZE, FONT_WEIGHT} from '../../../lib'; 4 | import {ChevronDownSvg} from '../../icons'; 5 | 6 | export const StyledTableWrap = styled.div<{$offsetY?: number; $disableOverflow?: boolean; $isEmptyTable?: boolean}>` 7 | overflow-x: ${({$disableOverflow}) => ($disableOverflow ? 'unset' : 'auto')}; 8 | overflow-y: ${({$disableOverflow}) => ($disableOverflow ? 'unset' : 'auto')}; 9 | border: 1px solid ${COLOR.gray[10]}; 10 | border-radius: 8px; 11 | background-color: white; 12 | ${({$offsetY, $isEmptyTable}) => 13 | typeof $offsetY !== 'undefined' 14 | ? css` 15 | height: ${() => ($isEmptyTable ? `calc(100vh - ${$offsetY}px)` : 'auto')}; 16 | max-height: calc(100vh - ${$offsetY}px); 17 | min-height: calc(100vh - ${$offsetY}px); 18 | ` 19 | : ''} 20 | `; 21 | 22 | export const StyledTable = styled.div` 23 | position: relative; 24 | width: min-content; 25 | min-width: 100%; 26 | height: 100%; 27 | display: flex; 28 | flex-direction: column; 29 | `; 30 | 31 | export const StyledTableBodyRow = styled.div` 32 | display: flex; 33 | flex-direction: column; 34 | border-bottom: 1px solid ${COLOR.gray[10]}; 35 | 36 | &:last-of-type { 37 | border-bottom-color: transparent; 38 | } 39 | `; 40 | 41 | export const StyledTableHead = styled.div` 42 | position: sticky; 43 | z-index: 1; 44 | top: 0; 45 | width: 100%; 46 | border-bottom: 1px solid ${COLOR.gray[10]}; 47 | background: ${COLOR.gray[5]}; 48 | `; 49 | 50 | export const StyledTableBody = styled.div<{isEmpty: boolean}>` 51 | ${({isEmpty}) => 52 | isEmpty && 53 | css` 54 | display: flex; 55 | justify-content: center; 56 | align-items: center; 57 | height: 100%; 58 | `} 59 | `; 60 | 61 | const CellBase = css` 62 | width: 100%; 63 | font-size: ${FONT_SIZE.m.fontSize}; 64 | line-height: ${FONT_SIZE.m.lineHeight}; 65 | `; 66 | 67 | export const StyledTableHeadCell = styled.div` 68 | display: flex; 69 | padding: 8px 16px; 70 | color: ${COLOR.gray[50]}; 71 | font-weight: ${FONT_WEIGHT.regular}; 72 | text-align: left; 73 | 74 | ${CellBase} 75 | `; 76 | 77 | export const StyledTableBodyCell = styled.div` 78 | display: flex; 79 | align-items: center; 80 | padding: 14px 16px; 81 | color: ${COLOR.gray[70]}; 82 | font-weight: ${FONT_WEIGHT.medium}; 83 | 84 | ${CellBase} 85 | `; 86 | 87 | export const StyledTableBodySubRow = styled.div` 88 | padding: 0 16px 16px; 89 | `; 90 | 91 | export const StyledTableSpinner = styled.div` 92 | display: flex; 93 | align-items: center; 94 | justify-content: center; 95 | padding: 32px 0; 96 | `; 97 | 98 | export const StyledChevron = styled(ChevronDownSvg)<{$isExpanded: boolean | undefined}>` 99 | transform: ${({$isExpanded}) => ($isExpanded ? 'rotate(-180deg)' : 'none')}; 100 | `; 101 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import type {Row, RowSelectionState, TableOptions} from '@tanstack/react-table'; 2 | import type {ReactNode, Dispatch, SetStateAction, CSSProperties} from 'react'; 3 | 4 | import type {NewInputProps} from '../../NewInput'; 5 | import type {NewSelectProps} from '../../NewSelect'; 6 | 7 | export type TableProps, ED extends Record> = Pick< 8 | TableOptions, 9 | 'columns' | 'data' 10 | > & { 11 | estimatedRowSize: number; 12 | isLoading?: boolean; 13 | isLoadingRows?: boolean; 14 | isExpandable?: boolean; 15 | loadingRows?: number; 16 | className?: string; 17 | bottomOffset?: number; 18 | 19 | rowSelection?: RowSelectionState; 20 | setRowSelection?: Dispatch>; 21 | 22 | extraData?: ED; 23 | updateExtraData?: (key: keyof ED, value: ED[keyof ED]) => void; 24 | 25 | onBottom?: () => void; 26 | renderExpandedRow?: (row: Row) => ReactNode; 27 | 28 | getRowStyles?: (row: Row) => CSSProperties | undefined; 29 | offsetY?: number; 30 | 31 | disableOverflow?: boolean; 32 | }; 33 | 34 | export type TableHeadInputProps = Omit< 35 | NewInputProps, 36 | 'onChange' | 'onChangeValue' | 'variant' | 'defaultValue' | 'size' 37 | > & { 38 | onChange?: (value: string) => void; 39 | debounceInterval?: number; 40 | }; 41 | 42 | export type TableHeadSelectProps = Omit, 'variant' | 'size'> & { 43 | debounceInterval?: number; 44 | }; 45 | 46 | export type TableHeadAsyncSelectProps = Omit, 'variant' | 'size'> & { 47 | debounceInterval?: number; 48 | }; 49 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import type {Table} from '@tanstack/react-table'; 2 | import type {CSSProperties} from 'styled-components'; 3 | 4 | export const getTableCellStyle = ({ 5 | size, 6 | minSize, 7 | maxSize, 8 | flexGrow = 1, 9 | }: { 10 | size?: number | string; 11 | minSize?: number | string; 12 | maxSize?: number | string; 13 | flexGrow?: number; 14 | }): CSSProperties => ({ 15 | width: size, 16 | maxWidth: maxSize, 17 | minWidth: minSize, 18 | flexGrow, 19 | }); 20 | 21 | export const getTableCbxCellStyle = (): CSSProperties => ({ 22 | flex: '40px 0 auto', 23 | minWidth: 0, 24 | width: 40, 25 | }); 26 | 27 | export const getTableExtraDataModel = >(table: Table, name: string) => ({ 28 | value: table.options.meta?.getExtraData()[name], 29 | onChange: (value: V) => { 30 | table.options.meta?.updateExtraData(name, value); 31 | }, 32 | }); 33 | --------------------------------------------------------------------------------