@hypothesis/frontend-shared
Version:
Shared components, styles and utilities for Hypothesis projects
329 lines (324 loc) • 12.4 kB
JavaScript
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/data/DataTable.tsx";
import classnames from 'classnames';
import { useCallback, useContext, useEffect, useMemo } from 'preact/hooks';
import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation';
import { useStableCallback } from '../../hooks/use-stable-callback';
import { useSyncedRef } from '../../hooks/use-synced-ref';
import { downcastRef } from '../../util/typing';
import { ArrowDownIcon, ArrowUpIcon, OrderableIcon, SpinnerSpokesIcon } from '../icons';
import { Button } from '../input';
import ScrollContext from './ScrollContext';
import Table from './Table';
import TableBody from './TableBody';
import TableCell from './TableCell';
import TableFoot from './TableFoot';
import TableHead from './TableHead';
import TableRow from './TableRow';
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "preact/jsx-dev-runtime";
function defaultRenderItem(r, field) {
return r[field];
}
function calculateNewOrder(newField, prevOrder, initialOrderForColumn) {
if (newField !== (prevOrder === null || prevOrder === void 0 ? void 0 : prevOrder.field)) {
var _initialOrderForColum;
return {
field: newField,
direction: (_initialOrderForColum = initialOrderForColumn === null || initialOrderForColumn === void 0 ? void 0 : initialOrderForColumn[newField]) !== null && _initialOrderForColum !== void 0 ? _initialOrderForColum : 'ascending'
};
}
const newDirection = prevOrder.direction === 'ascending' ? 'descending' : 'ascending';
return {
field: newField,
direction: newDirection
};
}
function HeaderComponent({
children,
onClick,
field
}) {
const commonClasses = 'flex justify-between items-center';
return onClick ? _jsxDEV(Button, {
classes: `${commonClasses} w-full h-full !p-3`,
variant: "custom",
onClick: onClick,
"data-testid": `${field}-order-button`,
children: children
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 135,
columnNumber: 5
}, this) : _jsxDEV("div", {
className: commonClasses,
children: children
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 144,
columnNumber: 5
}, this);
}
/**
* An interactive table of rows and columns with a sticky header.
*/
export default function DataTable({
children,
elementRef,
columns = [],
rows = [],
selectedRow,
selectedRows,
loading = false,
renderItem = defaultRenderItem,
onSelectRow,
onSelectRows,
onConfirmRow,
emptyMessage,
order,
onOrderChange,
orderableColumns = [],
// Forwarded to Table
title,
borderless,
striped,
grid,
...htmlAttributes
}) {
const tableRef = useSyncedRef(elementRef);
const scrollContext = useContext(ScrollContext);
const [orderableColumnsList, initialOrderForColumn] = useMemo(() => Array.isArray(orderableColumns) ? [orderableColumns, {}] : [Object.keys(orderableColumns), orderableColumns], [orderableColumns]);
const updateOrder = useCallback(newField => {
const newOrder = calculateNewOrder(newField, order, initialOrderForColumn);
onOrderChange === null || onOrderChange === void 0 || onOrderChange(newOrder);
}, [initialOrderForColumn, onOrderChange, order]);
const noContent = loading || !rows.length && emptyMessage;
const fields = useMemo(() => columns.map(column => column.field), [columns]);
const selectRow = useStableCallback((row, mode = 'replace') => {
onSelectRow === null || onSelectRow === void 0 || onSelectRow(row);
// If multi-selection is enabled, and the user shift+clicked the new row,
// extend the selection from the "anchor" row (first entry in `selectedRows`)
// to the just-clicked row.
let newSelection = [row];
if (mode === 'extend' && selectedRows && selectedRows.length > 0) {
const startIdx = rows.indexOf(selectedRows[0]);
const endIdx = rows.indexOf(row);
if (endIdx >= startIdx) {
newSelection = rows.slice(startIdx, endIdx + 1);
} else {
// We reverse the selection here so that `startIdx` remains the first
// entry in the list, and is used as the 'anchor' row for future
// selections.
newSelection = rows.slice(endIdx, startIdx + 1).reverse();
}
}
onSelectRows === null || onSelectRows === void 0 || onSelectRows(newSelection);
});
useArrowKeyNavigation(tableRef, {
selector: 'tbody tr',
horizontal: true,
vertical: true,
focusElement: (element, keyEvent) => {
// Simulate a click to update the selected row when arrow-key navigation
// happens. We do this instead of using an `onFocus` handler on the row
// itself because we need to know if the shift key was pressed, and
// `FocusEvent` doesn't provide that information.
if (keyEvent) {
element.dispatchEvent(new MouseEvent('click', {
// Propagate shift key state so arrow key + shift can be used to
// create a multi-selection.
shiftKey: keyEvent.shiftKey
}));
}
// Scroll selected row into view.
element.focus();
}
});
const confirmRow = useStableCallback(row => {
onConfirmRow === null || onConfirmRow === void 0 || onConfirmRow(row);
});
const handleKeyDown = useCallback((event, row) => {
// Avoid preventing Enter key interactions in children elements by
// ignoring events not triggered by the row element itself
if (event.key === 'Enter' && event.target === event.currentTarget) {
confirmRow(row);
event.preventDefault();
event.stopPropagation();
}
}, [confirmRow]);
// Ensure that a selected row is visible when this table is within
// a scrolling context
useEffect(() => {
var _tableRef$current, _tableRef$current2;
if (!selectedRow || !scrollContext) {
return;
}
const scrollEl = scrollContext.scrollRef.current;
const tableHead = (_tableRef$current = tableRef.current) === null || _tableRef$current === void 0 ? void 0 : _tableRef$current.querySelector('thead');
const selectedRowEl = (_tableRef$current2 = tableRef.current) === null || _tableRef$current2 === void 0 ? void 0 : _tableRef$current2.querySelector('tr[aria-selected="true"]');
if (scrollEl && selectedRowEl) {
// Ensure the row is visible within the scroll content area
const scrollOffset = selectedRowEl.offsetTop - scrollEl.scrollTop;
if (scrollOffset > scrollEl.clientHeight) {
selectedRowEl.scrollIntoView();
}
// Ensure the row is not obscured by a sticky header
if (tableHead) {
const headingHeight = tableHead.clientHeight;
const headingOffset = scrollOffset - headingHeight;
if (headingOffset < 0) {
scrollEl.scrollBy(0, headingOffset);
}
}
}
}, [selectedRow, tableRef, scrollContext]);
// Render a <tfoot> element when there are any row data. This absorbs any
// excess vertical space in tables with sparse rows data.
const withFoot = !loading && rows.length > 0;
const selection = useMemo(() => {
if (selectedRows) {
return selectedRows;
} else if (selectedRow) {
return [selectedRow];
} else {
return [];
}
}, [selectedRows, selectedRow]);
const tableRows = useMemo(() => {
return rows.map((row, idx) => _jsxDEV(TableRow, {
selected: selection.includes(row),
onClick: e => selectRow(row, e.shiftKey ? 'extend' : 'replace'),
onDblClick: () => confirmRow(row),
onKeyDown: event => handleKeyDown(event, row),
children: fields.map(field => _jsxDEV(TableCell, {
children: renderItem(row, field)
}, field, false, {
fileName: _jsxFileName,
lineNumber: 319,
columnNumber: 11
}, this))
}, idx, false, {
fileName: _jsxFileName,
lineNumber: 311,
columnNumber: 7
}, this));
}, [confirmRow, fields, renderItem, handleKeyDown, rows, selectRow, selection]);
const interactive = Boolean(onSelectRow || onSelectRows || onConfirmRow);
return _jsxDEV(Table, {
"data-composite-component": "DataTable",
role: "grid",
...htmlAttributes,
elementRef: downcastRef(tableRef),
interactive: interactive,
stickyHeader: true,
title: title,
borderless: borderless,
striped: striped,
grid: grid,
children: [_jsxDEV(TableHead, {
children: _jsxDEV(TableRow, {
children: columns.map(column => {
const isOrderable = !!onOrderChange && orderableColumnsList.includes(column.field);
const isActiveOrder = (order === null || order === void 0 ? void 0 : order.field) === column.field;
return _jsxDEV(TableCell, {
classes: column.classes,
unpadded: isOrderable,
"aria-sort": isActiveOrder ? order.direction : undefined,
children: _jsxDEV(HeaderComponent, {
field: column.field.toString(),
onClick: isOrderable ? () => updateOrder(column.field) : undefined,
children: [_jsxDEV("div", {
children: column.label
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 368,
columnNumber: 19
}, this), isOrderable && _jsxDEV("div", {
className: classnames('rounded p-1', {
'bg-white': isActiveOrder
}),
"aria-hidden": true,
children: [isActiveOrder && (order.direction === 'ascending' ? _jsxDEV(ArrowUpIcon, {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 378,
columnNumber: 27
}, this) : _jsxDEV(ArrowDownIcon, {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 380,
columnNumber: 27
}, this)), !isActiveOrder && _jsxDEV(OrderableIcon, {
className: classnames('text-grey-5', {
// Interactive rows set a darker background color on
// hover.
// Setting a darker color on the icon when hovering
// the row will ensure enough contrast.
'group-hover:text-grey-7': interactive
})
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 383,
columnNumber: 25
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 370,
columnNumber: 21
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 362,
columnNumber: 17
}, this)
}, column.field, false, {
fileName: _jsxFileName,
lineNumber: 356,
columnNumber: 15
}, this);
})
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 349,
columnNumber: 9
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 348,
columnNumber: 7
}, this), _jsxDEV(TableBody, {
children: [!loading && tableRows, noContent && _jsxDEV(TableRow, {
children: _jsxDEV(TableCell, {
colSpan: columns.length,
classes: "text-center p-3",
children: loading ? _jsxDEV(SpinnerSpokesIcon, {
className: "inline w-2em h-2em"
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 407,
columnNumber: 17
}, this) : _jsxDEV(_Fragment, {
children: emptyMessage
}, void 0, false)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 405,
columnNumber: 13
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 404,
columnNumber: 11
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 401,
columnNumber: 7
}, this), children, withFoot && _jsxDEV(TableFoot, {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 416,
columnNumber: 20
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 336,
columnNumber: 5
}, this);
}
//# sourceMappingURL=DataTable.js.map