UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

269 lines (239 loc) 8.22 kB
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