Skip to content

Commit

Permalink
Add Table component
Browse files Browse the repository at this point in the history
  • Loading branch information
lyzadanger committed Aug 12, 2021
1 parent ea2ba14 commit 5175bdf
Show file tree
Hide file tree
Showing 9 changed files with 554 additions and 9 deletions.
203 changes: 203 additions & 0 deletions src/components/Table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import classnames from 'classnames';
import { useEffect, useRef } from 'preact/hooks';
import { Scrollbox } from './containers';
import { Spinner } from './Spinner';

/**
* @typedef TableColumn
* @prop {string} label - Header label for the column
* @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 outermost
* element, which is a <Scrollbox> div
* @prop {TableColumn[]} columns - 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 called to render each item as the contents of a table row.
* The result should be a list of `<td>` elements (one per column) wrapped inside 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
* @prop {string} [tableClasses] - Extra CSS classes to apply to the <table>
*/

/**
* 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
*/
function nextItem(items, currentItem, step) {
const index = currentItem ? items.indexOf(currentItem) : -1;
if (index < 0) {
return items[0];
}

if (index + step < 0) {
return items[0];
}

if (index + step >= items.length) {
return items[items.length - 1];
}

return items[index + step];
}

/**
* An interactive table of items with a sticky header.
*
* @template Item
* @param {TableProps<Item>} props
*/
export function Table({
accessibleLabel,
classes,
columns,
isLoading = false,
items,
onSelectItem,
onUseItem,
renderItem,
tableClasses,
selectedItem,
}) {
const rowRefs = useRef(/** @type {(HTMLElement|null)[]} */ ([]));
const scrollboxRef = useRef(/** @type {HTMLDivElement|null} */ (null));
const headerRef = useRef(/** @type {HTMLElement|null} */ (null));

const onKeyboardSelect = item => {
const rowEl = rowRefs.current[items.indexOf(item)];
if (rowEl) {
rowEl.focus();
}
onSelectItem(item);
};

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 && headingEl && scrollboxEl) {
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 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 (
<Scrollbox
withHeader
classes={classnames('Hyp-Table-Scrollbox', classes)}
containerRef={scrollboxRef}
>
<table
aria-label={accessibleLabel}
className={classnames('Hyp-Table', tableClasses)}
tabIndex={0}
role="grid"
onKeyDown={onKeyDown}
>
<thead
ref={
/** @type {import('preact').Ref<HTMLTableSectionElement>} */ (headerRef)
}
>
<tr>
{columns.map(column => (
<th
key={column.label}
className={classnames(column.classes)}
scope="col"
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{!isLoading &&
items.map((item, index) => (
<tr
aria-selected={selectedItem === item}
key={index}
className={classnames({
'is-selected': selectedItem === item,
})}
onMouseDown={() => onSelectItem(item)}
onClick={() => onSelectItem(item)}
onDblClick={() => onUseItem(item)}
ref={node => (rowRefs.current[index] = node)}
tabIndex={-1}
>
{renderItem(item, selectedItem === item)}
</tr>
))}
</tbody>
</table>
{isLoading && (
<div className="Hyp-Table-Scrollbox__loading">
<Spinner size="large" />
</div>
)}
</Scrollbox>
);
}
135 changes: 135 additions & 0 deletions src/components/test/Table-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { mount } from 'enzyme';

import { Table } from '../Table';
import { checkAccessibility } from '../../../test/util/accessibility';

describe('Table', () => {
const renderTable = (props = {}) =>
mount(
<Table
accessibleLabel="Test table"
columns={[{ label: 'Item' }]}
items={[]}
renderItem={item => item}
{...props}
/>
);

it('renders column headings', () => {
const wrapper = renderTable({
columns: [{ label: 'Name' }, { label: 'Size' }],
});
const columns = wrapper.find('thead th').map(col => col.text());
assert.deepEqual(columns, ['Name', 'Size']);
});

it('renders items', () => {
const wrapper = renderTable({
columns: [{ label: 'Item' }],
items: ['One', 'Two', 'Three'],
// eslint-disable-next-line react/display-name
renderItem: item => <span>{item}</span>,
});

const items = wrapper.find('tr > span');
assert.equal(items.length, 3);
assert.isTrue(wrapper.contains(<span>One</span>));
assert.isTrue(wrapper.contains(<span>Two</span>));
assert.isTrue(wrapper.contains(<span>Three</span>));
});

['click', 'mousedown'].forEach(event => {
it(`selects item on ${event}`, () => {
const onSelectItem = sinon.stub();
const wrapper = renderTable({
items: ['One', 'Two', 'Three'],
onSelectItem,
});

wrapper.find('tbody > tr').first().simulate(event);

assert.calledWith(onSelectItem, 'One');
});
});

it('uses selected item on double-click', () => {
const item = 'Test item';
const onUseItem = sinon.stub();
const wrapper = renderTable({ items: [item], onUseItem });

wrapper.find('tbody > tr').first().simulate('dblclick');

assert.calledWith(onUseItem, item);
});

it('supports keyboard navigation', () => {
const onSelectItem = sinon.stub();
const onUseItem = sinon.stub();
const items = ['One', 'Two', 'Three'];
const wrapper = renderTable({
items,
selectedItem: items[1],
onSelectItem,
onUseItem,
});
const rows = wrapper.find('tbody > tr').map(n => n.getDOMNode());
rows.forEach(row => (row.focus = sinon.stub()));

const assertKeySelectsItem = (key, index) => {
rows[index].focus.reset();
onSelectItem.reset();

wrapper.find('table').simulate('keydown', { key });

assert.calledWith(onSelectItem, items[index]);
assert.called(rows[index].focus);
};

// Down arrow should select item below selected item.
assertKeySelectsItem('ArrowDown', 2);

// Up arrow should select item above selected item.
assertKeySelectsItem('ArrowUp', 0);

// Enter should use selected item.
onSelectItem.reset();
wrapper.find('table').simulate('keydown', { key: 'Enter' });
assert.calledWith(onUseItem, items[1]);

// Up arrow should not change selection if first item is selected.
wrapper.setProps({ selectedItem: items[0] });
assertKeySelectsItem('ArrowUp', 0);

// Down arrow should not change selection if last item is selected.
wrapper.setProps({ selectedItem: items[items.length - 1] });
assertKeySelectsItem('ArrowDown', items.length - 1);

// Up or down arrow should select the first item if no item is selected.
wrapper.setProps({ selectedItem: null });
assertKeySelectsItem('ArrowUp', 0);
assertKeySelectsItem('ArrowDown', 0);

// Other keys should do nothing.
onSelectItem.reset();
wrapper.find('table').simulate('keydown', { key: 'Tab' });
assert.notCalled(onSelectItem);
});

it('hides spinner if data is fetched', () => {
const wrapper = renderTable({ isLoading: false });
assert.isFalse(wrapper.exists('Spinner'));
});

it('shows spinner if data is loading', () => {
const wrapper = renderTable({ isLoading: true });
assert.isTrue(wrapper.exists('Spinner'));
});

it(
'should pass a11y checks',
checkAccessibility({
content: () =>
renderTable({ items: ['One', 'Two', 'Three'], selectedItem: 'One' }),
})
);
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { Modal, ConfirmModal } from './components/Modal';
export { Panel } from './components/Panel';
export { Spinner } from './components/Spinner';
export { SvgIcon, registerIcons } from './components/SvgIcon';
export { Table } from './components/Table';
export { TextInput, TextInputWithButton } from './components/TextInput';
export { Thumbnail } from './components/Thumbnail';

Expand Down
Loading

0 comments on commit 5175bdf

Please sign in to comment.