@hypothesis/frontend-shared
Version:
Shared components, styles and utilities for Hypothesis projects
269 lines (239 loc) • 8.22 kB
JavaScript
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/Table.js";
import classnames from 'classnames';
import { useEffect, useRef } from 'preact/hooks';
import { Spinner } from './Spinner';
import { Scrollbox } from './containers';
/**
* @typedef TableHeader
* @prop {string} label
* @prop {string} [classes] - Additional CSS classes for the column's `<th>` element
*/
/**
* @template Item
* @typedef TableProps
* @prop {string} accessibleLabel - An accessible label for the table
* @prop {string} [classes] - Extra CSS classes to apply to the <table>
* @prop {string} [containerClasses] - Extra CSS classes to apply to the outermost
* element, which is a <Scrollbox> div
* @prop {import("preact").ComponentChildren} [emptyItemsMessage] - Optional message to display if
* there are no `items`. Will only display when the Table is not loading.
* @prop {TableHeader[]} tableHeaders - The columns to display in this table
* @prop {boolean} [isLoading] - Show an indicator that data for the table is
* currently being fetched
* @prop {Item[]} items -
* The items to display in this table, one per row. `renderItem` defines how
* information from each item is represented as a series of table cells.
* @prop {(it: Item, selected: boolean) => any} renderItem -
* A function to render an item as a table row. It should return
* a `<td>` element for each `tableHeader` column, wrapped in a `Fragment`
* @prop {Item|null} selectedItem - The currently selected item from `items`
* @prop {(it: Item) => void} onSelectItem -
* Callback invoked when the user changes the selected item
* @prop {(it: Item) => void} onUseItem -
* Callback invoked when a user chooses to use an item by double-clicking it
* or pressing Enter while it is selected
*/
/**
* Return the next item to select when advancing the selection by `step` items
* forwards (if positive) or backwards (if negative).
*
* @template Item
* @param {Item[]} items
* @param {Item|null} currentItem
* @param {number} step
*/
import { jsxDEV as _jsxDEV } from "preact/jsx-dev-runtime";
function nextItem(items, currentItem, step) {
const index = currentItem ? items.indexOf(currentItem) : -1;
const delta = index + step;
if (index < 0) {
return items[0];
}
if (delta < 0) {
return items[0];
}
if (delta >= items.length) {
return items[items.length - 1];
}
return items[delta];
}
/**
* An interactive table of items with a sticky header.
*
* @template Item
* @param {TableProps<Item>} props
*/
export function Table({
accessibleLabel,
classes,
containerClasses,
emptyItemsMessage,
isLoading = false,
items,
onSelectItem,
onUseItem,
renderItem,
selectedItem,
tableHeaders
}) {
const rowRefs = useRef(
/** @type {(HTMLElement|null)[]} */
[]);
const scrollboxRef =
/** @type {{ current: HTMLElement }} */
useRef();
const headerRef =
/** @type {{ current: HTMLTableSectionElement }} */
useRef();
/** @param {Item} item */
const onKeyboardSelect = item => {
const rowEl = rowRefs.current[items.indexOf(item)];
if (rowEl) {
rowEl.focus();
}
onSelectItem(item);
};
/** @param {KeyboardEvent} event */
const onKeyDown = event => {
let handled = false;
switch (event.key) {
case 'Enter':
handled = true;
if (selectedItem) {
onUseItem(selectedItem);
}
break;
case 'ArrowUp':
handled = true;
onKeyboardSelect(nextItem(items, selectedItem, -1));
break;
case 'ArrowDown':
handled = true;
onKeyboardSelect(nextItem(items, selectedItem, 1));
break;
default:
handled = false;
break;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}; // When the selectedItem changes, assure that the table row associated with it
// is fully visible and not obscured by the sticky table header. This could
// happen if the table is partially scrolled. Scroll the Scrollbox as needed
// to make the item row fully visible below the header.
useEffect(() => {
if (!selectedItem) {
return;
}
const rowEl = rowRefs.current[items.indexOf(selectedItem)];
const headingEl = headerRef.current;
const scrollboxEl = scrollboxRef.current;
if (rowEl) {
const headingHeight = headingEl.offsetHeight; // The top of the selected row, relative to the top of the Scrollbox frame
const rowOffsetFromScrollbox = rowEl.offsetTop - scrollboxEl.scrollTop;
if (rowOffsetFromScrollbox >= scrollboxEl.clientHeight) {
// The `selectedItem` is in a table row that is not visible because it
// is below the visible content in the `scrollbox`. This is most likely
// to occur if a `Table` is rendered with an initial `selectedItem` that
// is towards the bottom of the table (later in the `items` array).
// Scroll it into view.
rowEl.scrollIntoView();
} // If the offset position is smaller than the height of the header,
// the row is partially or fully obscured by the header. Scroll just
// enough to make the full row visible beneath the header.
if (rowOffsetFromScrollbox <= headingHeight) {
scrollboxEl.scrollBy(0, rowOffsetFromScrollbox - headingHeight);
}
}
}, [items, selectedItem]);
return _jsxDEV(Scrollbox, {
withHeader: true,
classes: classnames('Hyp-Table-Scrollbox', containerClasses),
elementRef: scrollboxRef,
children: [_jsxDEV("table", {
"aria-label": accessibleLabel,
className: classnames('Hyp-Table', classes),
tabIndex: 0,
role: "grid",
onKeyDown: onKeyDown,
children: [_jsxDEV("thead", {
ref: headerRef,
children: _jsxDEV("tr", {
children: tableHeaders.map(({
classes,
label
}, index) => _jsxDEV("th", {
className: classnames('Hyp-Table__header', classes),
scope: "col",
children: label
}, `${label}-${index}`, false, {
fileName: _jsxFileName,
lineNumber: 180,
columnNumber: 15
}, this))
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 178,
columnNumber: 11
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 177,
columnNumber: 9
}, this), _jsxDEV("tbody", {
children: !isLoading && items.map((item, index) => _jsxDEV("tr", {
"aria-selected": selectedItem === item,
className: classnames({
'is-selected': selectedItem === item
}),
onMouseDown: () => onSelectItem(item),
onClick: () => onSelectItem(item),
onDblClick: () => onUseItem(item),
ref: node => rowRefs.current[index] = node,
tabIndex: -1,
children: renderItem(item, selectedItem === item)
}, index, false, {
fileName: _jsxFileName,
lineNumber: 193,
columnNumber: 15
}, this))
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 190,
columnNumber: 9
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 170,
columnNumber: 7
}, this), isLoading && _jsxDEV("div", {
className: "Hyp-Table-Scrollbox__loading",
children: _jsxDEV(Spinner, {
size: "large"
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 212,
columnNumber: 11
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 211,
columnNumber: 9
}, this), !isLoading && items.length === 0 && emptyItemsMessage && _jsxDEV("div", {
className: "Hyp-Table-Scrollbox__message",
"data-testid": "empty-items-message",
children: emptyItemsMessage
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 216,
columnNumber: 9
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 165,
columnNumber: 5
}, this);
}
//# sourceMappingURL=Table.js.map