From 3ce9a7f8e1a31bccde3007e4319706f8d19af88d Mon Sep 17 00:00:00 2001 From: Peter Shershov <16524839+PeterShershov@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:10:46 +0200 Subject: [PATCH] feat(list): support multi-selection (#1080) Co-authored-by: Sagiv Dayan --- package.json | 10 +- packages/components/src/board-assets/items.ts | 2 +- .../tree-items/tree-item-renderer.st.css | 23 ++- .../tree-items/tree-item-renderer.tsx | 2 +- packages/components/src/board-index.ts | 2 + .../scenario-plugin/scenario-plugin.tsx | 22 ++- .../src/hooks/use-id-based-event.ts | 4 +- .../components/src/hooks/use-keyboard-nav.ts | 5 +- .../use-tree-view-keyboard-interaction.ts | 67 +++++-- packages/components/src/list/index.ts | 1 + packages/components/src/list/list.tsx | 184 +++++++++++++++--- packages/components/src/list/types.ts | 4 + .../boards/scroll-to-selection.board.tsx | 12 +- .../use-scroll-list-scroll-to-selected.ts | 50 ++--- .../src/scroll-list/scroll-list.tsx | 19 +- packages/components/src/tree/boards/consts.ts | 3 + .../src/tree/boards/tree-focus.board.tsx | 29 +-- .../src/tree/boards/tree-keyboard.board.tsx | 23 +-- .../boards/tree-multi-selection.board.tsx | 177 +++++++++++++++++ .../src/tree/boards/tree-with-lanes.board.tsx | 8 +- packages/components/src/tree/tree.tsx | 5 +- packages/components/src/tree/types.ts | 5 +- 22 files changed, 514 insertions(+), 143 deletions(-) create mode 100644 packages/components/src/list/types.ts create mode 100644 packages/components/src/tree/boards/consts.ts create mode 100644 packages/components/src/tree/boards/tree-multi-selection.board.tsx diff --git a/package.json b/package.json index 505ea0db..6a3d519e 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,16 @@ "packages/*" ], "scripts": { - "clean": "rimraf -g ./packages/*/dist", "build": "npm run build:boards && npm run build:typescript && npm run build:stylable", - "build:typescript": "tsc --build", - "build:stylable": "stc", "build:boards": "node ./build-board-index.js", + "build:stylable": "stc", + "build:typescript": "tsc --build", + "clean": "rimraf -g ./packages/*/dist", "lint": "eslint", "pretest": "npm run lint && npm run build", + "prettify": "prettier . --write", "test": "npm run test:spec", - "test:spec": "mocha-web \"packages/*/dist/test/**/*.spec.js\"", - "prettify": "prettier . --write" + "test:spec": "mocha-web \"packages/*/dist/test/**/*.spec.js\"" }, "devDependencies": { "@playwright/browser-chromium": "^1.49.1", diff --git a/packages/components/src/board-assets/items.ts b/packages/components/src/board-assets/items.ts index 3735ba4f..be03cf08 100644 --- a/packages/components/src/board-assets/items.ts +++ b/packages/components/src/board-assets/items.ts @@ -22,5 +22,5 @@ export const createItems = (number = 1000, startingId = 0) => ({ id: 'a' + (id + startingId), title: 'item number ' + (id + startingId), - } as ItemData) + }) as ItemData, ); diff --git a/packages/components/src/board-assets/tree-items/tree-item-renderer.st.css b/packages/components/src/board-assets/tree-items/tree-item-renderer.st.css index e330284b..cab73dac 100644 --- a/packages/components/src/board-assets/tree-items/tree-item-renderer.st.css +++ b/packages/components/src/board-assets/tree-items/tree-item-renderer.st.css @@ -5,21 +5,32 @@ -st-states: focused, selected, open; --indent: 0; display: flex; - justify-content: space-between; - margin-left: calc(var(--indent) * 8px); + padding: 8px; + padding-left: calc(var(--indent) * 8px); height: 24px; + user-select: none; + outline: none; + cursor: pointer; + border-radius: 12px; + align-items: center; + margin: 4px 0px; + transition: background .1s; } -.root:hover { +.root:hover:not(:selected) { color: purple; + background: gainsboro; } - .root:focused { - color: blue + color: royalblue; + + &:not(:selected) { + background: gainsboro; + } } .root:selected { - text-decoration: underline; + background: powderblue; } .root:open .chevron { diff --git a/packages/components/src/board-assets/tree-items/tree-item-renderer.tsx b/packages/components/src/board-assets/tree-items/tree-item-renderer.tsx index e7907884..11b76579 100644 --- a/packages/components/src/board-assets/tree-items/tree-item-renderer.tsx +++ b/packages/components/src/board-assets/tree-items/tree-item-renderer.tsx @@ -30,7 +30,7 @@ export const TreeItemRenderer: React.FC> = (props) = props.open(); } }} - > + /> ) : null} diff --git a/packages/components/src/board-index.ts b/packages/components/src/board-index.ts index f37cdd04..5cbe43ae 100644 --- a/packages/components/src/board-index.ts +++ b/packages/components/src/board-index.ts @@ -31,6 +31,7 @@ import searchable_text from './searchable-text/boards/searchable-text.board.js'; import tree from './tree/boards/tree.board.js'; import tree_focus from './tree/boards/tree-focus.board.js'; import tree_keyboard from './tree/boards/tree-keyboard.board.js'; +import tree_multi_selection from './tree/boards/tree-multi-selection.board.js'; import tree_with_lanes from './tree/boards/tree-with-lanes.board.js'; import use_element_size from './_codux/boards/hooks/use-element-size/use-element-size.board.js'; import use_scroll_horizontal_window from './hooks/boards/use-scroll/use-scroll-horizontal-window.board.js'; @@ -66,6 +67,7 @@ export default [ tree, tree_focus, tree_keyboard, + tree_multi_selection, tree_with_lanes, use_element_size, use_scroll_horizontal_window, diff --git a/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx b/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx index adb92758..783af166 100644 --- a/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx +++ b/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx @@ -278,7 +278,7 @@ export const hoverAction = (selector?: string, timeout = 2_000): Action => { }; }; -export const clickAction = (selector?: string, timeout = 2_000): Action => { +export const clickAction = (selector?: string, timeout = 2_000, eventData: MouseEventInit = {}): Action => { return { title: 'Click ' + (selector || 'window'), execute: () => { @@ -288,18 +288,21 @@ export const clickAction = (selector?: string, timeout = 2_000): Action => { new MouseEvent('mousedown', { bubbles: true, relatedTarget: target, + ...eventData, }), ); target.dispatchEvent( new MouseEvent('click', { bubbles: true, relatedTarget: target, + ...eventData, }), ); target.dispatchEvent( new MouseEvent('mouseup', { bubbles: true, relatedTarget: target, + ...eventData, }), ); } @@ -309,7 +312,7 @@ export const clickAction = (selector?: string, timeout = 2_000): Action => { }; }; -export const keyDownAction = (selector: string, keyCode: string, which: number) => { +export const keyDownAction = (selector: string, keyCode: string, eventData: KeyboardEventInit = {}) => { return { title: `key down ${keyCode}`, execute: () => { @@ -323,7 +326,7 @@ export const keyDownAction = (selector: string, keyCode: string, which: number) key: keyCode, bubbles: true, composed: true, - which: which, + ...eventData, }), ); }, @@ -490,7 +493,18 @@ export const expectElementsStyle = ( title: title || 'expectElementsStyle ' + Object.keys(elements).join(', '), execute() { for (const [selector, styles] of Object.entries(elements)) { - expectElementStyle(selector, styles, title, timeout); + const exp = expectElement( + selector, + (el) => { + const style = window.getComputedStyle(el); + for (const [key, val] of Object.entries(styles)) { + expect(style[key as keyof CSSStyleDeclaration]).to.eql(val); + } + }, + title, + ); + + return exp.execute(); } }, timeout, diff --git a/packages/components/src/hooks/use-id-based-event.ts b/packages/components/src/hooks/use-id-based-event.ts index 9098af32..2bcfce11 100644 --- a/packages/components/src/hooks/use-id-based-event.ts +++ b/packages/components/src/hooks/use-id-based-event.ts @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import { getElementWithId } from '../common/index.js'; export function useIdListener( - idSetter: (id: string | undefined, element?: Element) => void, + idSetter: (id: string | undefined, ev: EVType, element?: Element) => void, ): (ev: EVType) => any { return useCallback( (ev: EVType) => { @@ -10,7 +10,7 @@ export function useIdListener, focusedId: string | undefined, setFocusedId: (id: string) => void, - setSelectedId: (id: 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: - setSelectedId(focusedId); + setSelectedIds({ lastSelectedId: 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 4adb8f16..4ba68040 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; @@ -18,6 +19,7 @@ export interface TreeViewKeyboardInteractionsParams { getFirstChild: (itemId: string) => string | undefined; getFirst: () => string | undefined; getLast: () => string | undefined; + selectedIds: string[]; } export interface KeyboardInteractionConfiguration { @@ -54,15 +56,15 @@ export const useTreeViewKeyboardInteraction = ({ select, endNodeExpandSelectsNext, selectionFollowsFocus, + selectedIds, }: TreeViewKeyboardInteractionsParams & KeyboardInteractionConfiguration) => { const handleFocus = useCallback( (itemId: string | undefined) => { if (!itemId) return; + focus(itemId); if (selectionFollowsFocus) { - select(itemId, 'keyboard'); - } else { - focus(itemId); + select({ lastSelectedId: itemId, ids: [itemId] }, 'keyboard'); } }, [focus, select, selectionFollowsFocus], @@ -72,7 +74,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!focusedItemId) { return; } - select(focusedItemId); + select({ lastSelectedId: focusedItemId, ids: [focusedItemId] }); }, [focusedItemId, select]); const handleArrowRight = useCallback(() => { @@ -99,17 +101,50 @@ export const useTreeViewKeyboardInteraction = ({ } }, [focusedItemId, getParent, isOpen, close, handleFocus]); - const handleArrowUp = useCallback(() => { - if (!focusedItemId) return; - - handleFocus(getPrevious(focusedItemId)); - }, [focusedItemId, getPrevious, handleFocus]); - - const handleArrowDown = useCallback(() => { - if (!focusedItemId) return; + const handleArrowUp = useCallback( + (event: KeyboardEvent) => { + if (!focusedItemId) return; + + const previous = getPrevious(focusedItemId); + if (previous) { + handleFocus(previous); + + if (event.shiftKey) { + if (!selectedIds.includes(previous)) { + select({ lastSelectedId: previous, ids: [...selectedIds, previous] }); + } else { + select({ + lastSelectedId: focusedItemId, + ids: selectedIds.filter((id) => id !== focusedItemId), + }); + } + } + } + }, + [focusedItemId, getPrevious, handleFocus, select, selectedIds], + ); - handleFocus(getNext(focusedItemId)); - }, [focusedItemId, getNext, handleFocus]); + const handleArrowDown = useCallback( + (event: KeyboardEvent) => { + if (!focusedItemId) return; + const next = getNext(focusedItemId); + if (next) { + handleFocus(next); + + if (event.shiftKey) { + if (!selectedIds.includes(next)) { + select({ lastSelectedId: next, ids: [...selectedIds, next] }); + } else { + select({ + lastSelectedId: focusedItemId, + ids: selectedIds.filter((id) => id !== focusedItemId), + }); + } + } + } + }, + [focusedItemId, getNext, handleFocus, select, selectedIds], + ); const handleHome = useCallback(() => handleFocus(getFirst()), [getFirst, handleFocus]); @@ -131,7 +166,7 @@ export const useTreeViewKeyboardInteraction = ({ event.preventDefault(); - handler(); + handler(event); }, [handleArrowRight, handleArrowLeft, handleArrowUp, handleArrowDown, handleHome, handleEnd, selectFocused], ); diff --git a/packages/components/src/list/index.ts b/packages/components/src/list/index.ts index 77c64a70..bfc9ae64 100644 --- a/packages/components/src/list/index.ts +++ b/packages/components/src/list/index.ts @@ -1 +1,2 @@ export * from './list.js'; +export * from './types.js'; diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 7216fc97..57a9deef 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { JSX, useCallback, useEffect, useMemo, useRef } from 'react'; import { callInternalFirst, defaultRoot, @@ -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: (id?: string) => void; + select: (params: ListSelection) => void; } export interface ListProps { @@ -51,11 +52,12 @@ export interface ListProps { items: T[]; ItemRenderer: React.ComponentType>; focusControl?: StateControls; - selectionControl?: StateControls; + selectionControl?: StateControls; transmitKeyPress?: UseTransmit; onItemMount?: (item: T) => void; onItemUnmount?: (item: T) => void; disableKeyboard?: boolean; + enableMultiselect?: boolean; } export type List = (props: ListProps) => React.ReactElement; @@ -71,26 +73,131 @@ export function List({ onItemMount, onItemUnmount, disableKeyboard, + enableMultiselect = true, }: ListProps): React.ReactElement { - const [selectedId, setSelectedId] = useStateControls(selectionControl, undefined); + const [selectedIds, setSelectedIds] = useStateControls(selectionControl, { ids: [] }); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); - const [prevSelectedId, setPrevSelectedId] = useState(selectedId); - if (selectedId !== prevSelectedId) { - setFocusedId(selectedId); - setPrevSelectedId(selectedId); - } const defaultRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; - const onClick = useIdListener(setSelectedId); + // Adapted from MDN as it's similar to the AnchorNode functionality in the DOM Selection API + // https://developer.mozilla.org/en-US/docs/Web/API/Selection/anchorNode + // A user may make a selection from up to down (in document order) or down to up (reverse of document order). + // The anchor is where the user began the selection. This can be visualized by holding the Shift key and + // pressing the arrow keys on your keyboard. The selection's anchor does not move, + // but the selection's focus, the other end of the selection, does move. + // Basically, This helps us determine which element is the starting point of the range selection. + const rangeSelectionAnchorId = useRef(undefined); + + useEffect(() => { + if (selectedIds.ids.length === 1) { + rangeSelectionAnchorId.current = selectedIds.ids[0]; + } else if (selectedIds.ids.length === 0) { + rangeSelectionAnchorId.current = undefined; + } + }, [selectedIds]); + + const { current: indexMap } = useRef(new Map()); + + const itemsToRender = useMemo(() => { + indexMap.clear(); + const jsxElements: JSX.Element[] = []; + + for (const [index, item] of items.entries()) { + const id = getId(item); + indexMap.set(id, index); + + jsxElements.push( + , + ); + } + + return jsxElements; + }, [ + indexMap, + items, + getId, + ItemRenderer, + onItemMount, + onItemUnmount, + setFocusedId, + focusedId, + selectedIds, + setSelectedIds, + ]); + + const onClick = useIdListener( + useCallback( + (id, ev: React.MouseEvent): void => { + // allowing to clear selection when providing an empty select ids array + if (!id) { + setSelectedIds({ ids: [] }); + setFocusedId(undefined); + return; + } + + setFocusedId(id); + + const isAlreadySelected = selectedIds.ids.includes(id); + + if (!enableMultiselect) { + if (isAlreadySelected) { + return; + } + + setSelectedIds({ lastSelectedId: id, ids: [id] }); + return; + } + + const isCtrlPressed = ev.ctrlKey || ev.metaKey; + const isShiftPressed = ev.shiftKey; + + if (isCtrlPressed && isAlreadySelected) { + setSelectedIds({ + lastSelectedId: id, + ids: selectedIds.ids.filter((selectedId) => selectedId !== id), + }); + } else if (isCtrlPressed) { + setSelectedIds({ lastSelectedId: id, ids: [...selectedIds.ids, id] }); + } else if (isShiftPressed) { + setSelectedIds({ + lastSelectedId: id, + ids: getRangeSelection({ + items, + id, + indexMap, + selectedIds: selectedIds.ids, + rangeSelectionAnchorId: rangeSelectionAnchorId.current, + getId, + }), + }); + } else { + setSelectedIds({ lastSelectedId: id, ids: [id] }); + } + }, + [setFocusedId, selectedIds, enableMultiselect, setSelectedIds, items, indexMap, getId], + ), + ); + const onKeyPress = disableKeyboard ? () => {} : getHandleKeyboardNav( actualRef as React.RefObject, focusedId, setFocusedId, - setSelectedId, + setSelectedIds, ); if (transmitKeyPress) { transmitKeyPress(callInternalFirst(onKeyPress, listRoot?.props?.onKeyPress)); @@ -106,23 +213,7 @@ export function List({ tabIndex: 0, }} > - {items.map((item) => { - const id = getId(item); - return ( - - ); - })} + {itemsToRender} ); } @@ -147,3 +238,40 @@ function ItemRendererWrapped({ return ; } + +function getRangeSelection({ + id, + indexMap, + items, + selectedIds, + rangeSelectionAnchorId, + getId, +}: { + selectedIds: string[]; + id: string; + items: T[]; + indexMap: Map; + rangeSelectionAnchorId?: string; + getId: (item: T) => string; +}) { + const [first] = selectedIds; + + if (!first) { + return [id]; + } + + // if the `rangeSelectionAnchorId` is not set, we will consider the + // first selected item as the starting point of the range selection. + const firstIndex = indexMap.get(rangeSelectionAnchorId || first); + const selectedIndex = indexMap.get(id); + + if (firstIndex === undefined || selectedIndex === undefined) { + return [id]; + } + + const startIndex = Math.min(firstIndex, selectedIndex); + const endIndex = Math.max(firstIndex, selectedIndex); + + // we add 1 to `endIndex` to include the last item in the selection + return items.slice(startIndex, endIndex + 1).map(getId); +} diff --git a/packages/components/src/list/types.ts b/packages/components/src/list/types.ts new file mode 100644 index 00000000..a502208e --- /dev/null +++ b/packages/components/src/list/types.ts @@ -0,0 +1,4 @@ +export interface ListSelection { + lastSelectedId?: string; + ids: string[]; +} diff --git a/packages/components/src/scroll-list/boards/scroll-to-selection.board.tsx b/packages/components/src/scroll-list/boards/scroll-to-selection.board.tsx index 6130245a..4b5a61d2 100644 --- a/packages/components/src/scroll-list/boards/scroll-to-selection.board.tsx +++ b/packages/components/src/scroll-list/boards/scroll-to-selection.board.tsx @@ -35,11 +35,11 @@ const ItemRenderer: React.FC> = (props) => { * Right now scrolling to selection is supported for finite lists that provide ref to scrollWindow. */ export default createBoard({ - name: 'ScrollList — scroll to selected item', + name: 'ScrollList — scroll to focused item', Board: () => { const initialSelectedIndex = 442; - const [selectedItem, setSelectedItem] = useState(`a${initialSelectedIndex}`); const [input, setInput] = useState(initialSelectedIndex); + const [focused, setFocused] = useState(`a${initialSelectedIndex}`); return ( <> @@ -60,7 +60,7 @@ export default createBoard({ /> - @@ -71,8 +71,8 @@ export default createBoard({ items={items} itemSize={() => 50} getId={getId} - selectionControl={[selectedItem, noop]} - scrollToSelection={true} + focusControl={[focused, noop]} + scrollToFocused={true} scrollListRoot={{ el: 'div', props: { @@ -96,7 +96,7 @@ export default createBoard({ }, plugins: [ scenarioPlugin.use({ - title: 'should scroll selected element into view', + title: 'should scroll focused element into view', resetBoard: () => { window.scrollTo(0, 0); }, diff --git a/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts b/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts index f7f92b6d..f5334e35 100644 --- a/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts +++ b/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts @@ -4,13 +4,13 @@ import type { DimensionsById } from '../../common/index.js'; import type { ListProps } from '../../list/list.js'; import type { ScrollListProps } from '../../scroll-list/scroll-list.js'; -export const useScrollListScrollToSelected = ({ - scrollToSelection, +export const useScrollListScrollToFocused = ({ + scrollToFocused, scrollWindow, scrollListRef, items, getId, - selected, + focused, averageItemSize, itemsDimensions, mountedItems, @@ -18,12 +18,12 @@ export const useScrollListScrollToSelected = ({ extraRenderSize, scrollWindowSize, }: { - scrollToSelection: boolean; + scrollToFocused: ScrollListProps['scrollToFocused']; scrollWindow?: ScrollListProps['scrollWindow']; scrollListRef: RefObject; items: ListProps['items']; getId: ListProps['getId']; - selected: string | undefined; + focused?: string; averageItemSize: number; itemsDimensions: MutableRefObject; mountedItems: MutableRefObject>; @@ -34,12 +34,12 @@ export const useScrollListScrollToSelected = ({ const loadingTimeout = useRef(0); const timeout = useRef(0); const isScrollingToSelection = useRef(false); - const selectedIndex = useMemo(() => items.findIndex((i) => getId(i) === selected), [items, getId, selected]); + const focusedIndex = useMemo(() => items.findIndex((i) => getId(i) === focused), [items, getId, focused]); const calculateDistance = useCallback( ({ itemIndex, direction }: { itemIndex: number; direction: 'up' | 'down' }) => { let distance = 0; - for (let index = itemIndex; index !== selectedIndex; direction === 'down' ? index++ : index--) { + for (let index = itemIndex; index !== focusedIndex; direction === 'down' ? index++ : index--) { const item = items[index]!; const id = getId(item); const { height, width } = itemsDimensions.current[id] || { @@ -59,16 +59,7 @@ export const useScrollListScrollToSelected = ({ return Math.floor((direction === 'down' ? 1 : -1) * distance); }, - [ - averageItemSize, - extraRenderSize, - getId, - isHorizontal, - items, - itemsDimensions, - scrollWindowSize, - selectedIndex, - ], + [averageItemSize, extraRenderSize, getId, isHorizontal, items, itemsDimensions, scrollWindowSize, focusedIndex], ); const cleanUp = () => { isScrollingToSelection.current = false; @@ -76,18 +67,18 @@ export const useScrollListScrollToSelected = ({ clearTimeout(timeout.current); }; const scrollTo = useCallback( - (selectedIndex: number, repeated = false) => { + (focusedIndex: number, repeated = false) => { if (!scrollListRef.current) { return; } clearTimeout(timeout.current); - const scrollIntoView = (selected: number, position: ScrollLogicalPosition) => { - const node = scrollListRef.current?.querySelector(`[data-id='${getId(items[selected]!)}']`); + const scrollIntoView = (focusedIndex: number, position: ScrollLogicalPosition) => { + const node = scrollListRef.current?.querySelector(`[data-id='${getId(items[focusedIndex]!)}']`); if (!node) { timeout.current = window.setTimeout( - () => isScrollingToSelection.current && scrollTo(selected, true), + () => isScrollingToSelection.current && scrollTo(focusedIndex, true), ); } else { scrollIntoViewIfNeeded(node, { @@ -107,33 +98,28 @@ export const useScrollListScrollToSelected = ({ let position: ScrollLogicalPosition = 'nearest'; - if (selectedIndex < firstIndex) { + if (focusedIndex < firstIndex) { position = 'start'; scrollTarget.scrollBy({ top: calculateDistance({ itemIndex: firstIndex, direction: 'up' }) }); - } else if (lastIndex < selectedIndex) { + } else if (lastIndex < focusedIndex) { position = 'end'; scrollTarget.scrollBy({ top: calculateDistance({ itemIndex: lastIndex, direction: 'down' }) }); } - timeout.current = window.setTimeout(() => scrollIntoView(selectedIndex, repeated ? 'center' : position)); + timeout.current = window.setTimeout(() => scrollIntoView(focusedIndex, repeated ? 'center' : position)); }, [scrollListRef, scrollWindow, mountedItems, items, getId, calculateDistance], ); useEffect(() => { - if ( - scrollToSelection && - selectedIndex > -1 && - mountedItems.current.size > 0 && - !isScrollingToSelection.current - ) { + if (scrollToFocused && focusedIndex > -1 && mountedItems.current.size > 0 && !isScrollingToSelection.current) { isScrollingToSelection.current = true; - scrollTo(selectedIndex); + scrollTo(focusedIndex); } return () => { cleanUp(); }; - }, [scrollToSelection, mountedItems, scrollTo, selectedIndex]); + }, [scrollToFocused, mountedItems, scrollTo, focusedIndex]); }; diff --git a/packages/components/src/scroll-list/scroll-list.tsx b/packages/components/src/scroll-list/scroll-list.tsx index 7666a036..40c14485 100644 --- a/packages/components/src/scroll-list/scroll-list.tsx +++ b/packages/components/src/scroll-list/scroll-list.tsx @@ -20,9 +20,10 @@ import { ScrollListPositioningProps, useLoadMoreOnScroll, useScrollListPosition, - useScrollListScrollToSelected, + 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, @@ -121,7 +122,7 @@ export interface ScrollListProps({ scrollListRoot, listRoot, selectionControl, - scrollToSelection = false, + scrollToFocused = false, extraRenderSize = 0.5, unmountItems, preloader, @@ -169,14 +170,14 @@ export function ScrollList({ }); const scrollWindowSize = useElementSize(scrollWindow, !isHorizontal); const mountedItems = useRef(new Set('')); - const [selected, setSelected] = useStateControls(selectionControl, undefined); + 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 === getId(data), + isSelected: selected.ids.includes(getId(data)), }), [getId, focused, selected], ); @@ -247,13 +248,13 @@ export function ScrollList({ loadedItemsNumber: items.length, }); - useScrollListScrollToSelected({ + useScrollListScrollToFocused({ scrollWindow, scrollListRef, - scrollToSelection, + scrollToFocused, items, getId, - selected, + focused, averageItemSize, mountedItems, isHorizontal, @@ -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/consts.ts b/packages/components/src/tree/boards/consts.ts new file mode 100644 index 00000000..491d536f --- /dev/null +++ b/packages/components/src/tree/boards/consts.ts @@ -0,0 +1,3 @@ +export const FOCUSED_STYLE = { color: 'rgb(65, 105, 225)' }; +export const SELECTED_STYLE = { backgroundColor: 'rgb(176, 224, 230)' }; +export const DEFAULT_STYLE = { color: 'rgb(0, 0, 0)' }; diff --git a/packages/components/src/tree/boards/tree-focus.board.tsx b/packages/components/src/tree/boards/tree-focus.board.tsx index 50778637..e496d720 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -13,6 +13,7 @@ import { scenarioPlugin, } from '../../board-plugins/index.js'; import { KeyCodes } from '../../common/index.js'; +import { DEFAULT_STYLE, FOCUSED_STYLE } from './consts.js'; const data: TreeItemData = { id: '1', @@ -31,7 +32,9 @@ export default createBoard({ Board: () => { const openItemsControl = useState([]); const scrollRef = useRef(null); - const selectionControl = useState(); + const focusControl = useState(undefined); + const [, setFocus] = focusControl; + return (
@@ -40,7 +43,6 @@ export default createBoard({ ItemRenderer={TreeItemRenderer} getChildren={(it) => it.children || []} openItemsControls={openItemsControl} - selectionControl={selectionControl} overlay={{ el: () => null, props: {} }} listRoot={{ props: { @@ -49,12 +51,13 @@ export default createBoard({ }, }} eventRoots={[scrollRef]} + focusControl={focusControl} /> - -
); @@ -64,17 +67,17 @@ export default createBoard({ title: 'tree focus should follow select and not hover', events: [ clickAction('[data-id="1"]'), - keyDownAction('[data-id="1"]', KeyCodes.ArrowRight, 39), + keyDownAction('[data-id="1"]', KeyCodes.ArrowRight, { which: 39 }), expectElement('[data-id="2"]'), hoverAction('[data-id="3"]'), - keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, 40), - expectElementStyle('[data-id="2"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, { which: 40 }), + expectElementStyle('[data-id="2"]', FOCUSED_STYLE), // blue (focused) clickAction('#clear'), - expectElementStyle('[data-id="2"]', { color: 'rgb(0, 0, 0)' }), //black (not focused) - clickAction('#select'), + expectElementStyle('[data-id="2"]', DEFAULT_STYLE), // not focused + clickAction('#focus'), focusAction('#LIST'), - keyDownAction('[data-id="5"]', KeyCodes.ArrowDown, 40), - expectElementStyle('[data-id="6"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + keyDownAction('[data-id="5"]', KeyCodes.ArrowDown, { which: 40 }), + expectElementStyle('[data-id="6"]', FOCUSED_STYLE), // blue (focused) ], }), ], diff --git a/packages/components/src/tree/boards/tree-keyboard.board.tsx b/packages/components/src/tree/boards/tree-keyboard.board.tsx index 50dea224..47c7d693 100644 --- a/packages/components/src/tree/boards/tree-keyboard.board.tsx +++ b/packages/components/src/tree/boards/tree-keyboard.board.tsx @@ -11,6 +11,7 @@ import { scenarioPlugin, } from '../../board-plugins/index.js'; import { KeyCodes } from '../../common/index.js'; +import { FOCUSED_STYLE, SELECTED_STYLE } from './consts.js'; const data: TreeItemData = { id: '1', @@ -52,18 +53,18 @@ export default createBoard({ title: 'tree focus test', events: [ clickAction('[data-id="1"]'), - keyDownAction('#LIST', KeyCodes.ArrowRight, 39), + keyDownAction('#LIST', KeyCodes.ArrowRight, { which: 39 }), expectElement('[data-id="2"]'), - keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, 40), - expectElementStyle('[data-id="2"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) - keyDownAction('[data-id="2"]', KeyCodes.Space, 32), - expectElementStyle('[data-id="2"]', { textDecorationLine: 'underline' }), - keyDownAction('[data-id="2"]', KeyCodes.Home, 36), - expectElementStyle('[data-id="1"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) - keyDownAction('[data-id="1"]', KeyCodes.End, 35), - expectElementStyle('[data-id="6"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) - keyDownAction('[data-id="6"]', KeyCodes.Enter, 13), - expectElementStyle('[data-id="6"]', { textDecorationLine: 'underline' }), //selected + keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, { which: 40 }), + expectElementStyle('[data-id="2"]', FOCUSED_STYLE), //blue (focused) + keyDownAction('[data-id="2"]', KeyCodes.Space, { which: 32 }), + expectElementStyle('[data-id="2"]', SELECTED_STYLE), + keyDownAction('[data-id="2"]', KeyCodes.Home, { which: 36 }), + expectElementStyle('[data-id="1"]', FOCUSED_STYLE), //blue (focused) + keyDownAction('[data-id="1"]', KeyCodes.End, { which: 35 }), + expectElementStyle('[data-id="6"]', FOCUSED_STYLE), //blue (focused) + keyDownAction('[data-id="6"]', KeyCodes.Enter, { which: 13 }), + expectElementStyle('[data-id="6"]', SELECTED_STYLE), // selected ], }), ], diff --git a/packages/components/src/tree/boards/tree-multi-selection.board.tsx b/packages/components/src/tree/boards/tree-multi-selection.board.tsx new file mode 100644 index 00000000..05dc364e --- /dev/null +++ b/packages/components/src/tree/boards/tree-multi-selection.board.tsx @@ -0,0 +1,177 @@ +import { createBoard } from '@wixc3/react-board'; +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'; +import { + scenarioPlugin, + clickAction, + keyDownAction, + expectElement, + expectElementStyle, + expectElementsStyle, +} from '../../board-plugins/index.js'; +import { KeyCodes } from '../../common/keycodes.js'; +import { DEFAULT_STYLE, FOCUSED_STYLE, SELECTED_STYLE } from './consts.js'; + +const data: TreeItemData = { + id: '1', + title: 'item 1', + children: [ + { + id: '2', + title: 'item 2', + children: [ + { + id: '2.1', + title: 'item 2.1', + children: [ + { id: '2.1.1', title: 'item 2.1.1' }, + { id: '2.1.2', title: 'item 2.1.2' }, + { id: '2.1.3', title: 'item 2.1.3' }, + ], + }, + ], + }, + { id: '3', title: 'item 3' }, + { id: '4', title: 'item 4' }, + { id: '5', title: 'item 5' }, + { id: '6', title: 'item 6' }, + ], +}; + +export default createBoard({ + name: 'Tree multi-selection', + 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 ( +
+
+ 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: [ + scenarioPlugin.use({ + title: 'tree multi selection test', + events: [ + clickAction('[data-id="1"]'), + keyDownAction('#LIST', KeyCodes.ArrowRight, { which: 39 }), + expectElement('[data-id="2"]'), + // basic range multi-selection using the shift key + keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, { which: 40, shiftKey: true }), + expectElementsStyle({ + [`[data-id="1"]`]: SELECTED_STYLE, + [`[data-id="2"]`]: SELECTED_STYLE, + }), + // checking two sets of ranges + // 1 <- selected + // 2 <- selected + // 3 + // 4 <- selected + keyDownAction('[data-id="2"]', KeyCodes.ArrowDown, { which: 40 }), + expectElement('[data-id="3"]'), + keyDownAction('[data-id="3"]', KeyCodes.ArrowDown, { which: 40, shiftKey: true }), + expectElementsStyle({ + [`[data-id="1"]`]: SELECTED_STYLE, + [`[data-id="2"]`]: SELECTED_STYLE, + [`[data-id="3"]`]: DEFAULT_STYLE, + [`[data-id="4"]`]: SELECTED_STYLE, + }), + // clears multi selection on click + clickAction('[data-id="3"]'), + expectElementsStyle({ + [`[data-id="1"]`]: DEFAULT_STYLE, + [`[data-id="2"]`]: DEFAULT_STYLE, + [`[data-id="3"]`]: SELECTED_STYLE, + [`[data-id="4"]`]: DEFAULT_STYLE, + [`[data-id="5"]`]: DEFAULT_STYLE, + [`[data-id="6"]`]: DEFAULT_STYLE, + }), + // selects the range from up to down using click and shift + clickAction('[data-id="5"]', 2000, { shiftKey: true }), + expectElementsStyle({ + [`[data-id="3"]`]: SELECTED_STYLE, + [`[data-id="4"]`]: SELECTED_STYLE, + [`[data-id="5"]`]: SELECTED_STYLE, + }), + // validates the anchor functionality + // selects the range from down to up using click and shift. + clickAction('[data-id="1"]', 2000, { shiftKey: true }), + expectElementsStyle({ + [`[data-id="1"]`]: SELECTED_STYLE, + [`[data-id="2"]`]: SELECTED_STYLE, + [`[data-id="3"]`]: SELECTED_STYLE, + }), + // removes already selected element with shift + clickAction('[data-id="2"]', 2000, { shiftKey: true }), + expectElementsStyle({ + [`[data-id="1"]`]: DEFAULT_STYLE, + [`[data-id="2"]`]: SELECTED_STYLE, + [`[data-id="3"]`]: SELECTED_STYLE, + }), + // validates multi-selection using click+ctrl key + clickAction('[data-id="1"]'), + clickAction('[data-id="3"]', 2000, { ctrlKey: true }), + clickAction('[data-id="5"]', 2000, { ctrlKey: true }), + expectElementsStyle({ + [`[data-id="1"]`]: SELECTED_STYLE, + [`[data-id="2"]`]: DEFAULT_STYLE, + [`[data-id="3"]`]: SELECTED_STYLE, + [`[data-id="4"]`]: DEFAULT_STYLE, + [`[data-id="5"]`]: SELECTED_STYLE, + [`[data-id="6"]`]: DEFAULT_STYLE, + }), + // validates clearing selected item using click+ctrl key + clickAction('[data-id="1"]', 2000, { ctrlKey: true }), + expectElementStyle('[data-id="1"]', FOCUSED_STYLE), + ], + }), + ], + environmentProps: { + windowWidth: 600, + 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 c25d7bde..7c81654e 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(undefined); + 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({ + lastSelectedId: item.id, + ids: [item.id], + }); }, }), [], diff --git a/packages/components/src/tree/tree.tsx b/packages/components/src/tree/tree.tsx index 775c8bb0..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,6 +170,7 @@ export function Tree(props: TreeProps(props: TreeProps diff --git a/packages/components/src/tree/types.ts b/packages/components/src/tree/types.ts index 8df0c71d..928de2e4 100644 --- a/packages/components/src/tree/types.ts +++ b/packages/components/src/tree/types.ts @@ -8,6 +8,7 @@ import type { import type { ListItemProps } from '../list/list.js'; import type { OverlayProps, ScrollListItemInfo, ScrollListProps } from '../scroll-list/scroll-list.js'; import type { overlayRoot } from './utils.js'; +import { ListSelection } from '../list/types.js'; export interface TreeItemInfo extends ScrollListItemInfo { isOpen: boolean; @@ -18,9 +19,7 @@ export interface TreeItemProps extends ListItemProps { isOpen: boolean; hasChildren: boolean; indent: number; - open(): void; - close(): void; } @@ -37,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<