Added selection / keyboard navigation

Summary: per title

Reviewed By: nikoant

Differential Revision: D26368673

fbshipit-source-id: 7a458e28af1229ee8193dfe2a6d156afd9282acd
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent fb7c09c972
commit 1ce665ceaf
7 changed files with 202 additions and 67 deletions

View File

@@ -69,9 +69,14 @@ type StateOptions = {
export function createState<T>( export function createState<T>(
initialValue: T, initialValue: T,
options?: StateOptions,
): Atom<T>;
export function createState<T>(): Atom<T | undefined>;
export function createState(
initialValue: any = undefined,
options: StateOptions = {}, options: StateOptions = {},
): Atom<T> { ): Atom<any> {
const atom = new AtomValue<T>(initialValue); const atom = new AtomValue(initialValue);
if (getCurrentPluginInstance() && options.persist) { if (getCurrentPluginInstance() && options.persist) {
const {rootStates} = getCurrentPluginInstance()!; const {rootStates} = getCurrentPluginInstance()!;
if (rootStates[options.persist]) { if (rootStates[options.persist]) {

View File

@@ -337,8 +337,12 @@ export class DataSource<
return this.reverse ? this.output.length - 1 - viewIndex : viewIndex; return this.reverse ? this.output.length - 1 - viewIndex : viewIndex;
} }
getItem(viewIndex: number) { getItem(viewIndex: number): T {
return this.output[this.normalizeIndex(viewIndex)].value; return this.getEntry(viewIndex)?.value;
}
getEntry(viewIndex: number): Entry<T> {
return this.output[this.normalizeIndex(viewIndex)];
} }
notifyItemUpdated(viewIndex: number) { notifyItemUpdated(viewIndex: number) {

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
useLayoutEffect, useLayoutEffect,
MutableRefObject,
} from 'react'; } from 'react';
import {DataSource} from '../../state/datasource/DataSource'; import {DataSource} from '../../state/datasource/DataSource';
import {useVirtual} from 'react-virtual'; import {useVirtual} from 'react-virtual';
@@ -28,6 +29,8 @@ enum UpdatePrio {
HIGH, HIGH,
} }
export type DataSourceVirtualizer = ReturnType<typeof useVirtual>;
type DataSourceProps<T extends object, C> = { type DataSourceProps<T extends object, C> = {
/** /**
* The data source to render * The data source to render
@@ -50,6 +53,8 @@ type DataSourceProps<T extends object, C> = {
itemRenderer(item: T, index: number, context: C): React.ReactElement; itemRenderer(item: T, index: number, context: C): React.ReactElement;
useFixedRowHeight: boolean; useFixedRowHeight: boolean;
defaultRowHeight: number; defaultRowHeight: number;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
_testHeight?: number; // exposed for unit testing only _testHeight?: number; // exposed for unit testing only
}; };
@@ -66,6 +71,8 @@ export const DataSourceRenderer: <T extends object, C>(
context, context,
itemRenderer, itemRenderer,
autoScroll, autoScroll,
onKeyDown,
virtualizerRef,
_testHeight, _testHeight,
}: DataSourceProps<any, any>) { }: DataSourceProps<any, any>) {
/** /**
@@ -89,6 +96,9 @@ export const DataSourceRenderer: <T extends object, C>(
estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]), estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]),
overscan: 0, overscan: 0,
}); });
if (virtualizerRef) {
virtualizerRef.current = virtualizer;
}
useEffect( useEffect(
function subscribeToDataSource() { function subscribeToDataSource() {
@@ -220,28 +230,30 @@ export const DataSourceRenderer: <T extends object, C>(
*/ */
return ( return (
<TableContainer onScroll={onScroll} ref={parentRef}> <TableContainer onScroll={onScroll} ref={parentRef}>
<TableWindow height={virtualizer.totalSize}> <TableWindow
{virtualizer.virtualItems.map((virtualRow) => ( height={virtualizer.totalSize}
onKeyDown={onKeyDown}
tabIndex={0}>
{virtualizer.virtualItems.map((virtualRow) => {
const entry = dataSource.getEntry(virtualRow.index);
// the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always. // the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/} // Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
<div return (
key={virtualRow.index} <div
style={{ key={virtualRow.index}
position: 'absolute', style={{
top: 0, position: 'absolute',
left: 0, top: 0,
width: '100%', left: 0,
height: useFixedRowHeight ? virtualRow.size : undefined, width: '100%',
transform: `translateY(${virtualRow.start}px)`, height: useFixedRowHeight ? virtualRow.size : undefined,
}} transform: `translateY(${virtualRow.start}px)`,
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}> }}
{itemRenderer( ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
dataSource.getItem(virtualRow.index), {itemRenderer(entry.value, virtualRow.index, context)}
virtualRow.index, </div>
context, );
)} })}
</div>
))}
</TableWindow> </TableWindow>
</TableContainer> </TableContainer>
); );

View File

@@ -7,13 +7,20 @@
* @format * @format
*/ */
import React, {MutableRefObject, RefObject, useMemo} from 'react'; import React, {
useCallback,
useLayoutEffect,
useMemo,
useRef,
RefObject,
MutableRefObject,
} from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow'; import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {DataSource} from '../../state/datasource/DataSource'; import {DataSource} from '../../state/datasource/DataSource';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {TableHead} from './TableHead'; import {TableHead} from './TableHead';
import {Percentage} from '../utils/widthUtils'; import {Percentage} from '../utils/widthUtils';
import {DataSourceRenderer} from './DataSourceRenderer'; import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
import {useDataTableManager, TableManager} from './useDataTableManager'; import {useDataTableManager, TableManager} from './useDataTableManager';
import {TableSearch} from './TableSearch'; import {TableSearch} from './TableSearch';
@@ -23,6 +30,15 @@ interface DataTableProps<T = any> {
autoScroll?: boolean; autoScroll?: boolean;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
// custom onSearch(text, row) option? // custom onSearch(text, row) option?
/**
* onSelect event
* @param item currently selected item
* @param index index of the selected item in the datasources' output.
* Note that the index could potentially refer to a different item if rendering is 'behind' and items have shifted
*/
onSelect?(item: T | undefined, index: number): void;
// multiselect?: true
// onMultiSelect
tableManagerRef?: RefObject<TableManager>; tableManagerRef?: RefObject<TableManager>;
_testHeight?: number; // exposed for unit testing only _testHeight?: number; // exposed for unit testing only
} }
@@ -38,27 +54,113 @@ export type DataTableColumn<T = any> = {
visible?: boolean; visible?: boolean;
}; };
export interface RenderingConfig<T = any> { export interface RenderContext<T = any> {
columns: DataTableColumn<T>[]; columns: DataTableColumn<T>[];
onClick(item: T, itemId: number): void;
} }
export function DataTable<T extends object>(props: DataTableProps<T>) { export function DataTable<T extends object>(props: DataTableProps<T>) {
const tableManager = useDataTableManager<T>(props.dataSource, props.columns); const {dataSource} = props;
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
const tableManager = useDataTableManager<T>(
dataSource,
props.columns,
props.onSelect,
);
if (props.tableManagerRef) { if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager; (props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
} }
const {visibleColumns, selectItem, selection} = tableManager;
const renderingConfig = useMemo(() => { const renderingConfig = useMemo<RenderContext<T>>(() => {
return { return {
columns: tableManager.visibleColumns, columns: visibleColumns,
onClick(_, itemIdx) {
selectItem(() => itemIdx);
},
}; };
}, [tableManager.visibleColumns]); }, [visibleColumns, selectItem]);
const usesWrapping = useMemo( const usesWrapping = useMemo(
() => tableManager.columns.some((col) => col.wrap), () => tableManager.columns.some((col) => col.wrap),
[tableManager.columns], [tableManager.columns],
); );
const itemRenderer = useCallback(
function itemRenderer(
item: any,
index: number,
renderContext: RenderContext<T>,
) {
return (
<TableRow
key={index}
config={renderContext}
value={item}
itemIndex={index}
highlighted={index === tableManager.selection}
/>
);
},
[tableManager.selection],
);
/**
* Keyboard / selection handling
*/
const onKeyDown = useCallback(
(e: React.KeyboardEvent<any>) => {
let handled = true;
switch (e.key) {
case 'ArrowUp':
selectItem((idx) => (idx > 0 ? idx - 1 : 0));
break;
case 'ArrowDown':
selectItem((idx) =>
idx < dataSource.output.length - 1 ? idx + 1 : idx,
);
break;
case 'Home':
selectItem(() => 0);
break;
case 'End':
selectItem(() => dataSource.output.length - 1);
break;
case 'PageDown':
selectItem((idx) =>
Math.min(
dataSource.output.length - 1,
idx + virtualizerRef.current!.virtualItems.length - 1,
),
);
break;
case 'PageUp':
selectItem((idx) =>
Math.max(0, idx - virtualizerRef.current!.virtualItems.length - 1),
);
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
},
[selectItem, dataSource],
);
useLayoutEffect(
function scrollSelectionIntoView() {
if (selection >= 0) {
virtualizerRef.current?.scrollToIndex(selection, {
align: 'auto',
});
}
},
[selection],
);
return ( return (
<Layout.Top> <Layout.Top>
<Layout.Container> <Layout.Container>
@@ -76,30 +178,17 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
onColumnSort={tableManager.sortColumn} onColumnSort={tableManager.sortColumn}
/> />
</Layout.Container> </Layout.Container>
<DataSourceRenderer<any, RenderContext> <DataSourceRenderer<T, RenderContext<T>>
dataSource={props.dataSource} dataSource={dataSource}
autoScroll={props.autoScroll} autoScroll={props.autoScroll}
useFixedRowHeight={!usesWrapping} useFixedRowHeight={!usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT} defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig} context={renderingConfig}
itemRenderer={itemRenderer} itemRenderer={itemRenderer}
onKeyDown={onKeyDown}
virtualizerRef={virtualizerRef}
_testHeight={props._testHeight} _testHeight={props._testHeight}
/> />
</Layout.Top> </Layout.Top>
); );
} }
export type RenderContext = {
columns: DataTableColumn<any>[];
};
function itemRenderer(item: any, index: number, renderContext: RenderContext) {
return (
<TableRow
key={index}
config={renderContext}
row={item}
highlighted={false}
/>
);
}

View File

@@ -70,18 +70,22 @@ const TableBodyColumnContainer = styled.div<{
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
type Props = { type Props = {
config: RenderContext; config: RenderContext<any>;
highlighted: boolean; highlighted: boolean;
row: any; value: any;
itemIndex: number;
}; };
export const TableRow = memo(function TableRow(props: Props) { export const TableRow = memo(function TableRow(props: Props) {
const {config, highlighted, row} = props; const {config, highlighted, value: row} = props;
return ( return (
<TableBodyRowContainer <TableBodyRowContainer
highlighted={highlighted} highlighted={highlighted}
data-key={row.key} data-key={row.key}
className="ant-table-row"> onClick={(e) => {
e.stopPropagation();
props.config.onClick(props.value, props.itemIndex);
}}>
{config.columns {config.columns
.filter((col) => col.visible) .filter((col) => col.visible)
.map((col) => { .map((col) => {

View File

@@ -49,21 +49,21 @@ test('update and append', async () => {
const elem = await rendering.findAllByText('test DataTable'); const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1); expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(` expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-4f2ebr-TableBodyRowContainer efe0za01"
>
<div <div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01" class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
> >
<div test DataTable
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
true
</div>
</div> </div>
`); <div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
true
</div>
</div>
`);
} }
act(() => { act(() => {
@@ -102,7 +102,7 @@ test('column visibility', async () => {
expect(elem.length).toBe(1); expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(` expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div <div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01" class="css-4f2ebr-TableBodyRowContainer efe0za01"
> >
<div <div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00" class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
@@ -127,7 +127,7 @@ test('column visibility', async () => {
expect(elem.length).toBe(1); expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(` expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div <div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01" class="css-4f2ebr-TableBodyRowContainer efe0za01"
> >
<div <div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00" class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"

View File

@@ -27,11 +27,14 @@ export type TableManager = ReturnType<typeof useDataTableManager>;
export function useDataTableManager<T extends object>( export function useDataTableManager<T extends object>(
dataSource: DataSource<T>, dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[], defaultColumns: DataTableColumn<T>[],
onSelect?: (item: T | undefined, index: number) => void,
) { ) {
// TODO: restore from local storage
const [columns, setEffectiveColumns] = useState( const [columns, setEffectiveColumns] = useState(
computeInitialColumns(defaultColumns), computeInitialColumns(defaultColumns),
); );
// TODO: move selection with shifts with index < selection?
// TODO: clear selection if out of range
const [selection, setSelection] = useState(-1);
const [sorting, setSorting] = useState<Sorting | undefined>(undefined); const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const visibleColumns = useMemo( const visibleColumns = useMemo(
@@ -107,6 +110,21 @@ export function useDataTableManager<T extends object>(
); );
}, []); }, []);
const selectItem = useCallback(
(updater: (currentIndex: number) => number) => {
setSelection((currentIndex) => {
const newIndex = updater(currentIndex);
const item =
newIndex >= 0 && newIndex < dataSource.output.length
? dataSource.getItem(newIndex)
: undefined;
onSelect?.(item, newIndex);
return newIndex;
});
},
[setSelection, onSelect, dataSource],
);
useEffect( useEffect(
function applyFilter() { function applyFilter() {
dataSource.setFilter(currentFilter); dataSource.setFilter(currentFilter);
@@ -131,6 +149,9 @@ export function useDataTableManager<T extends object>(
toggleColumnVisibility, toggleColumnVisibility,
/** Active search value */ /** Active search value */
setSearchValue, setSearchValue,
/** current selection, describes the index index in the datasources's current output (not window) */
selection,
selectItem,
}; };
} }