UNPKG

@utahdts/utah-design-system

Version:
221 lines (203 loc) 9.12 kB
import { useEffect, useId, useMemo, useRef, } from 'react'; import { useImmer } from 'use-immer'; import { useAriaMessaging } from '../../contexts/UtahDesignSystemContext/hooks/useAriaMessaging'; import { tableSortingRuleFieldType } from '../../enums/tableSortingRuleFieldType'; import { useRefAlways } from '../../hooks/useRefAlways'; import { joinClassNames } from '../../util/joinClassNames'; import { valueAtPath } from '../../util/state/valueAtPath'; import { TableContext } from './util/TableContext'; /** * @template TableSortingRuleT * @typedef {import('@utahdts/utah-design-system').TableSortingRuleType<TableSortingRuleT>} TableSortingRuleType */ /** * @template TableContextStateT * @typedef {import('@utahdts/utah-design-system').TableContextState<TableContextStateT>} TableContextState */ /** * @template TableContextStateT * @typedef {import('@utahdts/utah-design-system').TableContextValue<TableContextStateT>} TableContextValue */ /** * @template SortByFieldTypeDataT * @param {TableSortingRuleType<SortByFieldTypeDataT>} sortingRule * @param {any} fieldValueA * @param {any} fieldValueB * @returns {number} */ function sortByFieldType(sortingRule, fieldValueA, fieldValueB) { /** @type {number} */ let result; switch (sortingRule.fieldType) { case tableSortingRuleFieldType.DATE: result = (fieldValueA?.getTime() || 0) - (fieldValueB?.getTime() || 0); break; case tableSortingRuleFieldType.NUMBER: result = Number(fieldValueA || 0) - Number(fieldValueB || 0); break; case tableSortingRuleFieldType.STRING: result = (fieldValueA || '').localeCompare(fieldValueB || ''); break; default: throw new Error(`Unknown tableSortingRuleFieldType '${sortingRule.fieldType}'`); } return result; } /** * @template TableDataT extends TableDataT & { [x: string]: any; } * @param {object} props * @param {import('react').ReactNode} props.children * @param {string} [props.className] * @param {import('react').RefObject<HTMLDivElement>} [props.innerRef] * @param {string} [props.id] * @returns {import('react').JSX.Element} */ export function TableWrapper({ children, className, id, innerRef, ...rest }) { const internalId = useId(); /** @type {[TableContextState<TableDataT>, import('use-immer').Updater<import('@utahdts/utah-design-system').TableContextState<TableDataT>>]} */ const [state, setState] = useImmer( /** @returns {TableContextState<TableDataT>} */ () => ({ // when sorting, should the sort order for a rule be the "default" // ie a rule defaults to ascending so when currentSortingOrderIsDefault is true then sort that rule ascending currentSortingOrderIsDefault: true, // [recordFieldPath]: filterValue <== the current filtering values from <TableFilter... /> components filterValues: { // context level values from a <TableFilters /> component (<TableFilter... /> child components would override/chain these values) defaultValue: null, onChange: null, value: {}, }, // these are the sorting rules to which a <TableHeadCell> connects assumes order is add order sortingRules: {}, tableData: { allData: [], filteredData: [] }, tableId: id ?? internalId, // (func) when table sorting changes, this callback will be called: from <TableSortingRules> tableSortingOnChange: null, // (string | [string]) the current recordFieldPath name for the current header being sorted // array if <TableHeadCell> specifies sort order; otherwise, sort fields in registration order // set when a TableHeadCell is selected and sets its tableSortingFieldPaths as the tableSortingFieldPath // TableBodyData uses this value to sort its records tableSortingFieldPath: null, // a TableHeadCell can provide tableSortingFieldPaths to customize which sorters to use in which order tableSortingFieldPaths: null, }) ); const stateRef = useRefAlways(state); const tableSortingFieldPathOldRef = useRef(state.tableSortingFieldPath); const tableSortingFieldPathsOldRef = useRef(state.tableSortingFieldPaths); const isAscendingOldRef = useRef(state.currentSortingOrderIsDefault); const { addPoliteMessage } = useAriaMessaging(); useEffect( () => { if ( // do not send notification when first loading the table when the currentPath is null tableSortingFieldPathOldRef.current && state.tableSortingFieldPath // subsequent changes to sorting probably should have been triggered by the user and therefore needs announced && ( tableSortingFieldPathOldRef.current !== state.tableSortingFieldPath || tableSortingFieldPathsOldRef.current !== state.tableSortingFieldPaths || state.currentSortingOrderIsDefault !== isAscendingOldRef.current ) ) { const sortingFields = state.tableSortingFieldPaths || [state.tableSortingFieldPath]; const sortingRules = sortingFields.map((sortingField) => state.sortingRules[sortingField]); const sortingRulesMessages = sortingRules.map((sortingRule) => { const isAscending = (!!sortingRule?.defaultIsAscending === !!state.currentSortingOrderIsDefault); return `${sortingRule?.a11yLabel ?? ''} ${isAscending ? 'ascending' : 'descending'}`; }); addPoliteMessage(`Sorting changed to ${sortingRulesMessages.join(', ')}`); state.tableSortingOnChange?.({ recordFieldPath: state.tableSortingFieldPath }); } isAscendingOldRef.current = state.currentSortingOrderIsDefault; tableSortingFieldPathOldRef.current = state.tableSortingFieldPath; tableSortingFieldPathsOldRef.current = state.tableSortingFieldPaths; }, [addPoliteMessage, state] ); const contextValue = useMemo( () => /** @type {TableContextValue<TableDataT>} */({ // for analytic usage, rendering is generally done at the component level and not at the context level // because each data section handles it differently. This allData is useful for filtering and other // global table tooling that pokes through the data. allData: stateRef.current.tableData.allData, filteredData: stateRef.current.tableData.filteredData, // register a new rule for sorting, generally from a <TableSortingRule> registerSortingRule: (sortingRule) => setState((draftState) => { draftState.sortingRules[sortingRule.recordFieldPath] = { ...sortingRule, sorter: ( /** * * @param {{ record: TableDataT, recordIndex: number }} recordA * @param {{ record: TableDataT, recordIndex: number }} recordB * @param {TableDataT[]} records * @returns {number} */ (recordA, recordB, records) => { const fieldValueA = valueAtPath({ object: recordA.record, path: sortingRule.recordFieldPath }); const fieldValueB = valueAtPath({ object: recordB.record, path: sortingRule.recordFieldPath }); let result; if (sortingRule.customSort) { // custom sorting result = sortingRule.customSort({ fieldValueA, fieldValueB, recordA: recordA.record, recordAIndex: recordA.recordIndex, recordB: recordB.record, recordBIndex: recordB.recordIndex, records, }); } else { // sort by field type result = sortByFieldType(sortingRule, fieldValueA, fieldValueB); } // return sort result modified for sort order return result * (stateRef.current.currentSortingOrderIsDefault ? 1 : -1) * (sortingRule.defaultIsAscending ? 1 : -1); } ), }; }), // unregister a rule for sorting, generally when a <TableSortingRule> unmounts unregisterSortingRule: (recordFieldPath) => setState((draftState) => { delete draftState.sortingRules[recordFieldPath]; }), /** * data recording per table body section so as to form a full picture of the currently exposed data * @param {TableDataT[] | null} allData the data for this component (or null on unmount) * @param {TableDataT[] | null} [filteredData] the filtered data for this component (optional, defaults to []) */ setBodyData: (allData, filteredData) => { setState((draftState) => { draftState.tableData = { // @ts-expect-error allData: allData ?? [], // @ts-expect-error filteredData: filteredData || [], }; }); }, setState, state, }), [setState, state, stateRef] ); return ( <TableContext.Provider value={contextValue}> <div className={joinClassNames('table__wrapper', className)} id={id} ref={innerRef} {...rest}> {children} </div> </TableContext.Provider> ); }