From 1ce665ceafe53f3c22a83dfb18ccbce8bbf332f4 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Added selection / keyboard navigation Summary: per title Reviewed By: nikoant Differential Revision: D26368673 fbshipit-source-id: 7a458e28af1229ee8193dfe2a6d156afd9282acd --- desktop/flipper-plugin/src/state/atom.tsx | 9 +- .../src/state/datasource/DataSource.tsx | 8 +- .../src/ui/datatable/DataSourceRenderer.tsx | 52 ++++--- .../src/ui/datatable/DataTable.tsx | 137 +++++++++++++++--- .../src/ui/datatable/TableRow.tsx | 12 +- .../ui/datatable/__tests__/DataTable.node.tsx | 28 ++-- .../src/ui/datatable/useDataTableManager.tsx | 23 ++- 7 files changed, 202 insertions(+), 67 deletions(-) diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index f66b17cfc..da9ad078e 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -69,9 +69,14 @@ type StateOptions = { export function createState( initialValue: T, + options?: StateOptions, +): Atom; +export function createState(): Atom; +export function createState( + initialValue: any = undefined, options: StateOptions = {}, -): Atom { - const atom = new AtomValue(initialValue); +): Atom { + const atom = new AtomValue(initialValue); if (getCurrentPluginInstance() && options.persist) { const {rootStates} = getCurrentPluginInstance()!; if (rootStates[options.persist]) { diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx index bf00ffa98..9fafa783c 100644 --- a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -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 { + return this.output[this.normalizeIndex(viewIndex)]; } notifyItemUpdated(viewIndex: number) { diff --git a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx index 0ae7c78ba..2e1aac669 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx @@ -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; + type DataSourceProps = { /** * The data source to render @@ -50,6 +53,8 @@ type DataSourceProps = { itemRenderer(item: T, index: number, context: C): React.ReactElement; useFixedRowHeight: boolean; defaultRowHeight: number; + onKeyDown?: React.KeyboardEventHandler; + virtualizerRef?: MutableRefObject; _testHeight?: number; // exposed for unit testing only }; @@ -66,6 +71,8 @@ export const DataSourceRenderer: ( context, itemRenderer, autoScroll, + onKeyDown, + virtualizerRef, _testHeight, }: DataSourceProps) { /** @@ -89,6 +96,9 @@ export const DataSourceRenderer: ( estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]), overscan: 0, }); + if (virtualizerRef) { + virtualizerRef.current = virtualizer; + } useEffect( function subscribeToDataSource() { @@ -220,28 +230,30 @@ export const DataSourceRenderer: ( */ return ( - - {virtualizer.virtualItems.map((virtualRow) => ( + + {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*/} -
- {itemRenderer( - dataSource.getItem(virtualRow.index), - virtualRow.index, - context, - )} -
- ))} + return ( +
+ {itemRenderer(entry.value, virtualRow.index, context)} +
+ ); + })}
); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index fc114ab10..781ab694c 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -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 { 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; _testHeight?: number; // exposed for unit testing only } @@ -38,27 +54,113 @@ export type DataTableColumn = { visible?: boolean; }; -export interface RenderingConfig { +export interface RenderContext { columns: DataTableColumn[]; + onClick(item: T, itemId: number): void; } export function DataTable(props: DataTableProps) { - const tableManager = useDataTableManager(props.dataSource, props.columns); + const {dataSource} = props; + const virtualizerRef = useRef(); + const tableManager = useDataTableManager( + dataSource, + props.columns, + props.onSelect, + ); if (props.tableManagerRef) { (props.tableManagerRef as MutableRefObject).current = tableManager; } + const {visibleColumns, selectItem, selection} = tableManager; - const renderingConfig = useMemo(() => { + const renderingConfig = useMemo>(() => { 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, + ) { + return ( + + ); + }, + [tableManager.selection], + ); + + /** + * Keyboard / selection handling + */ + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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 ( @@ -76,30 +178,17 @@ export function DataTable(props: DataTableProps) { onColumnSort={tableManager.sortColumn} /> - - dataSource={props.dataSource} + > + dataSource={dataSource} autoScroll={props.autoScroll} useFixedRowHeight={!usesWrapping} defaultRowHeight={DEFAULT_ROW_HEIGHT} context={renderingConfig} itemRenderer={itemRenderer} + onKeyDown={onKeyDown} + virtualizerRef={virtualizerRef} _testHeight={props._testHeight} /> ); } - -export type RenderContext = { - columns: DataTableColumn[]; -}; - -function itemRenderer(item: any, index: number, renderContext: RenderContext) { - return ( - - ); -} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index 5a53a72b5..f2326f762 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -70,18 +70,22 @@ const TableBodyColumnContainer = styled.div<{ TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; type Props = { - config: RenderContext; + config: RenderContext; 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 ( + onClick={(e) => { + e.stopPropagation(); + props.config.onClick(props.value, props.itemIndex); + }}> {config.columns .filter((col) => col.visible) .map((col) => { diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx index c286383aa..72aa5afc9 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -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(` +
-
- test DataTable -
-
- true -
+ test DataTable
- `); +
+ true +
+
+ `); } act(() => { @@ -102,7 +102,7 @@ test('column visibility', async () => { expect(elem.length).toBe(1); expect(elem[0].parentElement).toMatchInlineSnapshot(`
{ expect(elem.length).toBe(1); expect(elem[0].parentElement).toMatchInlineSnapshot(`
; export function useDataTableManager( dataSource: DataSource, defaultColumns: DataTableColumn[], + 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(undefined); const [searchValue, setSearchValue] = useState(''); const visibleColumns = useMemo( @@ -107,6 +110,21 @@ export function useDataTableManager( ); }, []); + 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( toggleColumnVisibility, /** Active search value */ setSearchValue, + /** current selection, describes the index index in the datasources's current output (not window) */ + selection, + selectItem, }; }