Introduce multi selection

Summary:
Make sure DataTable supports multiselection, which works largely the same as before, with a few changes

* shift + click and ctrl + click work as expected
* shift + keyboard navigation works as expected
* drag selection works as expected
* drag selection when dragging accross screens, or Shift icmw with HOME / END / PageUp / PageDown works as expect
* text selection stil works as expected

The context menu items have been updated as well
* filter will filter on all the distinct values in the selection
* copying cells will copy all cells of the given column in the selection, separated by newline
* copying rows / creating a past will create a json array of the selection

Not done yet

- Shifting the selection after inserting rows hasn't been implemented yet
- I'm not entirely happy with the context menu trigger, maybe a hamburger button in the toolbar will be better

Reviewed By: nikoant

Differential Revision: D26548228

fbshipit-source-id: 5d1cddd6aad02ce9649d7980ab3a223e222da893
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 5c3a8742ef
commit 59a1327261
7 changed files with 547 additions and 144 deletions

View File

@@ -38,15 +38,8 @@ 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;
onSelect?(item: T | undefined, items: T[]): void;
// multiselect?: true
// onMultiSelect
tableManagerRef?: RefObject<TableManager>;
_testHeight?: number; // exposed for unit testing only
}
@@ -70,13 +63,24 @@ export type DataTableColumn<T = any> = {
export interface RenderContext<T = any> {
columns: DataTableColumn<T>[];
onClick(item: T, itemId: number): void;
onMouseEnter(
e: React.MouseEvent<HTMLDivElement>,
item: T,
itemId: number,
): void;
onMouseDown(
e: React.MouseEvent<HTMLDivElement>,
item: T,
itemId: number,
): void;
}
export function DataTable<T extends object>(props: DataTableProps<T>) {
export function DataTable<T extends object>(
props: DataTableProps<T>,
): React.ReactElement {
const {dataSource} = props;
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
const tableManager = useDataTableManager<T>(
const tableManager = useDataTableManager(
dataSource,
props.columns,
props.onSelect,
@@ -84,16 +88,50 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
}
const {visibleColumns, selectItem, selection} = tableManager;
const {
visibleColumns,
selectItem,
selection,
addRangeToSelection,
addColumnFilter,
getSelectedItem,
getSelectedItems,
} = tableManager;
const renderingConfig = useMemo<RenderContext<T>>(() => {
let dragging = false;
let startIndex = 0;
return {
columns: visibleColumns,
onClick(_, itemIdx) {
selectItem(() => itemIdx);
onMouseEnter(_e, _item, index) {
if (dragging) {
// by computing range we make sure no intermediate items are missed when scrolling fast
addRangeToSelection(startIndex, index);
}
},
onMouseDown(e, _item, index) {
if (!dragging) {
if (e.ctrlKey || e.metaKey) {
addRangeToSelection(index, index, true);
} else if (e.shiftKey) {
selectItem(index, true);
} else {
selectItem(index);
}
dragging = true;
startIndex = index;
function onStopDragSelecting() {
dragging = false;
document.removeEventListener('mouseup', onStopDragSelecting);
}
document.addEventListener('mouseup', onStopDragSelecting);
}
},
};
}, [visibleColumns, selectItem]);
}, [visibleColumns, selectItem, addRangeToSelection]);
const usesWrapping = useMemo(
() => tableManager.columns.some((col) => col.wrap),
@@ -112,11 +150,13 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
config={renderContext}
value={item}
itemIndex={index}
highlighted={index === tableManager.selection}
highlighted={
index === selection.current || selection.items.has(index)
}
/>
);
},
[tableManager.selection],
[selection],
);
/**
@@ -125,34 +165,34 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
const onKeyDown = useCallback(
(e: React.KeyboardEvent<any>) => {
let handled = true;
const shiftPressed = e.shiftKey;
const outputSize = dataSource.output.length;
const windowSize = virtualizerRef.current!.virtualItems.length;
switch (e.key) {
case 'ArrowUp':
selectItem((idx) => (idx > 0 ? idx - 1 : 0));
selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed);
break;
case 'ArrowDown':
selectItem((idx) =>
idx < dataSource.output.length - 1 ? idx + 1 : idx,
selectItem(
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
shiftPressed,
);
break;
case 'Home':
selectItem(() => 0);
selectItem(0, shiftPressed);
break;
case 'End':
selectItem(() => dataSource.output.length - 1);
selectItem(outputSize - 1, shiftPressed);
break;
case ' ': // yes, that is a space
case 'PageDown':
selectItem((idx) =>
Math.min(
dataSource.output.length - 1,
idx + virtualizerRef.current!.virtualItems.length - 1,
),
selectItem(
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
shiftPressed,
);
break;
case 'PageUp':
selectItem((idx) =>
Math.max(0, idx - virtualizerRef.current!.virtualItems.length - 1),
);
selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed);
break;
default:
handled = false;
@@ -167,8 +207,8 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
useLayoutEffect(
function scrollSelectionIntoView() {
if (selection >= 0) {
virtualizerRef.current?.scrollToIndex(selection, {
if (selection && selection.current >= 0) {
virtualizerRef.current?.scrollToIndex(selection!.current, {
align: 'auto',
});
}
@@ -193,14 +233,16 @@ export function DataTable<T extends object>(props: DataTableProps<T>) {
);
/** Context menu */
const contexMenu = !props._testHeight // don't render context menu in tests
? // eslint-disable-next-line
useMemoize(tableContextMenuFactory, [
// TODO: support customizing context menu
const contexMenu = props._testHeight
? undefined // don't render context menu in tests
: // eslint-disable-next-line
useMemoize(tableContextMenuFactory, [
visibleColumns,
tableManager.addColumnFilter,
])
: undefined;
addColumnFilter,
getSelectedItem,
getSelectedItems as any,
]);
return (
<Layout.Container grow>
<Layout.Top>

View File

@@ -19,65 +19,87 @@ import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
const {Item, SubMenu} = Menu;
export const TableContextMenuContext = createContext<
undefined | ((item: any) => React.ReactElement)
React.ReactElement | undefined
>(undefined);
export function tableContextMenuFactory<T>(
visibleColumns: DataTableColumn<T>[],
addColumnFilter: TableManager['addColumnFilter'],
_getSelection: () => T,
getMultiSelection: () => T[],
) {
return function (item: any) {
const lib = tryGetFlipperLibImplementation();
if (!lib) {
return (
<Menu>
<Item>Menu not ready</Item>
</Menu>
);
}
const lib = tryGetFlipperLibImplementation();
if (!lib) {
return (
<Menu>
<SubMenu title="Filter on" icon={<FilterOutlined />}>
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
addColumnFilter(
column.key,
normalizeCellValue(item[column.key]),
true,
);
}}>
{column.title || column.key}
</Item>
))}
</SubMenu>
<SubMenu title="Copy cell" icon={<CopyOutlined />}>
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
lib.writeTextToClipboard(normalizeCellValue(item[column.key]));
}}>
{column.title || column.key}
</Item>
))}
</SubMenu>
<Item
onClick={() => {
lib.writeTextToClipboard(JSON.stringify(item, null, 2));
}}>
Copy row
</Item>
{lib.isFB && (
<Item
onClick={() => {
lib.createPaste(JSON.stringify(item, null, 2));
}}>
Create paste
</Item>
)}
<Item>Menu not ready</Item>
</Menu>
);
};
}
return (
<Menu>
<SubMenu title="Filter on same..." icon={<FilterOutlined />}>
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
const items = getMultiSelection();
if (items.length) {
items.forEach((item, index) => {
addColumnFilter(
column.key,
normalizeCellValue(item[column.key]),
index === 0, // remove existing filters before adding the first
);
});
}
}}>
{column.title || column.key}
</Item>
))}
</SubMenu>
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}>
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
const items = getMultiSelection();
if (items.length) {
lib.writeTextToClipboard(
items
.map((item) => normalizeCellValue(item[column.key]))
.join('\n'),
);
}
}}>
{column.title || column.key}
</Item>
))}
</SubMenu>
<Item
onClick={() => {
const items = getMultiSelection();
if (items.length) {
lib.writeTextToClipboard(
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
);
}
}}>
Copy row(s)
</Item>
{lib.isFB && (
<Item
onClick={() => {
const items = getMultiSelection();
if (items.length) {
lib.createPaste(
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
);
}
}}>
Create paste
</Item>
)}
</Menu>
);
}

View File

@@ -80,6 +80,7 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
borderBottom: `1px solid ${theme.dividerColor}`,
backgroundColor: theme.backgroundWash,
userSelect: 'none',
borderLeft: `4px solid ${theme.backgroundWash}`, // space for selection, see TableRow
});
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';

View File

@@ -26,18 +26,18 @@ type TableBodyRowContainerProps = {
const backgroundColor = (props: TableBodyRowContainerProps) => {
if (props.highlighted) {
return theme.primaryColor;
return theme.backgroundTransparentHover;
}
return undefined;
};
const CircleMargin = 4;
const RowContextMenu = styled(DownCircleFilled)({
const RowContextMenuWrapper = styled.div({
position: 'absolute',
top: CircleMargin,
right: CircleMargin,
top: 0,
right: 0,
paddingRight: CircleMargin,
fontSize: DEFAULT_ROW_HEIGHT - CircleMargin * 2,
borderRadius: (DEFAULT_ROW_HEIGHT - CircleMargin * 2) * 0.5,
color: theme.primaryColor,
cursor: 'pointer',
visibility: 'hidden',
@@ -48,22 +48,15 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
display: 'flex',
flexDirection: 'row',
backgroundColor: backgroundColor(props),
color: props.highlighted ? theme.white : theme.textColorPrimary,
'& *': {
color: props.highlighted ? `${theme.white} !important` : undefined,
},
'& img': {
backgroundColor: props.highlighted
? `${theme.white} !important`
: undefined,
},
borderLeft: props.highlighted
? `4px solid ${theme.primaryColor}`
: `4px solid ${theme.backgroundDefault}`,
minHeight: DEFAULT_ROW_HEIGHT,
overflow: 'hidden',
width: '100%',
flexShrink: 0,
[`&:hover ${RowContextMenu}`]: {
[`&:hover ${RowContextMenuWrapper}`]: {
visibility: 'visible',
color: props.highlighted ? theme.white : undefined,
},
}),
);
@@ -85,9 +78,15 @@ const TableBodyColumnContainer = styled.div<{
width: props.width,
justifyContent: props.justifyContent,
borderBottom: `1px solid ${theme.dividerColor}`,
'&::selection': {
color: 'inherit',
backgroundColor: theme.buttonDefaultBackground,
},
}));
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
const contextMenuTriggers = ['click' as const, 'contextMenu' as const];
type Props = {
config: RenderContext<any>;
highlighted: boolean;
@@ -98,14 +97,15 @@ type Props = {
export const TableRow = memo(function TableRow(props: Props) {
const {config, highlighted, value: row} = props;
const menu = useContext(TableContextMenuContext);
return (
<TableBodyRowContainer
highlighted={highlighted}
data-key={row.key}
onClick={(e) => {
e.stopPropagation();
props.config.onClick(props.value, props.itemIndex);
onMouseDown={(e) => {
props.config.onMouseDown(e, props.value, props.itemIndex);
}}
onMouseEnter={(e) => {
props.config.onMouseEnter(e, props.value, props.itemIndex);
}}>
{config.columns
.filter((col) => col.visible)
@@ -125,18 +125,26 @@ export const TableRow = memo(function TableRow(props: Props) {
</TableBodyColumnContainer>
);
})}
{menu && (
<Dropdown
overlay={menu(row)}
placement="bottomRight"
trigger={['click', 'contextMenu']}>
<RowContextMenu />
</Dropdown>
{menu && highlighted && (
<RowContextMenuWrapper
onClick={stopPropagation}
onMouseDown={stopPropagation}>
<Dropdown
overlay={menu}
placement="bottomRight"
trigger={contextMenuTriggers}>
<DownCircleFilled />
</Dropdown>
</RowContextMenuWrapper>
)}
</TableBodyRowContainer>
);
});
function stopPropagation(e: React.MouseEvent<any>) {
e.stopPropagation();
}
export function normalizeCellValue(value: any): string {
switch (typeof value) {
case 'boolean':

View File

@@ -55,15 +55,15 @@ test('update and append', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-1rnoidw-TableBodyRowContainer efe0za01"
class="css-tihkal-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -112,15 +112,15 @@ test('column visibility', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-1rnoidw-TableBodyRowContainer efe0za01"
class="css-tihkal-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -137,10 +137,10 @@ test('column visibility', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-1rnoidw-TableBodyRowContainer efe0za01"
class="css-tihkal-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
class="ant-table-cell css-1u65yt0-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
@@ -510,3 +510,114 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([]);
}
});
test('onSelect callback fires, and in order', () => {
const events: any[] = [];
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const rendering = render(
<DataTable
dataSource={ds}
columns={columns}
tableManagerRef={ref}
_testHeight={400}
onSelect={(item, items) => {
events.push([item, items]);
}}
/>,
);
const item1 = {
title: 'item 1',
done: false,
};
const item2 = {
title: 'item 2',
done: false,
};
const item3 = {
title: 'item 3',
done: false,
};
act(() => {
ds.clear();
ds.append(item1);
ds.append(item2);
ds.append(item3);
ref.current!.selectItem(2);
});
expect(events.splice(0)).toEqual([
[undefined, []],
[item3, [item3]],
]);
act(() => {
ref.current!.addRangeToSelection(0, 0);
});
expect(events.splice(0)).toEqual([
[item1, [item1, item3]], // order preserved!
]);
rendering.unmount();
});
test('selection always has the latest state', () => {
const events: any[] = [];
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const rendering = render(
<DataTable
dataSource={ds}
columns={columns}
tableManagerRef={ref}
_testHeight={400}
onSelect={(item, items) => {
events.push([item, items]);
}}
/>,
);
const item1 = {
title: 'item 1',
done: false,
};
const item2 = {
title: 'item 2',
done: false,
};
const item3 = {
title: 'item 3',
done: false,
};
act(() => {
ds.clear();
ds.append(item1);
ds.append(item2);
ds.append(item3);
ref.current!.selectItem(2);
});
expect(events.splice(0)).toEqual([
[undefined, []],
[item3, [item3]],
]);
const item3updated = {
title: 'item 3 updated',
done: false,
};
act(() => {
ds.update(2, item3updated);
});
act(() => {
ref.current!.addRangeToSelection(0, 0);
});
expect(events.splice(0)).toEqual([
[item1, [item1, item3updated]], // update reflected in callback!
]);
rendering.unmount();
});

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
computeAddRangeToSelection,
computeSetSelection,
} from '../useDataTableManager';
test('computeSetSelection', () => {
const emptyBase = {
current: -1,
items: new Set<number>(),
};
const partialBase = {
current: 7,
items: new Set([2, 3, 8, 9]),
};
// set selection
expect(computeSetSelection(emptyBase, 2)).toEqual({
current: 2,
items: new Set([2]),
});
// move selection 2 down
expect(computeSetSelection(partialBase, (x) => x + 2)).toEqual({
current: 9,
items: new Set([9]),
});
// expand selection
expect(computeSetSelection(partialBase, (x) => x + 5, true)).toEqual({
current: 12,
items: new Set([2, 3, 7, 8, 9, 10, 11, 12]),
});
// expand selection backward
expect(computeSetSelection(partialBase, 5, true)).toEqual({
current: 5,
items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant
});
});
test('computeAddRangeToSelection', () => {
const emptyBase = {
current: -1,
items: new Set<number>(),
};
const partialBase = {
current: 7,
items: new Set([2, 3, 8, 9]),
};
// add range selection
expect(computeAddRangeToSelection(emptyBase, 23, 25)).toEqual({
current: 25,
items: new Set([23, 24, 25]),
});
// add range selection
expect(computeAddRangeToSelection(partialBase, 23, 25)).toEqual({
current: 25,
items: new Set([2, 3, 8, 9, 23, 24, 25]),
});
// add range backward
expect(computeAddRangeToSelection(partialBase, 25, 23)).toEqual({
current: 23,
items: new Set([2, 3, 8, 9, 23, 24, 25]),
});
// invest selection - toggle off
expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({
current: 8, // note: this item is not part of the selection!
items: new Set([2, 3, 9]),
});
// invest selection - toggle on
expect(computeAddRangeToSelection(partialBase, 5, 5, true)).toEqual({
current: 5,
items: new Set([2, 3, 5, 8, 9]),
});
});

View File

@@ -10,7 +10,7 @@
import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../../utils/widthUtils';
import produce from 'immer';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize';
@@ -22,20 +22,30 @@ export type Sorting = {
export type TableManager = ReturnType<typeof useDataTableManager>;
type Selection = {items: ReadonlySet<number>; current: number};
const emptySelection: Selection = {
items: new Set(),
current: -1,
};
/**
* A hook that coordinates filtering, sorting etc for a DataSource
*/
export function useDataTableManager<T extends object>(
export function useDataTableManager<T>(
dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[],
onSelect?: (item: T | undefined, index: number) => void,
onSelect?: (item: T | undefined, items: T[]) => void,
) {
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 [selection, setSelection] = useState<Selection>(emptySelection);
const selectionRef = useRef(selection);
selectionRef.current = selection; // store last seen selection for fetching it later
const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const [searchValue, setSearchValue] = useState('');
const visibleColumns = useMemo(
@@ -102,6 +112,7 @@ export function useDataTableManager<T extends object>(
setEffectiveColumns(computeInitialColumns(defaultColumns));
setSorting(undefined);
setSearchValue('');
setSelection(emptySelection);
dataSource.reset();
}, [dataSource, defaultColumns]);
@@ -148,21 +159,6 @@ 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);
@@ -170,6 +166,65 @@ export function useDataTableManager<T extends object>(
[currentFilter, dataSource],
);
/**
* Select an individual item, used by mouse clicks and keyboard navigation
* Set addToSelection if the current selection should be expanded to the given position,
* rather than replacing the current selection.
*
* The nextIndex can be used to compute the new selection by basing relatively to the current selection
*/
const selectItem = useCallback(
(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
) => {
setSelection((base) =>
computeSetSelection(base, nextIndex, addToSelection),
);
},
[],
);
/**
* Adds a range of items to the current seleciton (if any)
*/
const addRangeToSelection = useCallback(
(start: number, end: number, allowUnselect?: boolean) => {
setSelection((base) =>
computeAddRangeToSelection(base, start, end, allowUnselect),
);
},
[],
);
// N.B: we really want to have stable refs for these functions,
// to avoid that all context menus need re-render for every selection change,
// hence the selectionRef hack
const getSelectedItem = useCallback(() => {
return selectionRef.current.current < 0
? undefined
: dataSource.getItem(selectionRef.current.current);
}, [dataSource]);
const getSelectedItems = useCallback(() => {
return [...selectionRef.current.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean);
}, [dataSource]);
useEffect(
function fireSelection() {
if (onSelect) {
const item = getSelectedItem();
const items = getSelectedItems();
onSelect(item, items);
}
},
// selection is intentionally a dep
[onSelect, selection, selection, getSelectedItem, getSelectedItems],
);
return {
/** The default columns, but normalized */
columns,
@@ -190,6 +245,9 @@ export function useDataTableManager<T extends object>(
/** current selection, describes the index index in the datasources's current output (not window) */
selection,
selectItem,
addRangeToSelection,
getSelectedItem,
getSelectedItems,
/** Changing column filters */
addColumnFilter,
removeColumnFilter,
@@ -244,3 +302,73 @@ export function computeDataTableFilter(
);
};
}
export function computeSetSelection(
base: Selection,
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
): Selection {
const newIndex =
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
if (newIndex < 0) {
return emptySelection;
}
if (base.current < 0 || !addToSelection) {
return {
current: newIndex,
items: new Set([newIndex]),
};
} else {
const lowest = Math.min(base.current, newIndex);
const highest = Math.max(base.current, newIndex);
return {
current: newIndex,
items: addIndicesToMultiSelection(base.items, lowest, highest),
};
}
}
export function computeAddRangeToSelection(
base: Selection,
start: number,
end: number,
allowUnselect?: boolean,
): Selection {
// special case: unselectiong a single existing item
if (start === end && allowUnselect) {
if (base?.items.has(start)) {
const copy = new Set(base.items);
copy.delete(start);
if (copy.size === 0) {
return emptySelection;
}
return {
items: copy,
current: start,
};
}
// intentional fall-through
}
// N.B. start and end can be reverted if selecting backwards
const lowest = Math.min(start, end);
const highest = Math.max(start, end);
const current = end;
return {
items: addIndicesToMultiSelection(base.items, lowest, highest),
current,
};
}
function addIndicesToMultiSelection(
base: ReadonlySet<number>,
lowest: number,
highest: number,
): ReadonlySet<number> {
const copy = new Set(base);
for (let i = lowest; i <= highest; i++) {
copy.add(i);
}
return copy;
}