56
common/resources/client/ui/tables/checkbox-column-config.tsx
Executable file
56
common/resources/client/ui/tables/checkbox-column-config.tsx
Executable 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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
122
common/resources/client/ui/tables/header-cell.tsx
Executable file
122
common/resources/client/ui/tables/header-cell.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
165
common/resources/client/ui/tables/navigate-grid.ts
Executable file
165
common/resources/client/ui/tables/navigate-grid.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
common/resources/client/ui/tables/style/use-table-cell-style.ts
Executable file
36
common/resources/client/ui/tables/style/use-table-cell-style.ts
Executable 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
|
||||
);
|
||||
}
|
||||
37
common/resources/client/ui/tables/style/use-table-row-style.ts
Executable file
37
common/resources/client/ui/tables/style/use-table-row-style.ts
Executable 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'
|
||||
);
|
||||
}
|
||||
50
common/resources/client/ui/tables/table-cell.tsx
Executable file
50
common/resources/client/ui/tables/table-cell.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
31
common/resources/client/ui/tables/table-context.ts
Executable file
31
common/resources/client/ui/tables/table-context.ts
Executable 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!);
|
||||
19
common/resources/client/ui/tables/table-header-row.tsx
Executable file
19
common/resources/client/ui/tables/table-header-row.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
169
common/resources/client/ui/tables/table-row.tsx
Executable file
169
common/resources/client/ui/tables/table-row.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
275
common/resources/client/ui/tables/table.tsx
Executable file
275
common/resources/client/ui/tables/table.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
4
common/resources/client/ui/tables/types/sort-descriptor.ts
Executable file
4
common/resources/client/ui/tables/types/sort-descriptor.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export interface SortDescriptor {
|
||||
orderBy?: string;
|
||||
orderDir?: 'asc' | 'desc';
|
||||
}
|
||||
4
common/resources/client/ui/tables/types/table-data-item.ts
Executable file
4
common/resources/client/ui/tables/types/table-data-item.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export interface TableDataItem {
|
||||
id: number | string;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
28
common/resources/client/ui/tables/use-sortable-table-data.ts
Executable file
28
common/resources/client/ui/tables/use-sortable-table-data.ts
Executable 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};
|
||||
}
|
||||
Reference in New Issue
Block a user