diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx index ca5a8d605..e2a954f4d 100644 --- a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -299,6 +299,7 @@ export class DataSource< setReversed(reverse: boolean) { if (this.reverse !== reverse) { this.reverse = reverse; + // TODO: not needed anymore this.rebuildOutput(); } } diff --git a/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx index 282832df7..0e52fdf85 100644 --- a/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx @@ -29,6 +29,7 @@ export type ColumnFilterHandlers = { onAddColumnFilter(columnId: string, value: string): void; onRemoveColumnFilter(columnId: string, index: number): void; onToggleColumnFilter(columnId: string, index: number): void; + onSetColumnFilterFromSelection(columnId: string): void; }; export function FilterIcon({ @@ -98,6 +99,37 @@ export function FilterIcon({ No active filters )} + +
+ + + +
); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 272013e06..e6462d380 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -26,11 +26,7 @@ import {useDataTableManager, TableManager} from './useDataTableManager'; import {TableSearch} from './TableSearch'; import styled from '@emotion/styled'; import {theme} from '../theme'; -import { - tableContextMenuFactory, - TableContextMenuContext, -} from './TableContextMenu'; -import {useMemoize} from '../../utils/useMemoize'; +import {tableContextMenuFactory} from './TableContextMenu'; interface DataTableProps { columns: DataTableColumn[]; @@ -93,9 +89,6 @@ export function DataTable( selectItem, selection, addRangeToSelection, - addColumnFilter, - getSelectedItem, - getSelectedItems, } = tableManager; const renderingConfig = useMemo>(() => { @@ -236,13 +229,8 @@ export function DataTable( // TODO: support customizing context menu const contexMenu = props._testHeight ? undefined // don't render context menu in tests - : // eslint-disable-next-line - useMemoize(tableContextMenuFactory, [ - visibleColumns, - addColumnFilter, - getSelectedItem, - getSelectedItems as any, - ]); + : tableContextMenuFactory(tableManager); + return ( @@ -250,34 +238,34 @@ export function DataTable( - - > - dataSource={dataSource} - autoScroll={props.autoScroll} - useFixedRowHeight={!usesWrapping} - defaultRowHeight={DEFAULT_ROW_HEIGHT} - context={renderingConfig} - itemRenderer={itemRenderer} - onKeyDown={onKeyDown} - virtualizerRef={virtualizerRef} - onRangeChange={onRangeChange} - _testHeight={props._testHeight} - /> - + > + dataSource={dataSource} + autoScroll={props.autoScroll} + useFixedRowHeight={!usesWrapping} + defaultRowHeight={DEFAULT_ROW_HEIGHT} + context={renderingConfig} + itemRenderer={itemRenderer} + onKeyDown={onKeyDown} + virtualizerRef={virtualizerRef} + onRangeChange={onRangeChange} + _testHeight={props._testHeight} + /> {range && {range}} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx index f7b5a3e38..ca1aad6e8 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx @@ -8,26 +8,16 @@ */ import {CopyOutlined, FilterOutlined} from '@ant-design/icons'; -import {Menu} from 'antd'; -import {DataTableColumn} from './DataTable'; +import {Checkbox, Menu} from 'antd'; import {TableManager} from './useDataTableManager'; import React from 'react'; -import {createContext} from 'react'; import {normalizeCellValue} from './TableRow'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; +import {DataTableColumn} from './DataTable'; const {Item, SubMenu} = Menu; -export const TableContextMenuContext = createContext< - React.ReactElement | undefined ->(undefined); - -export function tableContextMenuFactory( - visibleColumns: DataTableColumn[], - addColumnFilter: TableManager['addColumnFilter'], - _getSelection: () => T, - getMultiSelection: () => T[], -) { +export function tableContextMenuFactory(tableManager: TableManager) { const lib = tryGetFlipperLibImplementation(); if (!lib) { return ( @@ -36,34 +26,32 @@ export function tableContextMenuFactory( ); } + const hasSelection = tableManager.selection?.items.size > 0 ?? false; return ( - }> - {visibleColumns.map((column) => ( + } + disabled={!hasSelection}> + {tableManager.visibleColumns.map((column) => ( { - 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 - ); - }); - } + tableManager.setColumnFilterFromSelection(column.key); }}> - {column.title || column.key} + {friendlyColumnTitle(column)} ))} - }> - {visibleColumns.map((column) => ( + } + disabled={!hasSelection}> + {tableManager.visibleColumns.map((column) => ( { - const items = getMultiSelection(); + const items = tableManager.getSelectedItems(); if (items.length) { lib.writeTextToClipboard( items @@ -72,13 +60,14 @@ export function tableContextMenuFactory( ); } }}> - {column.title || column.key} + {friendlyColumnTitle(column)} ))} { - const items = getMultiSelection(); + const items = tableManager.getSelectedItems(); if (items.length) { lib.writeTextToClipboard( JSON.stringify(items.length > 1 ? items : items[0], null, 2), @@ -89,8 +78,9 @@ export function tableContextMenuFactory( {lib.isFB && ( { - const items = getMultiSelection(); + const items = tableManager.getSelectedItems(); if (items.length) { lib.createPaste( JSON.stringify(items.length > 1 ? items : items[0], null, 2), @@ -100,6 +90,30 @@ export function tableContextMenuFactory( Create paste )} + + + {tableManager.columns.map((column) => ( + + { + e.stopPropagation(); + e.preventDefault(); + tableManager.toggleColumnVisibility(column.key); + }}> + {friendlyColumnTitle(column)} + + + ))} + + + Reset view + ); } + +function friendlyColumnTitle(column: DataTableColumn): string { + const name = column.title || column.key; + return name[0].toUpperCase() + name.substr(1); +} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx index 7a466dc49..8224185a0 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx @@ -20,23 +20,37 @@ import React from 'react'; import {theme} from '../theme'; import type {DataTableColumn} from './DataTable'; -import {Checkbox, Dropdown, Menu, Typography} from 'antd'; -import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons'; +import {Typography} from 'antd'; +import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons'; import {Layout} from '../Layout'; -import {Sorting, OnColumnResize} from './useDataTableManager'; +import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager'; import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter'; const {Text} = Typography; -function SortIcons({direction}: {direction?: 'up' | 'down'}) { +function SortIcons({ + direction, + onSort, +}: { + direction?: SortDirection; + onSort(direction: SortDirection): void; +}) { return ( { + e.stopPropagation(); + onSort(direction === 'up' ? undefined : 'up'); + }} className={ 'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '') } /> { + e.stopPropagation(); + onSort(direction === 'down' ? undefined : 'down'); + }} className={ 'ant-table-column-sorter-down ' + (direction === 'down' ? 'active' : '') @@ -55,25 +69,41 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>( position: 'relative', left: 4, top: -3, + cursor: 'pointer', 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)({ overflow: 'hidden', whiteSpace: 'nowrap', width: '100%', + borderRight: `1px solid ${theme.dividerColor}`, + paddingRight: 4, }); TableHeaderColumnInteractive.displayName = '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', display: 'flex', flexDirection: 'row', @@ -84,25 +114,6 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({ }); 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}; function TableHeadColumn({ @@ -116,7 +127,7 @@ function TableHeadColumn({ column: DataTableColumn; sorted: 'up' | 'down' | undefined; isResizable: boolean; - onSort: (id: string) => void; + onSort: (id: string, direction: SortDirection) => void; sortOrder: undefined | Sorting; onColumnResize: OnColumnResize; } & ColumnFilterHandlers) { @@ -150,11 +161,23 @@ function TableHeadColumn({ }; let children = ( - -
onSort(column.key)} role="button" tabIndex={0}> + +
{ + e.stopPropagation(); + onSort( + column.key, + sorted === 'up' ? undefined : sorted === 'down' ? 'up' : 'down', + ); + }} + role="button" + tabIndex={0}> {column.title ?? <> } - + onSort(column.key, dir)} + />
@@ -181,40 +204,15 @@ function TableHeadColumn({ } export const TableHead = memo(function TableHead({ - columns, visibleColumns, ...props }: { - columns: DataTableColumn[]; visibleColumns: DataTableColumn[]; onColumnResize: OnColumnResize; - onColumnToggleVisibility: (key: string) => void; onReset: () => void; sorting: Sorting | undefined; - onColumnSort: (key: string) => void; + onColumnSort: (key: string, direction: SortDirection) => void; } & ColumnFilterHandlers) { - const menu = ( - - {columns.map((column) => ( - - { - e.stopPropagation(); - e.preventDefault(); - props.onColumnToggleVisibility(column.key); - }}> - {column.title || column.key} - - - ))} - - - Reset - - - ); - return ( {visibleColumns.map((column, i) => ( @@ -228,6 +226,7 @@ export const TableHead = memo(function TableHead({ onAddColumnFilter={props.onAddColumnFilter} onRemoveColumnFilter={props.onRemoveColumnFilter} onToggleColumnFilter={props.onToggleColumnFilter} + onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection} sorted={ props.sorting?.key === column.key ? props.sorting!.direction @@ -235,11 +234,6 @@ export const TableHead = memo(function TableHead({ } /> ))} - - - - - ); }); diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index f97bcb143..e2b0dfc4b 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -7,15 +7,12 @@ * @format */ -import React, {memo, useContext} from 'react'; +import React, {memo} from 'react'; import styled from '@emotion/styled'; import {theme} from 'flipper-plugin'; import type {RenderContext} from './DataTable'; import {Width} from '../../utils/widthUtils'; 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 export const DEFAULT_ROW_HEIGHT = 24; @@ -85,8 +82,6 @@ const TableBodyColumnContainer = styled.div<{ })); TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; -const contextMenuTriggers = ['click' as const, 'contextMenu' as const]; - type Props = { config: RenderContext; highlighted: boolean; @@ -96,7 +91,6 @@ type Props = { export const TableRow = memo(function TableRow(props: Props) { const {config, highlighted, value: row} = props; - const menu = useContext(TableContextMenuContext); return ( ); })} - {menu && highlighted && ( - - - - - - )} ); }); -function stopPropagation(e: React.MouseEvent) { - e.stopPropagation(); -} - export function normalizeCellValue(value: any): string { switch (typeof value) { case 'boolean': diff --git a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx index 752af41a3..d03a1b746 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx @@ -7,7 +7,8 @@ * @format */ -import {Input} from 'antd'; +import {MenuOutlined} from '@ant-design/icons'; +import {Button, Dropdown, Input} from 'antd'; import React, {memo} from 'react'; import {Layout} from '../Layout'; import {theme} from '../theme'; @@ -15,9 +16,12 @@ import {theme} from '../theme'; export const TableSearch = memo(function TableSearch({ onSearch, extraActions, + contextMenu, }: { onSearch(value: string): void; extraActions?: React.ReactElement; + hasSelection?: boolean; + contextMenu?: React.ReactElement; }) { return ( {extraActions} + {contextMenu && ( + + + + )} ); }); 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 c01023189..a3e7cce84 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -195,7 +195,7 @@ test('sorting', async () => { } // sort asc act(() => { - ref.current?.sortColumn('title'); + ref.current?.sortColumn('title', 'down'); }); { const elem = await rendering.findAllByText(/item/); @@ -208,7 +208,7 @@ test('sorting', async () => { } // sort desc act(() => { - ref.current?.sortColumn('title'); + ref.current?.sortColumn('title', 'up'); }); { const elem = await rendering.findAllByText(/item/); @@ -219,9 +219,9 @@ test('sorting', async () => { 'item a', ]); } - // another click resets again + // reset sort act(() => { - ref.current?.sortColumn('title'); + ref.current?.sortColumn('title', undefined); }); { const elem = await rendering.findAllByText(/item/); diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx index 1f7052c5a..792a10083 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx @@ -46,6 +46,34 @@ test('computeSetSelection', () => { current: 5, 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', () => { @@ -79,7 +107,7 @@ test('computeAddRangeToSelection', () => { // invest selection - toggle off 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]), }); diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx index ed9d31758..dc8d356d2 100644 --- a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx @@ -17,9 +17,11 @@ import {useMemoize} from '../../utils/useMemoize'; export type OnColumnResize = (id: string, size: number | Percentage) => void; export type Sorting = { key: string; - direction: 'up' | 'down'; + direction: Exclude; }; +export type SortDirection = 'up' | 'down' | undefined; + export type TableManager = ReturnType; type Selection = {items: ReadonlySet; current: number}; @@ -53,6 +55,69 @@ export function useDataTableManager( [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( (columnId: string, value: string, disableOthers = false) => { // TODO: fix typings @@ -102,6 +167,22 @@ export function useDataTableManager( ); }, []); + 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 const currentFilter = useMemoize( computeDataTableFilter, @@ -127,23 +208,22 @@ export function useDataTableManager( }, []); const sortColumn = useCallback( - (key: string) => { - if (sorting?.key === key) { - if (sorting.direction === 'down') { - setSorting({key, direction: 'up'}); - dataSource.setReversed(true); - } else { - setSorting(undefined); - dataSource.setSortBy(undefined); - dataSource.setReversed(false); - } - } else { - setSorting({ - key, - direction: 'down', - }); - dataSource.setSortBy(key as any); + (key: string, direction: SortDirection) => { + if (direction === undefined) { + // remove sorting + setSorting(undefined); + dataSource.setSortBy(undefined); dataSource.setReversed(false); + } else { + // update sorting + // TODO: make sure that setting both doesn't rebuild output twice! + if (!sorting || sorting.key !== key) { + dataSource.setSortBy(key as any); + } + if (!sorting || sorting.direction !== direction) { + dataSource.setReversed(direction === 'up'); + } + setSorting({key, direction}); } }, [dataSource, sorting], @@ -166,65 +246,6 @@ export function useDataTableManager( [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, @@ -252,6 +273,7 @@ export function useDataTableManager( addColumnFilter, removeColumnFilter, toggleColumnFilter, + setColumnFilterFromSelection, }; } @@ -310,6 +332,10 @@ export function computeSetSelection( ): Selection { const newIndex = 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) { return emptySelection; } @@ -334,17 +360,18 @@ export function computeAddRangeToSelection( end: number, allowUnselect?: boolean, ): Selection { - // special case: unselectiong a single existing item + // special case: unselectiong a single item with the selection if (start === end && allowUnselect) { if (base?.items.has(start)) { const copy = new Set(base.items); copy.delete(start); - if (copy.size === 0) { + const current = [...copy]; + if (current.length === 0) { return emptySelection; } return { items: copy, - current: start, + current: current[current.length - 1], // back to the last selected one }; } // intentional fall-through