import React, { useContext, useEffect, useRef, useState } from 'react'; import classnames from 'classnames'; import { v4 } from 'uuid'; import styles from './styles.module.scss'; import { Icons } from '..'; const TABLE_HEADER = 'TABLE_HEADER'; const TABLE_ROW = 'TABLE_ROW'; const ALIGN_TO_JUSTIFY = { 'left': 'flex-start', 'center': 'center', 'right': 'flex-end' }; const SORT_INDICATORS = { 'asc':
, 'desc':
, 'none': null, }; export type SortDirection = 'asc' | 'desc' | 'none'; export type SortRule = { column: string | null direction: SortDirection } type ListViewContextType = { mode: typeof TABLE_ROW | typeof TABLE_HEADER height: React.CSSProperties['minHeight'] fontSize: React.CSSProperties['fontSize'] rowHeaderWidth: number item?: any sort?: SortRule[] onClickHeader?: (column: C, value: any) => void } // In practice, this should not be used, but a React.Context requires a default value const DEFAULT_LIST_VIEW_CONTEXT: ListViewContextType = { mode: 'TABLE_ROW' as const, height: 0, fontSize: 0, rowHeaderWidth: 0, } const ListViewContext = React.createContext(DEFAULT_LIST_VIEW_CONTEXT); export type ListViewProps = { data: T[] sort?: SortRule[] onClickHeader?: (column: C, sortTemplate: (item: T) => SortableValue) => void onClickRow?: (item: T) => void keyTemplate?: (item: T) => C showHeaders?: boolean fixedWidth?: boolean padOuterColumns?: boolean scrollX?: boolean rowHeight?: React.CSSProperties['minHeight'] headerHeight?: React.CSSProperties['minHeight'] fontSize?: React.CSSProperties['fontSize'] headerFontSize?: React.CSSProperties['fontSize'] // HACK: this is needed because we're reading `props` out of children, // which isn't defined on all React.ReactNode types (such as string or number) children: JSX.Element | JSX.Element[] // children: React.ReactNode } interface ObjectWithID { id: string | number } function isAnObjectWithID(thing: unknown): thing is ObjectWithID { if (thing instanceof Object) return thing.hasOwnProperty('id'); return false; } function defaultKeyTemplate(item: T) { if (isAnObjectWithID(item)) { return item.id; } else { return v4(); } } /** * `T` is the data type of each Row, * `C` is the column id type (probably a union of string literals) */ export default function ListView(props: ListViewProps) { const { data = [], sort = [], onClickHeader, onClickRow, keyTemplate = defaultKeyTemplate, showHeaders = true, fixedWidth = true, padOuterColumns = false, scrollX = false, rowHeight, headerHeight, fontSize, headerFontSize, children, } = props; // Handle scrolling with state, refs, and a listener const containerRef = useRef(null); const tableRef = useRef(null); const [tableShadows, setTableShadows] = useState({left: false, right: false}); useEffect(() => { if (!containerRef.current) return; function shadowHandler() { if (!(containerRef.current && tableRef.current)) return; if (scrollX) { const container = containerRef.current.getBoundingClientRect(); const table = tableRef.current.getBoundingClientRect(); const leftShadow = table.left < container.left; const rightShadow = table.right > container.right; if (tableShadows.left !== leftShadow || tableShadows.right !== rightShadow) { setTableShadows({left: leftShadow, right: rightShadow}); } } } function cleanup() { window.removeEventListener('resize', shadowHandler); if (!containerRef.current) return; containerRef.current.removeEventListener('scroll', shadowHandler); } window.addEventListener('resize', shadowHandler); containerRef.current.addEventListener('scroll', shadowHandler); shadowHandler(); return cleanup; }); // Compute total width of "row headers" const rowHeaderWidth = React.Children.toArray(children).reduce((a, n) => { a += (n as React.ReactElement).props.isRowHeader ? (n as React.ReactElement).props.width : 0; return a; }, 0) as number; const headerContextValue: ListViewContextType = { mode: TABLE_HEADER, height: headerHeight, fontSize: headerFontSize, rowHeaderWidth, sort, onClickHeader, } return (
{showHeaders ? ( {children} ) : null} {data.map(item => ( onClickRow(item) : undefined} > {children} ))}
); } type ListViewColumnProps = { id: string title?: React.ReactNode template?: (item: T) => React.ReactNode valueTemplate?: (item: T) => SortableValue onClick?: (item: T) => void disabled?: (item: T) => boolean isRowHeader?: boolean width?: React.CSSProperties['width'] minWidth?: React.CSSProperties['minWidth'] align?: keyof typeof ALIGN_TO_JUSTIFY } /** * `T` is the data type of each Row, */ export function ListViewColumn(props: ListViewColumnProps) { const { id, title = null, template = () => null, valueTemplate, onClick, disabled = () => false, isRowHeader = false, width = 'auto', minWidth = 'auto', align = 'left' } = props; const { mode, height, fontSize, rowHeaderWidth, item, sort = [], onClickHeader, } = useContext(ListViewContext); const headerClickable = Boolean(onClickHeader); const cellClickable = item && !disabled(item) && Boolean(onClick); if (mode === TABLE_HEADER) { const sortRuleIndex = sort.findIndex(x => x.column === id); const sortRule = sortRuleIndex > -1 ? sort[sortRuleIndex] : null; const sortIndicator = sortRule && ['asc', 'desc'].indexOf(sortRule.direction) > -1 ? SORT_INDICATORS[sortRule.direction] : null; return (
onClickHeader(id, valueTemplate || template) : undefined} className={classnames(styles.listViewHeader, { [styles.clickable]: headerClickable })} style={{minHeight: height, fontSize, justifyContent: ALIGN_TO_JUSTIFY[align]}} > {title !== null ? title : id} {sortIndicator}
); } else { const contents = (
onClick(item) : undefined} className={classnames(styles.listViewCell, { [styles.clickable]: cellClickable })} style={{minHeight: height, fontSize, justifyContent: ALIGN_TO_JUSTIFY[align]}} > {Boolean(template) && template(item)}
); return isRowHeader ? ( {contents} ) : ( {contents} ); } } export const ListViewColumnSpacer: React.FunctionComponent<{id?: string}> = ({id}) => { return ; } export const ListViewClickableLink: React.FunctionComponent<{ onClick?: (evt: React.MouseEvent) => void }> = ({ onClick, children, }) => { return ( {children} ); } export type SortableValue = number | string | boolean | null | undefined; // Helper functions export function getDefaultSortFunction(sortTemplate: (item: T) => SortableValue, sortDirection: SortDirection, nullsLast = true) { return function(a: T, b: T) { // Short circuit and return initial order if sorting is toggled off if (sortDirection !== 'asc' && sortDirection !== 'desc') { return 1; } // Pass each item through the sortTemplate function const aValue = sortTemplate(a), bValue = sortTemplate(b); // Invert sorting if mode is descending const sortMultiplier = sortDirection === 'desc' ? -1 : 1; // Use coercion trick to check for either null or undefined if (aValue == null && bValue == null) { return 0; } else if (aValue == null) { return nullsLast ? -1 : 1; } else if (bValue == null) { return nullsLast ? 1 : -1; } else if (typeof aValue === 'number' && typeof bValue === 'number') { return (aValue > bValue ? 1 : aValue < bValue ? -1 : 0) * sortMultiplier; } else { return String(aValue).localeCompare(String(bValue)) * sortMultiplier; } } } export function getNextSortDirection(sortDirection: SortDirection, reverse = false) { const nextMap = { 'asc': 'desc', 'desc': 'none', 'none': 'asc' }; return reverse ? nextMap[nextMap[sortDirection]] : nextMap[sortDirection] as SortDirection; };