@utahdts/utah-design-system
Version:
Utah Design System React Library
221 lines (203 loc) • 9.12 kB
JSX
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>
);
}