User experience improvements
Summary: This diff has some jak-shaving UX improvements after playing with the DataTable a bit more: Selection * deselecting a row from a larger set will make the last selected item the default selection * re-selecting an item in a single selection will unselect it Column Filtering * Introduced button to toggle between filtering on all, nothing, and the values present in the current selection Column sorting * The up / down arrows are now inidividually clickable, rather than action as a general toggle * Title still works as a general toggle between asc / desc / not sorted Context menu * I found the context menu for column selection and on the selected rows itself a bit finicky to find and click and not super intuitive for noob users. Merged both menus instead into a single hamburger menu adjacent to the search bar Reviewed By: passy Differential Revision: D26580038 fbshipit-source-id: 220f501a1d996acbd51088c08ea866caed768572
This commit is contained in:
committed by
Facebook GitHub Bot
parent
59a1327261
commit
59e6c98669
@@ -299,6 +299,7 @@ export class DataSource<
|
|||||||
setReversed(reverse: boolean) {
|
setReversed(reverse: boolean) {
|
||||||
if (this.reverse !== reverse) {
|
if (this.reverse !== reverse) {
|
||||||
this.reverse = reverse;
|
this.reverse = reverse;
|
||||||
|
// TODO: not needed anymore
|
||||||
this.rebuildOutput();
|
this.rebuildOutput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ColumnFilterHandlers = {
|
|||||||
onAddColumnFilter(columnId: string, value: string): void;
|
onAddColumnFilter(columnId: string, value: string): void;
|
||||||
onRemoveColumnFilter(columnId: string, index: number): void;
|
onRemoveColumnFilter(columnId: string, index: number): void;
|
||||||
onToggleColumnFilter(columnId: string, index: number): void;
|
onToggleColumnFilter(columnId: string, index: number): void;
|
||||||
|
onSetColumnFilterFromSelection(columnId: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilterIcon({
|
export function FilterIcon({
|
||||||
@@ -98,6 +99,37 @@ export function FilterIcon({
|
|||||||
No active filters
|
No active filters
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
<div style={{textAlign: 'right'}}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{fontWeight: 'unset'}}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSetColumnFilterFromSelection(column.key);
|
||||||
|
}}>
|
||||||
|
From selection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{fontWeight: 'unset'}}
|
||||||
|
onClick={() => {
|
||||||
|
filters?.forEach((f, index) => {
|
||||||
|
if (!f.enabled) props.onToggleColumnFilter(column.key, index);
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{fontWeight: 'unset'}}
|
||||||
|
onClick={() => {
|
||||||
|
filters?.forEach((f, index) => {
|
||||||
|
if (f.enabled) props.onToggleColumnFilter(column.key, index);
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ import {useDataTableManager, TableManager} from './useDataTableManager';
|
|||||||
import {TableSearch} from './TableSearch';
|
import {TableSearch} from './TableSearch';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
import {
|
import {tableContextMenuFactory} from './TableContextMenu';
|
||||||
tableContextMenuFactory,
|
|
||||||
TableContextMenuContext,
|
|
||||||
} from './TableContextMenu';
|
|
||||||
import {useMemoize} from '../../utils/useMemoize';
|
|
||||||
|
|
||||||
interface DataTableProps<T = any> {
|
interface DataTableProps<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
@@ -93,9 +89,6 @@ export function DataTable<T extends object>(
|
|||||||
selectItem,
|
selectItem,
|
||||||
selection,
|
selection,
|
||||||
addRangeToSelection,
|
addRangeToSelection,
|
||||||
addColumnFilter,
|
|
||||||
getSelectedItem,
|
|
||||||
getSelectedItems,
|
|
||||||
} = tableManager;
|
} = tableManager;
|
||||||
|
|
||||||
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||||
@@ -236,13 +229,8 @@ export function DataTable<T extends object>(
|
|||||||
// TODO: support customizing context menu
|
// TODO: support customizing context menu
|
||||||
const contexMenu = props._testHeight
|
const contexMenu = props._testHeight
|
||||||
? undefined // don't render context menu in tests
|
? undefined // don't render context menu in tests
|
||||||
: // eslint-disable-next-line
|
: tableContextMenuFactory(tableManager);
|
||||||
useMemoize(tableContextMenuFactory, [
|
|
||||||
visibleColumns,
|
|
||||||
addColumnFilter,
|
|
||||||
getSelectedItem,
|
|
||||||
getSelectedItems as any,
|
|
||||||
]);
|
|
||||||
return (
|
return (
|
||||||
<Layout.Container grow>
|
<Layout.Container grow>
|
||||||
<Layout.Top>
|
<Layout.Top>
|
||||||
@@ -250,21 +238,22 @@ export function DataTable<T extends object>(
|
|||||||
<TableSearch
|
<TableSearch
|
||||||
onSearch={tableManager.setSearchValue}
|
onSearch={tableManager.setSearchValue}
|
||||||
extraActions={props.extraActions}
|
extraActions={props.extraActions}
|
||||||
|
contextMenu={contexMenu}
|
||||||
/>
|
/>
|
||||||
<TableHead
|
<TableHead
|
||||||
columns={tableManager.columns}
|
|
||||||
visibleColumns={tableManager.visibleColumns}
|
visibleColumns={tableManager.visibleColumns}
|
||||||
onColumnResize={tableManager.resizeColumn}
|
onColumnResize={tableManager.resizeColumn}
|
||||||
onReset={tableManager.reset}
|
onReset={tableManager.reset}
|
||||||
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
|
|
||||||
sorting={tableManager.sorting}
|
sorting={tableManager.sorting}
|
||||||
onColumnSort={tableManager.sortColumn}
|
onColumnSort={tableManager.sortColumn}
|
||||||
onAddColumnFilter={tableManager.addColumnFilter}
|
onAddColumnFilter={tableManager.addColumnFilter}
|
||||||
onRemoveColumnFilter={tableManager.removeColumnFilter}
|
onRemoveColumnFilter={tableManager.removeColumnFilter}
|
||||||
onToggleColumnFilter={tableManager.toggleColumnFilter}
|
onToggleColumnFilter={tableManager.toggleColumnFilter}
|
||||||
|
onSetColumnFilterFromSelection={
|
||||||
|
tableManager.setColumnFilterFromSelection
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Layout.Container>
|
</Layout.Container>
|
||||||
<TableContextMenuContext.Provider value={contexMenu}>
|
|
||||||
<DataSourceRenderer<T, RenderContext<T>>
|
<DataSourceRenderer<T, RenderContext<T>>
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
autoScroll={props.autoScroll}
|
autoScroll={props.autoScroll}
|
||||||
@@ -277,7 +266,6 @@ export function DataTable<T extends object>(
|
|||||||
onRangeChange={onRangeChange}
|
onRangeChange={onRangeChange}
|
||||||
_testHeight={props._testHeight}
|
_testHeight={props._testHeight}
|
||||||
/>
|
/>
|
||||||
</TableContextMenuContext.Provider>
|
|
||||||
</Layout.Top>
|
</Layout.Top>
|
||||||
{range && <RangeFinder>{range}</RangeFinder>}
|
{range && <RangeFinder>{range}</RangeFinder>}
|
||||||
</Layout.Container>
|
</Layout.Container>
|
||||||
|
|||||||
@@ -8,26 +8,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
|
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
|
||||||
import {Menu} from 'antd';
|
import {Checkbox, Menu} from 'antd';
|
||||||
import {DataTableColumn} from './DataTable';
|
|
||||||
import {TableManager} from './useDataTableManager';
|
import {TableManager} from './useDataTableManager';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {createContext} from 'react';
|
|
||||||
import {normalizeCellValue} from './TableRow';
|
import {normalizeCellValue} from './TableRow';
|
||||||
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
|
||||||
|
import {DataTableColumn} from './DataTable';
|
||||||
|
|
||||||
const {Item, SubMenu} = Menu;
|
const {Item, SubMenu} = Menu;
|
||||||
|
|
||||||
export const TableContextMenuContext = createContext<
|
export function tableContextMenuFactory(tableManager: TableManager) {
|
||||||
React.ReactElement | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export function tableContextMenuFactory<T>(
|
|
||||||
visibleColumns: DataTableColumn<T>[],
|
|
||||||
addColumnFilter: TableManager['addColumnFilter'],
|
|
||||||
_getSelection: () => T,
|
|
||||||
getMultiSelection: () => T[],
|
|
||||||
) {
|
|
||||||
const lib = tryGetFlipperLibImplementation();
|
const lib = tryGetFlipperLibImplementation();
|
||||||
if (!lib) {
|
if (!lib) {
|
||||||
return (
|
return (
|
||||||
@@ -36,34 +26,32 @@ export function tableContextMenuFactory<T>(
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const hasSelection = tableManager.selection?.items.size > 0 ?? false;
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<SubMenu title="Filter on same..." icon={<FilterOutlined />}>
|
<SubMenu
|
||||||
{visibleColumns.map((column) => (
|
title="Filter on same"
|
||||||
|
icon={<FilterOutlined />}
|
||||||
|
disabled={!hasSelection}>
|
||||||
|
{tableManager.visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = getMultiSelection();
|
tableManager.setColumnFilterFromSelection(column.key);
|
||||||
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}
|
{friendlyColumnTitle(column)}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}>
|
<SubMenu
|
||||||
{visibleColumns.map((column) => (
|
title="Copy cell(s)"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
disabled={!hasSelection}>
|
||||||
|
{tableManager.visibleColumns.map((column) => (
|
||||||
<Item
|
<Item
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = getMultiSelection();
|
const items = tableManager.getSelectedItems();
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.writeTextToClipboard(
|
lib.writeTextToClipboard(
|
||||||
items
|
items
|
||||||
@@ -72,13 +60,14 @@ export function tableContextMenuFactory<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{column.title || column.key}
|
{friendlyColumnTitle(column)}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
<Item
|
<Item
|
||||||
|
disabled={!hasSelection}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = getMultiSelection();
|
const items = tableManager.getSelectedItems();
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.writeTextToClipboard(
|
lib.writeTextToClipboard(
|
||||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
@@ -89,8 +78,9 @@ export function tableContextMenuFactory<T>(
|
|||||||
</Item>
|
</Item>
|
||||||
{lib.isFB && (
|
{lib.isFB && (
|
||||||
<Item
|
<Item
|
||||||
|
disabled={!hasSelection}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = getMultiSelection();
|
const items = tableManager.getSelectedItems();
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.createPaste(
|
lib.createPaste(
|
||||||
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
|
||||||
@@ -100,6 +90,30 @@ export function tableContextMenuFactory<T>(
|
|||||||
Create paste
|
Create paste
|
||||||
</Item>
|
</Item>
|
||||||
)}
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
<SubMenu title="Visible columns">
|
||||||
|
{tableManager.columns.map((column) => (
|
||||||
|
<Menu.Item key={column.key}>
|
||||||
|
<Checkbox
|
||||||
|
checked={column.visible}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
tableManager.toggleColumnVisibility(column.key);
|
||||||
|
}}>
|
||||||
|
{friendlyColumnTitle(column)}
|
||||||
|
</Checkbox>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</SubMenu>
|
||||||
|
<Menu.Item key="reset" onClick={tableManager.reset}>
|
||||||
|
Reset view
|
||||||
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function friendlyColumnTitle(column: DataTableColumn<any>): string {
|
||||||
|
const name = column.title || column.key;
|
||||||
|
return name[0].toUpperCase() + name.substr(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,23 +20,37 @@ import React from 'react';
|
|||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
import type {DataTableColumn} from './DataTable';
|
import type {DataTableColumn} from './DataTable';
|
||||||
|
|
||||||
import {Checkbox, Dropdown, Menu, Typography} from 'antd';
|
import {Typography} from 'antd';
|
||||||
import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
|
import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
|
||||||
import {Layout} from '../Layout';
|
import {Layout} from '../Layout';
|
||||||
import {Sorting, OnColumnResize} from './useDataTableManager';
|
import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager';
|
||||||
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
|
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
|
||||||
|
|
||||||
const {Text} = Typography;
|
const {Text} = Typography;
|
||||||
|
|
||||||
function SortIcons({direction}: {direction?: 'up' | 'down'}) {
|
function SortIcons({
|
||||||
|
direction,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
direction?: SortDirection;
|
||||||
|
onSort(direction: SortDirection): void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SortIconsContainer direction={direction}>
|
<SortIconsContainer direction={direction}>
|
||||||
<CaretUpFilled
|
<CaretUpFilled
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSort(direction === 'up' ? undefined : 'up');
|
||||||
|
}}
|
||||||
className={
|
className={
|
||||||
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
|
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CaretDownFilled
|
<CaretDownFilled
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSort(direction === 'down' ? undefined : 'down');
|
||||||
|
}}
|
||||||
className={
|
className={
|
||||||
'ant-table-column-sorter-down ' +
|
'ant-table-column-sorter-down ' +
|
||||||
(direction === 'down' ? 'active' : '')
|
(direction === 'down' ? 'active' : '')
|
||||||
@@ -55,25 +69,41 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
left: 4,
|
left: 4,
|
||||||
top: -3,
|
top: -3,
|
||||||
|
cursor: 'pointer',
|
||||||
color: theme.disabledColor,
|
color: theme.disabledColor,
|
||||||
|
'.ant-table-column-sorter-up:hover, .ant-table-column-sorter-down:hover': {
|
||||||
|
color: theme.primaryColor,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SettingsButton = styled(HeaderButton)({
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
|
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
borderRight: `1px solid ${theme.dividerColor}`,
|
||||||
|
paddingRight: 4,
|
||||||
});
|
});
|
||||||
TableHeaderColumnInteractive.displayName =
|
TableHeaderColumnInteractive.displayName =
|
||||||
'TableHead:TableHeaderColumnInteractive';
|
'TableHead:TableHeaderColumnInteractive';
|
||||||
|
|
||||||
const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
|
const TableHeadColumnContainer = styled.div<{
|
||||||
|
width: Width;
|
||||||
|
}>((props) => ({
|
||||||
|
flexShrink: props.width === undefined ? 1 : 0,
|
||||||
|
flexGrow: props.width === undefined ? 1 : 0,
|
||||||
|
width: props.width === undefined ? '100%' : props.width,
|
||||||
|
paddingLeft: 4,
|
||||||
|
[`:hover ${SortIconsContainer}`]: {
|
||||||
|
visibility: 'visible',
|
||||||
|
},
|
||||||
|
[`&:hover ${HeaderButton}`]: {
|
||||||
|
visibility: 'visible !important' as any,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
|
||||||
|
|
||||||
|
const TableHeadContainer = styled.div({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -84,25 +114,6 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
|
|||||||
});
|
});
|
||||||
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
|
||||||
|
|
||||||
const TableHeadColumnContainer = styled.div<{
|
|
||||||
width: Width;
|
|
||||||
}>((props) => ({
|
|
||||||
flexShrink: props.width === undefined ? 1 : 0,
|
|
||||||
flexGrow: props.width === undefined ? 1 : 0,
|
|
||||||
width: props.width === undefined ? '100%' : props.width,
|
|
||||||
'&:last-of-type': {
|
|
||||||
marginRight: 20, // space for settings button
|
|
||||||
},
|
|
||||||
[`:hover ${SortIconsContainer}`]: {
|
|
||||||
visibility: 'visible',
|
|
||||||
},
|
|
||||||
[`&:hover ${HeaderButton}`]: {
|
|
||||||
visibility: 'visible !important' as any,
|
|
||||||
},
|
|
||||||
padding: '0 4px',
|
|
||||||
}));
|
|
||||||
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
|
|
||||||
|
|
||||||
const RIGHT_RESIZABLE = {right: true};
|
const RIGHT_RESIZABLE = {right: true};
|
||||||
|
|
||||||
function TableHeadColumn({
|
function TableHeadColumn({
|
||||||
@@ -116,7 +127,7 @@ function TableHeadColumn({
|
|||||||
column: DataTableColumn<any>;
|
column: DataTableColumn<any>;
|
||||||
sorted: 'up' | 'down' | undefined;
|
sorted: 'up' | 'down' | undefined;
|
||||||
isResizable: boolean;
|
isResizable: boolean;
|
||||||
onSort: (id: string) => void;
|
onSort: (id: string, direction: SortDirection) => void;
|
||||||
sortOrder: undefined | Sorting;
|
sortOrder: undefined | Sorting;
|
||||||
onColumnResize: OnColumnResize;
|
onColumnResize: OnColumnResize;
|
||||||
} & ColumnFilterHandlers) {
|
} & ColumnFilterHandlers) {
|
||||||
@@ -150,11 +161,23 @@ function TableHeadColumn({
|
|||||||
};
|
};
|
||||||
|
|
||||||
let children = (
|
let children = (
|
||||||
<Layout.Right center style={{padding: '0 4px'}}>
|
<Layout.Right center>
|
||||||
<div onClick={() => onSort(column.key)} role="button" tabIndex={0}>
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSort(
|
||||||
|
column.key,
|
||||||
|
sorted === 'up' ? undefined : sorted === 'down' ? 'up' : 'down',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}>
|
||||||
<Text strong>
|
<Text strong>
|
||||||
{column.title ?? <> </>}
|
{column.title ?? <> </>}
|
||||||
<SortIcons direction={sorted} />
|
<SortIcons
|
||||||
|
direction={sorted}
|
||||||
|
onSort={(dir) => onSort(column.key, dir)}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<FilterIcon column={column} {...filterHandlers} />
|
<FilterIcon column={column} {...filterHandlers} />
|
||||||
@@ -181,40 +204,15 @@ function TableHeadColumn({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TableHead = memo(function TableHead({
|
export const TableHead = memo(function TableHead({
|
||||||
columns,
|
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
columns: DataTableColumn<any>[];
|
|
||||||
visibleColumns: DataTableColumn<any>[];
|
visibleColumns: DataTableColumn<any>[];
|
||||||
onColumnResize: OnColumnResize;
|
onColumnResize: OnColumnResize;
|
||||||
onColumnToggleVisibility: (key: string) => void;
|
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
sorting: Sorting | undefined;
|
sorting: Sorting | undefined;
|
||||||
onColumnSort: (key: string) => void;
|
onColumnSort: (key: string, direction: SortDirection) => void;
|
||||||
} & ColumnFilterHandlers) {
|
} & ColumnFilterHandlers) {
|
||||||
const menu = (
|
|
||||||
<Menu style={{minWidth: 200}}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<Menu.Item key={column.key}>
|
|
||||||
<Checkbox
|
|
||||||
checked={column.visible}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
props.onColumnToggleVisibility(column.key);
|
|
||||||
}}>
|
|
||||||
{column.title || column.key}
|
|
||||||
</Checkbox>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item key="reset" onClick={props.onReset}>
|
|
||||||
Reset
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHeadContainer>
|
<TableHeadContainer>
|
||||||
{visibleColumns.map((column, i) => (
|
{visibleColumns.map((column, i) => (
|
||||||
@@ -228,6 +226,7 @@ export const TableHead = memo(function TableHead({
|
|||||||
onAddColumnFilter={props.onAddColumnFilter}
|
onAddColumnFilter={props.onAddColumnFilter}
|
||||||
onRemoveColumnFilter={props.onRemoveColumnFilter}
|
onRemoveColumnFilter={props.onRemoveColumnFilter}
|
||||||
onToggleColumnFilter={props.onToggleColumnFilter}
|
onToggleColumnFilter={props.onToggleColumnFilter}
|
||||||
|
onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection}
|
||||||
sorted={
|
sorted={
|
||||||
props.sorting?.key === column.key
|
props.sorting?.key === column.key
|
||||||
? props.sorting!.direction
|
? props.sorting!.direction
|
||||||
@@ -235,11 +234,6 @@ export const TableHead = memo(function TableHead({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Dropdown overlay={menu} trigger={['click']}>
|
|
||||||
<SettingsButton type="text">
|
|
||||||
<DownOutlined />
|
|
||||||
</SettingsButton>
|
|
||||||
</Dropdown>
|
|
||||||
</TableHeadContainer>
|
</TableHeadContainer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,15 +7,12 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {memo, useContext} from 'react';
|
import React, {memo} from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {theme} from 'flipper-plugin';
|
import {theme} from 'flipper-plugin';
|
||||||
import type {RenderContext} from './DataTable';
|
import type {RenderContext} from './DataTable';
|
||||||
import {Width} from '../../utils/widthUtils';
|
import {Width} from '../../utils/widthUtils';
|
||||||
import {pad} from 'lodash';
|
import {pad} from 'lodash';
|
||||||
import {DownCircleFilled} from '@ant-design/icons';
|
|
||||||
import {Dropdown} from 'antd';
|
|
||||||
import {TableContextMenuContext} from './TableContextMenu';
|
|
||||||
|
|
||||||
// heuristic for row estimation, should match any future styling updates
|
// heuristic for row estimation, should match any future styling updates
|
||||||
export const DEFAULT_ROW_HEIGHT = 24;
|
export const DEFAULT_ROW_HEIGHT = 24;
|
||||||
@@ -85,8 +82,6 @@ const TableBodyColumnContainer = styled.div<{
|
|||||||
}));
|
}));
|
||||||
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
||||||
|
|
||||||
const contextMenuTriggers = ['click' as const, 'contextMenu' as const];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
config: RenderContext<any>;
|
config: RenderContext<any>;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
@@ -96,7 +91,6 @@ type Props = {
|
|||||||
|
|
||||||
export const TableRow = memo(function TableRow(props: Props) {
|
export const TableRow = memo(function TableRow(props: Props) {
|
||||||
const {config, highlighted, value: row} = props;
|
const {config, highlighted, value: row} = props;
|
||||||
const menu = useContext(TableContextMenuContext);
|
|
||||||
return (
|
return (
|
||||||
<TableBodyRowContainer
|
<TableBodyRowContainer
|
||||||
highlighted={highlighted}
|
highlighted={highlighted}
|
||||||
@@ -125,26 +119,10 @@ export const TableRow = memo(function TableRow(props: Props) {
|
|||||||
</TableBodyColumnContainer>
|
</TableBodyColumnContainer>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{menu && highlighted && (
|
|
||||||
<RowContextMenuWrapper
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onMouseDown={stopPropagation}>
|
|
||||||
<Dropdown
|
|
||||||
overlay={menu}
|
|
||||||
placement="bottomRight"
|
|
||||||
trigger={contextMenuTriggers}>
|
|
||||||
<DownCircleFilled />
|
|
||||||
</Dropdown>
|
|
||||||
</RowContextMenuWrapper>
|
|
||||||
)}
|
|
||||||
</TableBodyRowContainer>
|
</TableBodyRowContainer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function stopPropagation(e: React.MouseEvent<any>) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCellValue(value: any): string {
|
export function normalizeCellValue(value: any): string {
|
||||||
switch (typeof value) {
|
switch (typeof value) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Input} from 'antd';
|
import {MenuOutlined} from '@ant-design/icons';
|
||||||
|
import {Button, Dropdown, Input} from 'antd';
|
||||||
import React, {memo} from 'react';
|
import React, {memo} from 'react';
|
||||||
import {Layout} from '../Layout';
|
import {Layout} from '../Layout';
|
||||||
import {theme} from '../theme';
|
import {theme} from '../theme';
|
||||||
@@ -15,9 +16,12 @@ import {theme} from '../theme';
|
|||||||
export const TableSearch = memo(function TableSearch({
|
export const TableSearch = memo(function TableSearch({
|
||||||
onSearch,
|
onSearch,
|
||||||
extraActions,
|
extraActions,
|
||||||
|
contextMenu,
|
||||||
}: {
|
}: {
|
||||||
onSearch(value: string): void;
|
onSearch(value: string): void;
|
||||||
extraActions?: React.ReactElement;
|
extraActions?: React.ReactElement;
|
||||||
|
hasSelection?: boolean;
|
||||||
|
contextMenu?: React.ReactElement;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Layout.Horizontal
|
<Layout.Horizontal
|
||||||
@@ -28,6 +32,13 @@ export const TableSearch = memo(function TableSearch({
|
|||||||
}}>
|
}}>
|
||||||
<Input.Search allowClear placeholder="Search..." onSearch={onSearch} />
|
<Input.Search allowClear placeholder="Search..." onSearch={onSearch} />
|
||||||
{extraActions}
|
{extraActions}
|
||||||
|
{contextMenu && (
|
||||||
|
<Dropdown overlay={contextMenu} placement="bottomRight">
|
||||||
|
<Button type="text" size="small" style={{height: '100%'}}>
|
||||||
|
<MenuOutlined />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
</Layout.Horizontal>
|
</Layout.Horizontal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ test('sorting', async () => {
|
|||||||
}
|
}
|
||||||
// sort asc
|
// sort asc
|
||||||
act(() => {
|
act(() => {
|
||||||
ref.current?.sortColumn('title');
|
ref.current?.sortColumn('title', 'down');
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
const elem = await rendering.findAllByText(/item/);
|
const elem = await rendering.findAllByText(/item/);
|
||||||
@@ -208,7 +208,7 @@ test('sorting', async () => {
|
|||||||
}
|
}
|
||||||
// sort desc
|
// sort desc
|
||||||
act(() => {
|
act(() => {
|
||||||
ref.current?.sortColumn('title');
|
ref.current?.sortColumn('title', 'up');
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
const elem = await rendering.findAllByText(/item/);
|
const elem = await rendering.findAllByText(/item/);
|
||||||
@@ -219,9 +219,9 @@ test('sorting', async () => {
|
|||||||
'item a',
|
'item a',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// another click resets again
|
// reset sort
|
||||||
act(() => {
|
act(() => {
|
||||||
ref.current?.sortColumn('title');
|
ref.current?.sortColumn('title', undefined);
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
const elem = await rendering.findAllByText(/item/);
|
const elem = await rendering.findAllByText(/item/);
|
||||||
|
|||||||
@@ -46,6 +46,34 @@ test('computeSetSelection', () => {
|
|||||||
current: 5,
|
current: 5,
|
||||||
items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant
|
items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// single item existing selection
|
||||||
|
expect(
|
||||||
|
computeSetSelection(
|
||||||
|
{
|
||||||
|
current: 4,
|
||||||
|
items: new Set([4]),
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
current: 5,
|
||||||
|
items: new Set([5]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// single item existing selection, toggle item off
|
||||||
|
expect(
|
||||||
|
computeSetSelection(
|
||||||
|
{
|
||||||
|
current: 4,
|
||||||
|
items: new Set([4]),
|
||||||
|
},
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
current: -1,
|
||||||
|
items: new Set(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('computeAddRangeToSelection', () => {
|
test('computeAddRangeToSelection', () => {
|
||||||
@@ -79,7 +107,7 @@ test('computeAddRangeToSelection', () => {
|
|||||||
|
|
||||||
// invest selection - toggle off
|
// invest selection - toggle off
|
||||||
expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({
|
expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({
|
||||||
current: 8, // note: this item is not part of the selection!
|
current: 9, // select the next thing
|
||||||
items: new Set([2, 3, 9]),
|
items: new Set([2, 3, 9]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {useMemoize} from '../../utils/useMemoize';
|
|||||||
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
||||||
export type Sorting = {
|
export type Sorting = {
|
||||||
key: string;
|
key: string;
|
||||||
direction: 'up' | 'down';
|
direction: Exclude<SortDirection, undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SortDirection = 'up' | 'down' | undefined;
|
||||||
|
|
||||||
export type TableManager = ReturnType<typeof useDataTableManager>;
|
export type TableManager = ReturnType<typeof useDataTableManager>;
|
||||||
|
|
||||||
type Selection = {items: ReadonlySet<number>; current: number};
|
type Selection = {items: ReadonlySet<number>; current: number};
|
||||||
@@ -53,6 +55,69 @@ export function useDataTableManager<T>(
|
|||||||
[columns],
|
[columns],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) as any[];
|
||||||
|
}, [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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtering
|
||||||
|
*/
|
||||||
|
|
||||||
const addColumnFilter = useCallback(
|
const addColumnFilter = useCallback(
|
||||||
(columnId: string, value: string, disableOthers = false) => {
|
(columnId: string, value: string, disableOthers = false) => {
|
||||||
// TODO: fix typings
|
// TODO: fix typings
|
||||||
@@ -102,6 +167,22 @@ export function useDataTableManager<T>(
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setColumnFilterFromSelection = useCallback(
|
||||||
|
(columnId: string) => {
|
||||||
|
const items = getSelectedItems();
|
||||||
|
if (items.length) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
addColumnFilter(
|
||||||
|
columnId,
|
||||||
|
item[columnId],
|
||||||
|
index === 0, // remove existing filters before adding the first
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getSelectedItems, addColumnFilter],
|
||||||
|
);
|
||||||
|
|
||||||
// filter is computed by useMemo to support adding column filters etc here in the future
|
// filter is computed by useMemo to support adding column filters etc here in the future
|
||||||
const currentFilter = useMemoize(
|
const currentFilter = useMemoize(
|
||||||
computeDataTableFilter,
|
computeDataTableFilter,
|
||||||
@@ -127,23 +208,22 @@ export function useDataTableManager<T>(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sortColumn = useCallback(
|
const sortColumn = useCallback(
|
||||||
(key: string) => {
|
(key: string, direction: SortDirection) => {
|
||||||
if (sorting?.key === key) {
|
if (direction === undefined) {
|
||||||
if (sorting.direction === 'down') {
|
// remove sorting
|
||||||
setSorting({key, direction: 'up'});
|
|
||||||
dataSource.setReversed(true);
|
|
||||||
} else {
|
|
||||||
setSorting(undefined);
|
setSorting(undefined);
|
||||||
dataSource.setSortBy(undefined);
|
dataSource.setSortBy(undefined);
|
||||||
dataSource.setReversed(false);
|
dataSource.setReversed(false);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSorting({
|
// update sorting
|
||||||
key,
|
// TODO: make sure that setting both doesn't rebuild output twice!
|
||||||
direction: 'down',
|
if (!sorting || sorting.key !== key) {
|
||||||
});
|
|
||||||
dataSource.setSortBy(key as any);
|
dataSource.setSortBy(key as any);
|
||||||
dataSource.setReversed(false);
|
}
|
||||||
|
if (!sorting || sorting.direction !== direction) {
|
||||||
|
dataSource.setReversed(direction === 'up');
|
||||||
|
}
|
||||||
|
setSorting({key, direction});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dataSource, sorting],
|
[dataSource, sorting],
|
||||||
@@ -166,65 +246,6 @@ export function useDataTableManager<T>(
|
|||||||
[currentFilter, dataSource],
|
[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 {
|
return {
|
||||||
/** The default columns, but normalized */
|
/** The default columns, but normalized */
|
||||||
columns,
|
columns,
|
||||||
@@ -252,6 +273,7 @@ export function useDataTableManager<T>(
|
|||||||
addColumnFilter,
|
addColumnFilter,
|
||||||
removeColumnFilter,
|
removeColumnFilter,
|
||||||
toggleColumnFilter,
|
toggleColumnFilter,
|
||||||
|
setColumnFilterFromSelection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +332,10 @@ export function computeSetSelection(
|
|||||||
): Selection {
|
): Selection {
|
||||||
const newIndex =
|
const newIndex =
|
||||||
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
||||||
|
// special case: toggle existing selection off
|
||||||
|
if (!addToSelection && base.items.size === 1 && base.current === newIndex) {
|
||||||
|
return emptySelection;
|
||||||
|
}
|
||||||
if (newIndex < 0) {
|
if (newIndex < 0) {
|
||||||
return emptySelection;
|
return emptySelection;
|
||||||
}
|
}
|
||||||
@@ -334,17 +360,18 @@ export function computeAddRangeToSelection(
|
|||||||
end: number,
|
end: number,
|
||||||
allowUnselect?: boolean,
|
allowUnselect?: boolean,
|
||||||
): Selection {
|
): Selection {
|
||||||
// special case: unselectiong a single existing item
|
// special case: unselectiong a single item with the selection
|
||||||
if (start === end && allowUnselect) {
|
if (start === end && allowUnselect) {
|
||||||
if (base?.items.has(start)) {
|
if (base?.items.has(start)) {
|
||||||
const copy = new Set(base.items);
|
const copy = new Set(base.items);
|
||||||
copy.delete(start);
|
copy.delete(start);
|
||||||
if (copy.size === 0) {
|
const current = [...copy];
|
||||||
|
if (current.length === 0) {
|
||||||
return emptySelection;
|
return emptySelection;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
items: copy,
|
items: copy,
|
||||||
current: start,
|
current: current[current.length - 1], // back to the last selected one
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// intentional fall-through
|
// intentional fall-through
|
||||||
|
|||||||
Reference in New Issue
Block a user