Added selection / keyboard navigation
Summary: per title Reviewed By: nikoant Differential Revision: D26368673 fbshipit-source-id: 7a458e28af1229ee8193dfe2a6d156afd9282acd
This commit is contained in:
committed by
Facebook GitHub Bot
parent
fb7c09c972
commit
1ce665ceaf
@@ -69,9 +69,14 @@ type StateOptions = {
|
||||
|
||||
export function createState<T>(
|
||||
initialValue: T,
|
||||
options?: StateOptions,
|
||||
): Atom<T>;
|
||||
export function createState<T>(): Atom<T | undefined>;
|
||||
export function createState(
|
||||
initialValue: any = undefined,
|
||||
options: StateOptions = {},
|
||||
): Atom<T> {
|
||||
const atom = new AtomValue<T>(initialValue);
|
||||
): Atom<any> {
|
||||
const atom = new AtomValue(initialValue);
|
||||
if (getCurrentPluginInstance() && options.persist) {
|
||||
const {rootStates} = getCurrentPluginInstance()!;
|
||||
if (rootStates[options.persist]) {
|
||||
|
||||
@@ -337,8 +337,12 @@ export class DataSource<
|
||||
return this.reverse ? this.output.length - 1 - viewIndex : viewIndex;
|
||||
}
|
||||
|
||||
getItem(viewIndex: number) {
|
||||
return this.output[this.normalizeIndex(viewIndex)].value;
|
||||
getItem(viewIndex: number): T {
|
||||
return this.getEntry(viewIndex)?.value;
|
||||
}
|
||||
|
||||
getEntry(viewIndex: number): Entry<T> {
|
||||
return this.output[this.normalizeIndex(viewIndex)];
|
||||
}
|
||||
|
||||
notifyItemUpdated(viewIndex: number) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
MutableRefObject,
|
||||
} from 'react';
|
||||
import {DataSource} from '../../state/datasource/DataSource';
|
||||
import {useVirtual} from 'react-virtual';
|
||||
@@ -28,6 +29,8 @@ enum UpdatePrio {
|
||||
HIGH,
|
||||
}
|
||||
|
||||
export type DataSourceVirtualizer = ReturnType<typeof useVirtual>;
|
||||
|
||||
type DataSourceProps<T extends object, C> = {
|
||||
/**
|
||||
* The data source to render
|
||||
@@ -50,6 +53,8 @@ type DataSourceProps<T extends object, C> = {
|
||||
itemRenderer(item: T, index: number, context: C): React.ReactElement;
|
||||
useFixedRowHeight: boolean;
|
||||
defaultRowHeight: number;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
|
||||
_testHeight?: number; // exposed for unit testing only
|
||||
};
|
||||
|
||||
@@ -66,6 +71,8 @@ export const DataSourceRenderer: <T extends object, C>(
|
||||
context,
|
||||
itemRenderer,
|
||||
autoScroll,
|
||||
onKeyDown,
|
||||
virtualizerRef,
|
||||
_testHeight,
|
||||
}: DataSourceProps<any, any>) {
|
||||
/**
|
||||
@@ -89,6 +96,9 @@ export const DataSourceRenderer: <T extends object, C>(
|
||||
estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]),
|
||||
overscan: 0,
|
||||
});
|
||||
if (virtualizerRef) {
|
||||
virtualizerRef.current = virtualizer;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function subscribeToDataSource() {
|
||||
@@ -220,28 +230,30 @@ export const DataSourceRenderer: <T extends object, C>(
|
||||
*/
|
||||
return (
|
||||
<TableContainer onScroll={onScroll} ref={parentRef}>
|
||||
<TableWindow height={virtualizer.totalSize}>
|
||||
{virtualizer.virtualItems.map((virtualRow) => (
|
||||
<TableWindow
|
||||
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.
|
||||
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: useFixedRowHeight ? virtualRow.size : undefined,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
|
||||
{itemRenderer(
|
||||
dataSource.getItem(virtualRow.index),
|
||||
virtualRow.index,
|
||||
context,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: useFixedRowHeight ? virtualRow.size : undefined,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
|
||||
{itemRenderer(entry.value, virtualRow.index, context)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableWindow>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
* @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 {DataSource} from '../../state/datasource/DataSource';
|
||||
import {Layout} from '../Layout';
|
||||
import {TableHead} from './TableHead';
|
||||
import {Percentage} from '../utils/widthUtils';
|
||||
import {DataSourceRenderer} from './DataSourceRenderer';
|
||||
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
|
||||
import {useDataTableManager, TableManager} from './useDataTableManager';
|
||||
import {TableSearch} from './TableSearch';
|
||||
|
||||
@@ -23,6 +30,15 @@ interface DataTableProps<T = any> {
|
||||
autoScroll?: boolean;
|
||||
extraActions?: React.ReactElement;
|
||||
// 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>;
|
||||
_testHeight?: number; // exposed for unit testing only
|
||||
}
|
||||
@@ -38,27 +54,113 @@ export type DataTableColumn<T = any> = {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export interface RenderingConfig<T = any> {
|
||||
export interface RenderContext<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
onClick(item: T, itemId: number): void;
|
||||
}
|
||||
|
||||
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) {
|
||||
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
|
||||
}
|
||||
const {visibleColumns, selectItem, selection} = tableManager;
|
||||
|
||||
const renderingConfig = useMemo(() => {
|
||||
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||
return {
|
||||
columns: tableManager.visibleColumns,
|
||||
columns: visibleColumns,
|
||||
onClick(_, itemIdx) {
|
||||
selectItem(() => itemIdx);
|
||||
},
|
||||
};
|
||||
}, [tableManager.visibleColumns]);
|
||||
}, [visibleColumns, selectItem]);
|
||||
|
||||
const usesWrapping = useMemo(
|
||||
() => tableManager.columns.some((col) => col.wrap),
|
||||
[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 (
|
||||
<Layout.Top>
|
||||
<Layout.Container>
|
||||
@@ -76,30 +178,17 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
onColumnSort={tableManager.sortColumn}
|
||||
/>
|
||||
</Layout.Container>
|
||||
<DataSourceRenderer<any, RenderContext>
|
||||
dataSource={props.dataSource}
|
||||
<DataSourceRenderer<T, RenderContext<T>>
|
||||
dataSource={dataSource}
|
||||
autoScroll={props.autoScroll}
|
||||
useFixedRowHeight={!usesWrapping}
|
||||
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
||||
context={renderingConfig}
|
||||
itemRenderer={itemRenderer}
|
||||
onKeyDown={onKeyDown}
|
||||
virtualizerRef={virtualizerRef}
|
||||
_testHeight={props._testHeight}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,18 +70,22 @@ const TableBodyColumnContainer = styled.div<{
|
||||
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
||||
|
||||
type Props = {
|
||||
config: RenderContext;
|
||||
config: RenderContext<any>;
|
||||
highlighted: boolean;
|
||||
row: any;
|
||||
value: any;
|
||||
itemIndex: number;
|
||||
};
|
||||
|
||||
export const TableRow = memo(function TableRow(props: Props) {
|
||||
const {config, highlighted, row} = props;
|
||||
const {config, highlighted, value: row} = props;
|
||||
return (
|
||||
<TableBodyRowContainer
|
||||
highlighted={highlighted}
|
||||
data-key={row.key}
|
||||
className="ant-table-row">
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.config.onClick(props.value, props.itemIndex);
|
||||
}}>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => {
|
||||
|
||||
@@ -49,21 +49,21 @@ test('update and append', async () => {
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
test DataTable
|
||||
</div>
|
||||
`);
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
@@ -102,7 +102,7 @@ test('column visibility', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
@@ -127,7 +127,7 @@ test('column visibility', async () => {
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
class="css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
|
||||
@@ -27,11 +27,14 @@ export type TableManager = ReturnType<typeof useDataTableManager>;
|
||||
export function useDataTableManager<T extends object>(
|
||||
dataSource: DataSource<T>,
|
||||
defaultColumns: DataTableColumn<T>[],
|
||||
onSelect?: (item: T | undefined, index: number) => void,
|
||||
) {
|
||||
// TODO: restore from local storage
|
||||
const [columns, setEffectiveColumns] = useState(
|
||||
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 [searchValue, setSearchValue] = useState('');
|
||||
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(
|
||||
function applyFilter() {
|
||||
dataSource.setFilter(currentFilter);
|
||||
@@ -131,6 +149,9 @@ export function useDataTableManager<T extends object>(
|
||||
toggleColumnVisibility,
|
||||
/** Active search value */
|
||||
setSearchValue,
|
||||
/** current selection, describes the index index in the datasources's current output (not window) */
|
||||
selection,
|
||||
selectItem,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user