From c69cf017a1e5f2fb414c385da414160dc5ea215e Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Thu, 19 Dec 2024 16:03:55 +0200 Subject: [PATCH] change list selection state to include `mainSelection` --- .../components/src/hooks/use-keyboard-nav.ts | 5 +- .../use-tree-view-keyboard-interaction.ts | 15 +++-- packages/components/src/list/list.tsx | 39 ++++++----- packages/components/src/list/types.ts | 4 ++ .../src/scroll-list/scroll-list.tsx | 7 +- .../boards/tree-multi-selection.board.tsx | 65 ++++++++++++++----- .../src/tree/boards/tree-with-lanes.board.tsx | 8 ++- packages/components/src/tree/tree.tsx | 4 +- packages/components/src/tree/types.ts | 3 +- 9 files changed, 98 insertions(+), 52 deletions(-) create mode 100644 packages/components/src/list/types.ts diff --git a/packages/components/src/hooks/use-keyboard-nav.ts b/packages/components/src/hooks/use-keyboard-nav.ts index b748fa8a..e7774fe9 100644 --- a/packages/components/src/hooks/use-keyboard-nav.ts +++ b/packages/components/src/hooks/use-keyboard-nav.ts @@ -1,11 +1,12 @@ import React from 'react'; import { childrenById, KeyCodes } from '../common/index.js'; +import { ListSelection } from '../list/types.js'; export const getHandleKeyboardNav = ( elementsParent: React.RefObject, focusedId: string | undefined, setFocusedId: (id: string) => void, - setSelectedIds: (ids: string[]) => void, + setSelectedIds: (selectedData: ListSelection) => void, ) => { const onKeyPress = (ev: React.KeyboardEvent) => { if ( @@ -117,7 +118,7 @@ export const getHandleKeyboardNav = ( break; case KeyCodes.Space: case KeyCodes.Enter: - setSelectedIds([focusedId]); + setSelectedIds({ mainSelection: focusedId, ids: [focusedId] }); break; default: } diff --git a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts index 8ea76c4a..754a25d0 100644 --- a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts +++ b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts @@ -1,6 +1,7 @@ import React, { useCallback, useEffect } from 'react'; import { KeyCodes } from '../common/index.js'; import { ProcessedControlledState } from './use-state-controls.js'; +import { ListSelection } from '../list/types.js'; export type KeyboardSelectMeta = 'keyboard'; export interface TreeViewKeyboardInteractionsParams { @@ -9,7 +10,7 @@ export interface TreeViewKeyboardInteractionsParams { open: (itemId: string) => void; close: (itemId: string) => void; focus: (itemId: string) => void; - select: ProcessedControlledState[1]; + select: ProcessedControlledState[1]; isOpen: (itemId: string) => boolean; isEndNode: (itemId: string) => boolean; getPrevious: (itemId: string) => string | undefined; @@ -62,7 +63,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!itemId) return; if (selectionFollowsFocus) { - select([itemId], 'keyboard'); + select({ mainSelection: itemId, ids: [itemId] }, 'keyboard'); } else { focus(itemId); } @@ -74,7 +75,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!focusedItemId) { return; } - select([focusedItemId]); + select({ mainSelection: focusedItemId, ids: [focusedItemId] }); }, [focusedItemId, select]); const handleArrowRight = useCallback(() => { @@ -111,9 +112,9 @@ export const useTreeViewKeyboardInteraction = ({ if (event.shiftKey) { if (!selectedIds.includes(previous)) { - select([...selectedIds, previous]); + select({ mainSelection: previous, ids: [...selectedIds, previous] }); } else { - select(selectedIds.filter((id) => id !== focusedItemId)); + select({ mainSelection: focusedItemId, ids: selectedIds.filter((id) => id !== focusedItemId) }); } } } @@ -130,9 +131,9 @@ export const useTreeViewKeyboardInteraction = ({ if (event.shiftKey) { if (!selectedIds.includes(next)) { - select([...selectedIds, next]); + select({ mainSelection: next, ids: [...selectedIds, next] }); } else { - select(selectedIds.filter((id) => id !== focusedItemId)); + select({ mainSelection: focusedItemId, ids: selectedIds.filter((id) => id !== focusedItemId) }); } } } diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index c8d157c2..506224ce 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -10,6 +10,7 @@ import { useIdListener } from '../hooks/use-id-based-event.js'; import { getHandleKeyboardNav } from '../hooks/use-keyboard-nav.js'; import { StateControls, useStateControls } from '../hooks/use-state-controls.js'; import type { UseTransmit } from '../hooks/use-transmitted-events.js'; +import { ListSelection } from './types.js'; export type ListRootMinimalProps = Pick< React.HTMLAttributes & React.RefAttributes, @@ -42,7 +43,7 @@ export interface ListItemProps { isFocused: boolean; isSelected: boolean; focus: (id?: string) => void; - select: (ids: string[]) => void; + select: (params: ListSelection) => void; } export interface ListProps { @@ -51,7 +52,7 @@ export interface ListProps { items: T[]; ItemRenderer: React.ComponentType>; focusControl?: StateControls; - selectionControl?: StateControls; + selectionControl?: StateControls; transmitKeyPress?: UseTransmit; onItemMount?: (item: T) => void; onItemUnmount?: (item: T) => void; @@ -74,7 +75,7 @@ export function List({ disableKeyboard, enableMultiselect = true, }: ListProps): React.ReactElement { - const [selectedIds, setSelectedIds] = useStateControls(selectionControl, []); + const [selectedIds, setSelectedIds] = useStateControls(selectionControl, { ids: [] }); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); const defaultRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -90,9 +91,9 @@ export function List({ const rangeSelectionAnchorId = useRef(undefined); useEffect(() => { - if (selectedIds.length === 1) { - rangeSelectionAnchorId.current = selectedIds[0]; - } else if (selectedIds.length === 0) { + if (selectedIds.ids.length === 1) { + rangeSelectionAnchorId.current = selectedIds.ids[0]; + } else if (selectedIds.ids.length === 0) { rangeSelectionAnchorId.current = undefined; } }, [selectedIds]); @@ -117,7 +118,7 @@ export function List({ data={item} focus={setFocusedId} isFocused={focusedId === id} - isSelected={selectedIds.includes(id)} + isSelected={selectedIds.ids.includes(id)} select={setSelectedIds} />, ); @@ -142,21 +143,21 @@ export function List({ (id, ev: React.MouseEvent): void => { // allowing to clear selection when providing an empty select ids array if (!id) { - setSelectedIds([]); + setSelectedIds({ ids: [] }); setFocusedId(undefined); return; } setFocusedId(id); - const isAlreadySelected = selectedIds.includes(id); + const isAlreadySelected = selectedIds.ids.includes(id); if (!enableMultiselect) { if (isAlreadySelected) { return; } - setSelectedIds([id]); + setSelectedIds({ mainSelection: id, ids: [id] }); return; } @@ -164,22 +165,26 @@ export function List({ const isShiftPressed = ev.shiftKey; if (isCtrlPressed && isAlreadySelected) { - setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); + setSelectedIds({ + mainSelection: id, + ids: selectedIds.ids.filter((selectedId) => selectedId !== id), + }); } else if (isCtrlPressed) { - setSelectedIds([...selectedIds, id]); + setSelectedIds({ mainSelection: id, ids: [...selectedIds.ids, id] }); } else if (isShiftPressed) { - setSelectedIds( - getRangeSelection({ + setSelectedIds({ + mainSelection: id, + ids: getRangeSelection({ items, id, indexMap, - selectedIds, + selectedIds: selectedIds.ids, rangeSelectionAnchorId: rangeSelectionAnchorId.current, getId, }), - ); + }); } else { - setSelectedIds([id]); + setSelectedIds({ mainSelection: id, ids: [id] }); } }, [setFocusedId, selectedIds, enableMultiselect, setSelectedIds, items, indexMap, getId], diff --git a/packages/components/src/list/types.ts b/packages/components/src/list/types.ts new file mode 100644 index 00000000..fc73c7b0 --- /dev/null +++ b/packages/components/src/list/types.ts @@ -0,0 +1,4 @@ +export interface ListSelection { + mainSelection?: string; + ids: string[]; +} diff --git a/packages/components/src/scroll-list/scroll-list.tsx b/packages/components/src/scroll-list/scroll-list.tsx index fd4a6788..40c14485 100644 --- a/packages/components/src/scroll-list/scroll-list.tsx +++ b/packages/components/src/scroll-list/scroll-list.tsx @@ -23,6 +23,7 @@ import { useScrollListScrollToFocused, } from './hooks/index.js'; import { classes } from './scroll-list.st.css'; +import { ListSelection } from '../list/types.js'; type ScrollListRootMinimalProps = Pick< React.HTMLAttributes & React.RefAttributes, @@ -169,14 +170,14 @@ export function ScrollList({ }); const scrollWindowSize = useElementSize(scrollWindow, !isHorizontal); const mountedItems = useRef(new Set('')); - const [selected, setSelected] = useStateControls(selectionControl, []); + const [selected, setSelected] = useStateControls(selectionControl, { ids: [] }); const [focused, setFocused] = useStateControls(focusControl, undefined); const getItemInfo = useCallback( (data: T): ScrollListItemInfo => ({ data, isFocused: focused === getId(data), - isSelected: selected.includes(getId(data)), + isSelected: selected.ids.includes(getId(data)), }), [getId, focused, selected], ); @@ -283,7 +284,7 @@ export function ScrollList({ () => [focused, setFocused], [focused, setFocused], ); - const selectionControlMemoized: ProcessedControlledState = useMemo( + const selectionControlMemoized: ProcessedControlledState = useMemo( () => [selected, setSelected], [selected, setSelected], ); diff --git a/packages/components/src/tree/boards/tree-multi-selection.board.tsx b/packages/components/src/tree/boards/tree-multi-selection.board.tsx index 7e70b6ea..ef1f99ff 100644 --- a/packages/components/src/tree/boards/tree-multi-selection.board.tsx +++ b/packages/components/src/tree/boards/tree-multi-selection.board.tsx @@ -1,5 +1,5 @@ import { createBoard } from '@wixc3/react-board'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Tree } from '../tree.js'; import { TreeItemData } from '../../board-assets/index.js'; import { TreeItemRenderer } from '../../board-assets/tree-items/tree-item-renderer.js'; @@ -45,23 +45,52 @@ export default createBoard({ Board: () => { const openItemsControl = useState([]); const scrollRef = useRef(null); + + const [keys, setKeys] = useState([]); + + useEffect(() => { + const keyDownHandler = (event: KeyboardEvent) => { + if (!keys.includes(event.key)) { + setKeys([...keys, event.key]); + } + }; + + const keyUpHandler = (event: KeyboardEvent) => { + setKeys(keys.filter((key) => key !== event.key)); + }; + + document.addEventListener('keydown', keyDownHandler); + document.addEventListener('keyup', keyUpHandler); + + return () => { + document.removeEventListener('keydown', keyDownHandler); + document.removeEventListener('keyup', keyUpHandler); + }; + }, [keys]); + return ( - - data={data} - getId={(it) => it.id} - ItemRenderer={TreeItemRenderer} - getChildren={(it) => it.children || []} - openItemsControls={openItemsControl} - overlay={{ el: () => null, props: {} }} - listRoot={{ - props: { - ref: scrollRef, - id: 'LIST', - style: { outline: 'none', width: '12rem' }, - }, - }} - eventRoots={[scrollRef]} - /> +
+
+ Key pressed: {keys.join(', ')} +
+ + + data={data} + getId={(it) => it.id} + ItemRenderer={TreeItemRenderer} + getChildren={(it) => it.children || []} + openItemsControls={openItemsControl} + overlay={{ el: () => null, props: {} }} + listRoot={{ + props: { + ref: scrollRef, + id: 'LIST', + style: { outline: 'none', width: '12rem' }, + }, + }} + eventRoots={[scrollRef]} + /> +
); }, plugins: [ @@ -143,6 +172,6 @@ export default createBoard({ ], environmentProps: { windowWidth: 600, - windowHeight: 400, + windowHeight: 559, }, }); diff --git a/packages/components/src/tree/boards/tree-with-lanes.board.tsx b/packages/components/src/tree/boards/tree-with-lanes.board.tsx index 61a34e32..0c599050 100644 --- a/packages/components/src/tree/boards/tree-with-lanes.board.tsx +++ b/packages/components/src/tree/boards/tree-with-lanes.board.tsx @@ -14,6 +14,7 @@ import { } from '../../board-assets/tree-items/tree-item-with-lane-renderer.js'; import { createTreeOverlay } from '../utils.js'; import { Tree } from '../tree.js'; +import { ListSelection } from '../../list/types.js'; let idCounter = 0; const nextId = () => 'id' + idCounter++; @@ -171,7 +172,7 @@ const treeOverlay = createTreeOverlay(OverlayRenderer, {}); export default createBoard({ name: 'Tree with lanes', Board: () => { - const [selection, updateSelection] = useState([]); + const [selection, updateSelection] = useState({ ids: [] }); const [openItems, updateOpen] = useState(allIds); return ( @@ -181,7 +182,10 @@ export default createBoard({ getIndent, getParents, selectItem: (item) => { - updateSelection([item.id]); + updateSelection({ + mainSelection: item.id, + ids: [item.id], + }); }, }), [], diff --git a/packages/components/src/tree/tree.tsx b/packages/components/src/tree/tree.tsx index 38cddfb5..c2a804bd 100644 --- a/packages/components/src/tree/tree.tsx +++ b/packages/components/src/tree/tree.tsx @@ -57,7 +57,7 @@ export function Tree(props: TreeProps getItems({ item: data, getChildren, getId, openItemIds }), @@ -170,7 +170,7 @@ export function Tree(props: TreeProps extends ScrollListItemInfo { isOpen: boolean; @@ -35,8 +36,8 @@ export interface TreeAddedProps { openItemsControls: StateControls; eventRoots?: TreeViewKeyboardInteractionsParams['eventRoots']; ItemRenderer: React.ComponentType>; + selectionControl?: StateControls; overlay?: typeof overlayRoot; - selectionControl?: StateControls; } export type TreeProps = Omit<