first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {TableDataItem} from '@common/ui/tables/types/table-data-item';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import React, {useContext} from 'react';
import {Checkbox} from '@common/ui/forms/toggle/checkbox';
import {useTrans} from '@common/i18n/use-trans';
import {TableContext} from '@common/ui/tables/table-context';
export const CheckboxColumnConfig: ColumnConfig<TableDataItem> = {
key: 'checkbox',
header: () => <SelectAllCheckbox />,
align: 'center',
width: 'w-24 flex-shrink-0',
body: (item, row) => {
if (row.isPlaceholder) {
return <Skeleton size="w-24 h-24" variant="rect" />;
}
return <SelectRowCheckbox item={item} />;
},
};
interface SelectRowCheckboxProps {
item: TableDataItem;
}
function SelectRowCheckbox({item}: SelectRowCheckboxProps) {
const {selectedRows, toggleRow} = useContext(TableContext);
return (
<Checkbox
checked={selectedRows.includes(item.id)}
onChange={() => toggleRow(item)}
/>
);
}
function SelectAllCheckbox() {
const {trans} = useTrans();
const {data, selectedRows, onSelectionChange} = useContext(TableContext);
const allRowsSelected = !!data.length && data.length === selectedRows.length;
const someRowsSelected = !allRowsSelected && !!selectedRows.length;
return (
<Checkbox
aria-label={trans({message: 'Select all'})}
isIndeterminate={someRowsSelected}
checked={allRowsSelected}
onChange={() => {
if (allRowsSelected) {
onSelectionChange([]);
} else {
onSelectionChange(data.map(d => d.id));
}
}}
/>
);
}

View File

@@ -0,0 +1,122 @@
import {useContext, useState} from 'react';
import clsx from 'clsx';
import {AnimatePresence, m} from 'framer-motion';
import {TableContext} from './table-context';
import {SortDescriptor} from './types/sort-descriptor';
import {ArrowDownwardIcon} from '../../icons/material/ArrowDownward';
import {useTableCellStyle} from '@common/ui/tables/style/use-table-cell-style';
interface HeaderCellProps {
index: number;
}
export function HeaderCell({index}: HeaderCellProps) {
const {columns, sortDescriptor, onSortChange, enableSorting} =
useContext(TableContext);
const column = columns[index];
const style = useTableCellStyle({
index: index,
isHeader: true,
});
const [isHovered, setIsHovered] = useState(false);
const sortingKey = column.sortingKey || column.key;
const allowSorting = column.allowsSorting && enableSorting;
const {orderBy, orderDir} = sortDescriptor || {};
const sortActive = allowSorting && orderBy === sortingKey;
let ariaSort: 'ascending' | 'descending' | 'none' | undefined;
if (sortActive && orderDir === 'asc') {
ariaSort = 'ascending';
} else if (sortActive && orderDir === 'desc') {
ariaSort = 'descending';
} else if (allowSorting) {
ariaSort = 'none';
}
const toggleSorting = () => {
if (!allowSorting) return;
let newSort: SortDescriptor;
// if this col was sorted desc, go to asc
if (sortActive && orderDir === 'desc') {
newSort = {orderDir: 'asc', orderBy: sortingKey};
// if this col was sorted asc, clear sort
} else if (sortActive && orderDir === 'asc') {
newSort = {orderBy: undefined, orderDir: undefined};
// if sort was on another col, or no sort was applied yet, start from desc
} else {
newSort = {orderDir: 'desc', orderBy: sortingKey};
}
onSortChange?.(newSort);
};
const sortVisible = sortActive || isHovered;
const sortVariants = {
visible: {opacity: 1, y: 0},
hidden: {opacity: 0, y: '-25%'},
};
return (
<div
role="columnheader"
tabIndex={-1}
aria-colindex={index + 1}
aria-sort={ariaSort}
className={clsx(
style,
'text-xs font-medium text-muted',
allowSorting && 'cursor-pointer',
)}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
onKeyDown={e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggleSorting();
}
}}
onClick={toggleSorting}
>
{column.hideHeader ? (
<div className="opacity-0">{column.header()}</div>
) : (
column.header()
)}
<AnimatePresence>
{allowSorting && (
<m.span
variants={sortVariants}
animate={sortVisible ? 'visible' : 'hidden'}
initial={false}
transition={{type: 'tween'}}
key="sort-icon"
className="-mt-2 ml-6 inline-block"
data-testid="table-sort-button"
aria-hidden={!sortVisible}
>
<ArrowDownwardIcon
size="xs"
className={clsx(
'text-muted',
orderDir === 'asc' &&
orderBy === sortingKey &&
'rotate-180 transition-transform',
)}
/>
</m.span>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React, {KeyboardEventHandler} from 'react';
import {getFocusableTreeWalker} from '@react-aria/focus';
import {focusWithoutScrolling} from '@react-aria/utils';
import {isCtrlKeyPressed} from '../../utils/keybinds/is-ctrl-key-pressed';
interface Props {
cellCount: number;
rowCount: number;
}
export function useGridNavigation(props: Props) {
const {cellCount, rowCount} = props;
const onKeyDown: KeyboardEventHandler<HTMLElement> = e => {
switch (e.key) {
case 'ArrowLeft':
focusSiblingCell(e, {cell: {op: 'decrement'}}, props);
break;
case 'ArrowRight':
focusSiblingCell(e, {cell: {op: 'increment'}}, props);
break;
case 'ArrowUp':
focusSiblingCell(e, {row: {op: 'decrement'}}, props);
break;
case 'ArrowDown':
focusSiblingCell(e, {row: {op: 'increment'}}, props);
break;
case 'PageUp':
focusSiblingCell(e, {row: {op: 'decrement', count: 5}}, props);
break;
case 'PageDown':
focusSiblingCell(e, {row: {op: 'increment', count: 5}}, props);
break;
case 'Tab':
focusFirstElementAfterGrid(e);
break;
case 'Home':
if (isCtrlKeyPressed(e)) {
// move to first cell in first row
focusSiblingCell(
e,
{
row: {op: 'decrement', count: rowCount},
cell: {op: 'decrement', count: cellCount},
},
props
);
} else {
// move to first cell in current row
focusSiblingCell(
e,
{cell: {op: 'decrement', count: cellCount}},
props
);
}
break;
case 'End':
if (isCtrlKeyPressed(e)) {
// move to last cell in last row
focusSiblingCell(
e,
{
row: {op: 'increment', count: rowCount},
cell: {op: 'increment', count: cellCount},
},
props
);
} else {
// move to last cell in current row
focusSiblingCell(
e,
{cell: {op: 'increment', count: cellCount}},
props
);
}
break;
}
};
return {onKeyDown};
}
interface Operations {
cell?: {
op: 'increment' | 'decrement';
count?: number;
};
row?: {
op: 'increment' | 'decrement';
count?: number;
};
}
function focusSiblingCell(
e: React.KeyboardEvent,
operations: Operations,
{cellCount, rowCount}: Props
) {
if (document.activeElement?.tagName === 'input') return;
e.preventDefault();
const grid = e.currentTarget as HTMLElement;
// focused element might be inside the cell and not the cell itself
const currentCell = (e.target as HTMLElement).closest('[aria-colindex]');
if (!currentCell || !grid) return;
const row = currentCell.closest('[aria-rowindex]');
if (!row) return;
// grab row and cell index from aria attributes
let rowIndex = parseInt(row.getAttribute('aria-rowindex') as string);
let cellIndex = parseInt(currentCell.getAttribute('aria-colindex') as string);
if (Number.isNaN(rowIndex) || Number.isNaN(cellIndex)) return;
// adjust row index for next cell selector
const rowOpCount = operations.row?.count ?? 1;
if (operations.row?.op === 'increment') {
rowIndex = Math.min(rowCount, rowIndex + rowOpCount);
} else if (operations.row?.op === 'decrement') {
rowIndex = Math.max(1, rowIndex - rowOpCount);
}
// adjust cell index for next cell selector
const cellOpCount = operations.cell?.count ?? 1;
if (operations.cell?.op === 'increment') {
cellIndex = Math.min(cellCount, cellIndex + cellOpCount);
} else if (operations.cell?.op === 'decrement') {
cellIndex = Math.max(1, cellIndex - cellOpCount);
}
// find the next cell that should be focused
const nextCell = grid.querySelector(
`[aria-rowindex="${rowIndex}"] [aria-colindex="${cellIndex}"]`
) as HTMLElement | undefined;
if (!nextCell) return;
// find any focusable elements inside the cell
const walker = getFocusableTreeWalker(nextCell);
const nextFocusableElement = (walker.nextNode() || nextCell) as HTMLElement;
// adjust tab index on current and next cells and focus either next cell or first focusable element inside that cell
currentCell.setAttribute('tabindex', '-1');
nextFocusableElement.setAttribute('tabindex', '0');
nextFocusableElement.focus();
}
// grid is treated as a single tab stop, focus first element after grid, instead of moving focus withing grid on tab press
function focusFirstElementAfterGrid(e: React.KeyboardEvent) {
const grid = e.currentTarget as HTMLElement;
if (e.shiftKey) {
grid.focus();
} else {
const walker = getFocusableTreeWalker(grid, {tabbable: true});
let next: HTMLElement;
let last: HTMLElement;
do {
last = walker.lastChild() as HTMLElement;
if (last) {
next = last;
}
} while (last);
// @ts-ignore
if (next && !next.contains(document.activeElement)) {
focusWithoutScrolling(next);
}
}
}

View File

@@ -0,0 +1,36 @@
import clsx from 'clsx';
import {useContext} from 'react';
import {TableContext} from '@common/ui/tables/table-context';
interface Props {
index: number;
isHeader: boolean;
}
export function useTableCellStyle({index, isHeader}: Props) {
const {
columns,
cellHeight = 'h-46',
headerCellHeight = 'h-46',
} = useContext(TableContext);
const column = columns[index];
const userPadding = column?.padding;
let justify = 'justify-start';
if (column?.align === 'center') {
justify = 'justify-center';
} else if (column?.align === 'end') {
justify = 'justify-end';
}
return clsx(
'flex items-center overflow-hidden whitespace-nowrap overflow-ellipsis outline-none focus-visible:outline focus-visible:outline-offset-2',
isHeader ? headerCellHeight : cellHeight,
column?.width ?? 'flex-1',
column?.maxWidth,
column?.minWidth,
justify,
userPadding,
column?.className
);
}

View File

@@ -0,0 +1,37 @@
import clsx from 'clsx';
import {useContext} from 'react';
import {TableContext} from '@common/ui/tables/table-context';
import {useIsDarkMode} from '@common/ui/themes/use-is-dark-mode';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
interface Props {
index: number;
isSelected: boolean;
isHeader?: boolean;
}
export function useTableRowStyle({index, isSelected, isHeader}: Props) {
const isDarkMode = useIsDarkMode();
const isMobile = useIsMobileMediaQuery();
const {hideBorder, enableSelection, collapseOnMobile, onAction} =
useContext(TableContext);
const isFirst = index === 0;
return clsx(
'flex gap-x-16 break-inside-avoid outline-none border border-transparent',
onAction && 'cursor-pointer',
isMobile && collapseOnMobile && hideBorder
? 'mb-8 pl-8 pr-0 rounded'
: 'px-16',
!hideBorder && 'border-b-divider',
!hideBorder && isFirst && 'border-t-divider',
isSelected &&
!isDarkMode &&
'bg-primary/selected hover:bg-primary/focus focus-visible:bg-primary/focus',
isSelected &&
isDarkMode &&
'bg-selected hover:bg-focus focus-visible:bg-focus',
!isSelected &&
!isHeader &&
(enableSelection || onAction) &&
'focus-visible:bg-focus hover:bg-hover'
);
}

View File

@@ -0,0 +1,50 @@
import {useContext, useMemo} from 'react';
import {TableContext} from './table-context';
import {TableDataItem} from './types/table-data-item';
import {RowContext} from '@common/datatable/column-config';
import {useTableCellStyle} from '@common/ui/tables/style/use-table-cell-style';
interface TableCellProps {
rowIsHovered: boolean;
rowIndex: number;
index: number;
item: TableDataItem;
id?: string;
}
export function TableCell({
rowIndex,
rowIsHovered,
index,
item,
id,
}: TableCellProps) {
const {columns} = useContext(TableContext);
const column = columns[index];
const rowContext: RowContext = useMemo(() => {
return {
index: rowIndex,
isHovered: rowIsHovered,
isPlaceholder: item.isPlaceholder,
};
}, [rowIndex, rowIsHovered, item.isPlaceholder]);
const style = useTableCellStyle({
index: index,
isHeader: false,
});
return (
<div
tabIndex={-1}
role="gridcell"
aria-colindex={index + 1}
id={id}
className={style}
>
<div className="overflow-x-hidden overflow-ellipsis min-w-0 w-full">
{column.body(item, rowContext)}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import {createContext} from 'react';
import type {SortDescriptor} from './types/sort-descriptor';
import type {TableProps} from './table';
import type {ColumnConfig} from '../../datatable/column-config';
import type {TableDataItem} from './types/table-data-item';
export type TableSelectionStyle = 'checkbox' | 'highlight';
export interface TableContextValue<T extends TableDataItem = TableDataItem> {
isCollapsedMode: boolean;
selectedRows: (string | number)[];
onSelectionChange: (keys: (string | number)[]) => void;
sortDescriptor?: SortDescriptor;
onSortChange?: (descriptor: SortDescriptor) => any;
enableSelection?: boolean;
enableSorting?: boolean;
selectionStyle: TableSelectionStyle;
data: T[];
meta?: any;
columns: ColumnConfig<T>[];
toggleRow: (item: T) => void;
selectRow: (item: T | null, merge?: boolean) => void;
hideBorder: boolean;
hideHeaderRow: boolean;
collapseOnMobile: boolean;
onAction: TableProps<T>['onAction'];
selectRowOnContextMenu: TableProps<T>['selectRowOnContextMenu'];
cellHeight: string | undefined;
headerCellHeight: string | undefined;
}
export const TableContext = createContext<TableContextValue>(null!);

View File

@@ -0,0 +1,19 @@
import {HeaderCell} from '@common/ui/tables/header-cell';
import React, {useContext} from 'react';
import {TableContext} from '@common/ui/tables/table-context';
export function TableHeaderRow() {
const {columns} = useContext(TableContext);
return (
<div
role="row"
aria-rowindex={1}
tabIndex={-1}
className="flex gap-x-16 px-16"
>
{columns.map((column, columnIndex) => (
<HeaderCell index={columnIndex} key={column.key} />
))}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React, {
ComponentPropsWithoutRef,
JSXElementConstructor,
KeyboardEventHandler,
MouseEventHandler,
useContext,
useRef,
useState,
} from 'react';
import {TableContext} from './table-context';
import {TableCell} from './table-cell';
import {TableDataItem} from './types/table-data-item';
import {createEventHandler} from '../../utils/dom/create-event-handler';
import {usePointerEvents} from '../interactions/use-pointer-events';
import {isCtrlOrShiftPressed} from '../../utils/keybinds/is-ctrl-or-shift-pressed';
import {useTableRowStyle} from '@common/ui/tables/style/use-table-row-style';
import clsx from 'clsx';
const interactableElements = ['button', 'a', 'input', 'select', 'textarea'];
export interface RowElementProps<T = TableDataItem>
extends ComponentPropsWithoutRef<'tr'> {
item: T & {isPlaceholder?: boolean};
}
interface TableRowProps {
item: TableDataItem;
index: number;
renderAs?: JSXElementConstructor<RowElementProps>;
className?: string;
style?: React.CSSProperties;
}
export function TableRow({
item,
index,
renderAs,
className,
style,
}: TableRowProps) {
const {
selectedRows,
columns,
toggleRow,
selectRow,
onAction,
selectRowOnContextMenu,
enableSelection,
selectionStyle,
hideHeaderRow,
} = useContext(TableContext);
const isTouchDevice = useRef(false);
const isSelected = selectedRows.includes(item.id);
const [isHovered, setIsHovered] = useState(false);
const clickedOnInteractable = (e: React.MouseEvent | PointerEvent) => {
return (e.target as HTMLElement).closest(interactableElements.join(','));
};
const doubleClickHandler: MouseEventHandler<HTMLDivElement> = e => {
if (
selectionStyle === 'highlight' &&
onAction &&
!isTouchDevice.current &&
!clickedOnInteractable(e)
) {
e.preventDefault();
e.stopPropagation();
onAction(item, index);
}
};
const anyRowsSelected = !!selectedRows.length;
const handleRowTap = (e: PointerEvent) => {
if (clickedOnInteractable(e)) return;
if (selectionStyle === 'checkbox') {
if (enableSelection && (anyRowsSelected || !onAction)) {
toggleRow(item);
} else if (onAction) {
onAction(item, index);
}
} else if (selectionStyle === 'highlight') {
if (isTouchDevice.current) {
if (enableSelection && anyRowsSelected) {
toggleRow(item);
} else {
onAction?.(item, index);
}
} else if (enableSelection) {
selectRow(item, isCtrlOrShiftPressed(e));
}
}
};
const {domProps} = usePointerEvents({
onPointerDown: e => {
isTouchDevice.current = e.pointerType === 'touch';
},
onPress: handleRowTap,
onLongPress: enableSelection
? () => {
if (isTouchDevice.current) {
toggleRow(item);
}
}
: undefined,
});
const keyboardHandler: KeyboardEventHandler = e => {
if (enableSelection && e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (selectionStyle === 'checkbox') {
toggleRow(item);
} else {
selectRow(item);
}
} else if (e.key === 'Enter' && !selectedRows.length && onAction) {
e.preventDefault();
e.stopPropagation();
onAction(item, index);
}
};
const contextMenuHandler: MouseEventHandler = e => {
if (selectRowOnContextMenu && enableSelection) {
if (!selectedRows.includes(item.id)) {
selectRow(item);
}
}
// prevent context menu on mobile
if (isTouchDevice.current) {
e.preventDefault();
e.stopPropagation();
}
};
const styleClassName = useTableRowStyle({index, isSelected});
const RowElement = renderAs || 'div';
return (
<RowElement
role="row"
aria-rowindex={index + 1 + (hideHeaderRow ? 0 : 1)}
aria-selected={isSelected}
tabIndex={-1}
className={clsx(className, styleClassName)}
item={RowElement === 'div' ? (undefined as any) : item}
onDoubleClick={createEventHandler(doubleClickHandler)}
onKeyDown={createEventHandler(keyboardHandler)}
onContextMenu={createEventHandler(contextMenuHandler)}
onPointerEnter={createEventHandler(() => setIsHovered(true))}
onPointerLeave={createEventHandler(() => setIsHovered(false))}
style={style}
{...domProps}
>
{columns.map((column, cellIndex) => (
<TableCell
rowIndex={index}
rowIsHovered={isHovered}
index={cellIndex}
item={item}
key={`${item.id}-${column.key}`}
/>
))}
</RowElement>
);
}

View File

@@ -0,0 +1,275 @@
import React, {
cloneElement,
ComponentPropsWithoutRef,
Fragment,
JSXElementConstructor,
MutableRefObject,
ReactElement,
useCallback,
useContext,
useMemo,
} from 'react';
import {useControlledState} from '@react-stately/utils';
import {SortDescriptor} from './types/sort-descriptor';
import {useGridNavigation} from './navigate-grid';
import {RowElementProps, TableRow} from './table-row';
import {
TableContext,
TableContextValue,
TableSelectionStyle,
} from './table-context';
import {ColumnConfig} from '../../datatable/column-config';
import {TableDataItem} from './types/table-data-item';
import clsx from 'clsx';
import {useInteractOutside} from '@react-aria/interactions';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {isCtrlKeyPressed} from '@common/utils/keybinds/is-ctrl-key-pressed';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {CheckboxColumnConfig} from '@common/ui/tables/checkbox-column-config';
import {TableHeaderRow} from '@common/ui/tables/table-header-row';
export interface TableProps<T extends TableDataItem>
extends ComponentPropsWithoutRef<'table'> {
className?: string;
columns: ColumnConfig<T>[];
hideHeaderRow?: boolean;
data: T[];
meta?: any;
tableRef?: MutableRefObject<HTMLTableElement>;
selectedRows?: (number | string)[];
defaultSelectedRows?: (number | string)[];
onSelectionChange?: (keys: (number | string)[]) => void;
sortDescriptor?: SortDescriptor;
onSortChange?: (descriptor: SortDescriptor) => any;
enableSorting?: boolean;
onDelete?: (items: T[]) => void;
enableSelection?: boolean;
selectionStyle?: TableSelectionStyle;
ariaLabelledBy?: string;
onAction?: (item: T, index: number) => void;
selectRowOnContextMenu?: boolean;
renderRowAs?: JSXElementConstructor<RowElementProps<T>>;
tableBody?: ReactElement<TableBodyProps>;
hideBorder?: boolean;
closeOnInteractOutside?: boolean;
collapseOnMobile?: boolean;
cellHeight?: string;
headerCellHeight?: string;
}
export function Table<T extends TableDataItem>({
className,
columns: userColumns,
collapseOnMobile = true,
hideHeaderRow = false,
hideBorder = false,
data,
selectedRows: propsSelectedRows,
defaultSelectedRows: propsDefaultSelectedRows,
onSelectionChange: propsOnSelectionChange,
sortDescriptor: propsSortDescriptor,
onSortChange: propsOnSortChange,
enableSorting = true,
onDelete,
enableSelection = true,
selectionStyle = 'checkbox',
ariaLabelledBy,
selectRowOnContextMenu,
onAction,
renderRowAs,
tableBody,
meta,
tableRef: propsTableRef,
closeOnInteractOutside = false,
cellHeight,
headerCellHeight,
...domProps
}: TableProps<T>) {
const isMobile = useIsMobileMediaQuery();
const isCollapsedMode = !!isMobile && collapseOnMobile;
if (isCollapsedMode) {
hideHeaderRow = true;
hideBorder = true;
}
const [selectedRows, onSelectionChange] = useControlledState(
propsSelectedRows,
propsDefaultSelectedRows || [],
propsOnSelectionChange,
);
const [sortDescriptor, onSortChange] = useControlledState(
propsSortDescriptor,
undefined,
propsOnSortChange,
);
const toggleRow = useCallback(
(item: TableDataItem) => {
const newValues = [...selectedRows];
if (!newValues.includes(item.id)) {
newValues.push(item.id);
} else {
const index = newValues.indexOf(item.id);
newValues.splice(index, 1);
}
onSelectionChange(newValues);
},
[selectedRows, onSelectionChange],
);
const selectRow = useCallback(
// allow deselecting all rows by passing in null
(item: TableDataItem | null, merge?: boolean) => {
let newValues: (string | number)[] = [];
if (item) {
newValues = merge
? [...selectedRows?.filter(id => id !== item.id), item.id]
: [item.id];
}
onSelectionChange(newValues);
},
[selectedRows, onSelectionChange],
);
// add checkbox columns to config, if selection is enabled
const columns = useMemo(() => {
const filteredColumns = userColumns.filter(c => {
const visibleInMode = c.visibleInMode || 'regular';
if (visibleInMode === 'all') {
return true;
}
if (visibleInMode === 'compact' && isCollapsedMode) {
return true;
}
if (visibleInMode === 'regular' && !isCollapsedMode) {
return true;
}
});
const showCheckboxCell =
enableSelection && selectionStyle !== 'highlight' && !isMobile;
if (showCheckboxCell) {
filteredColumns.unshift(CheckboxColumnConfig);
}
return filteredColumns;
}, [isMobile, userColumns, enableSelection, selectionStyle, isCollapsedMode]);
const contextValue: TableContextValue<T> = {
isCollapsedMode,
cellHeight,
headerCellHeight,
hideBorder,
hideHeaderRow,
selectedRows,
onSelectionChange,
enableSorting,
enableSelection,
selectionStyle,
data,
columns,
sortDescriptor,
onSortChange,
toggleRow,
selectRow,
onAction,
selectRowOnContextMenu,
meta,
collapseOnMobile,
};
const navProps = useGridNavigation({
cellCount: enableSelection ? columns.length + 1 : columns.length,
rowCount: data.length + 1,
});
const tableBodyProps: TableBodyProps = {
renderRowAs: renderRowAs as any,
};
if (!tableBody) {
tableBody = <BasicTableBody {...tableBodyProps} />;
} else {
tableBody = cloneElement(tableBody, tableBodyProps);
}
// deselect rows when clicking outside the table
const tableRef = useObjectRef(propsTableRef);
useInteractOutside({
ref: tableRef,
onInteractOutside: e => {
if (
closeOnInteractOutside &&
enableSelection &&
selectedRows?.length &&
// don't deselect if clicking on a dialog (for example is table row has a context menu)
!(e.target as HTMLElement).closest('[role="dialog"]')
) {
onSelectionChange([]);
}
},
});
return (
<TableContext.Provider value={contextValue as any}>
<div
{...mergeProps(domProps, navProps, {
onKeyDown: (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (selectedRows?.length) {
onSelectionChange([]);
}
} else if (e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
if (selectedRows?.length) {
onDelete?.(
data.filter(item => selectedRows?.includes(item.id)),
);
}
} else if (isCtrlKeyPressed(e) && e.key === 'a') {
e.preventDefault();
e.stopPropagation();
if (enableSelection) {
onSelectionChange(data.map(item => item.id));
}
}
},
})}
role="grid"
tabIndex={0}
aria-rowcount={data.length + 1}
aria-colcount={columns.length + 1}
ref={tableRef}
aria-multiselectable={enableSelection ? true : undefined}
aria-labelledby={ariaLabelledBy}
className={clsx(
className,
'isolate select-none text-sm outline-none focus-visible:ring-2',
)}
>
{!hideHeaderRow && <TableHeaderRow />}
{tableBody}
</div>
</TableContext.Provider>
);
}
export interface TableBodyProps {
renderRowAs?: TableProps<TableDataItem>['renderRowAs'];
}
function BasicTableBody({renderRowAs}: TableBodyProps) {
const {data} = useContext(TableContext);
return (
<Fragment>
{data.map((item, rowIndex) => (
<TableRow
item={item}
index={rowIndex}
key={item.id}
renderAs={renderRowAs}
/>
))}
</Fragment>
);
}

View File

@@ -0,0 +1,4 @@
export interface SortDescriptor {
orderBy?: string;
orderDir?: 'asc' | 'desc';
}

View File

@@ -0,0 +1,4 @@
export interface TableDataItem {
id: number | string;
isPlaceholder?: boolean;
}

View File

@@ -0,0 +1,28 @@
import {useMemo, useState} from 'react';
import {SortDescriptor} from '@common/ui/tables/types/sort-descriptor';
import {sortArrayOfObjects} from '@common/utils/array/sort-array-of-objects';
import {TableDataItem} from '@common/ui/tables/types/table-data-item';
import {TableProps} from '@common/ui/tables/table';
export function useSortableTableData<T extends TableDataItem>(
data?: T[]
): {
data: T[];
sortDescriptor: TableProps<T>['sortDescriptor'];
onSortChange: TableProps<T>['onSortChange'];
} {
const [sortDescriptor, onSortChange] = useState<SortDescriptor>({});
const sortedData: T[] = useMemo(() => {
if (!data) {
return [];
} else if (sortDescriptor?.orderBy) {
return sortArrayOfObjects(
[...data],
sortDescriptor.orderBy,
sortDescriptor.orderDir
);
}
return data;
}, [sortDescriptor, data]);
return {data: sortedData, sortDescriptor, onSortChange};
}