From 7e20c88b4d3f28bbbec0d4994943dbe8ae95a480 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Sun, 8 Dec 2024 11:46:52 +0200 Subject: [PATCH 01/21] refactor: change selection type to `string[]` and adjust usages (initial) --- package.json | 10 +++---- .../src/auto-complete/auto-complete.tsx | 18 +++++++----- .../src/hooks/use-id-based-event.ts | 4 +-- .../components/src/hooks/use-keyboard-nav.ts | 4 +-- .../use-tree-view-keyboard-interaction.ts | 6 ++-- packages/components/src/list/list.tsx | 29 ++++++++++++------- .../boards/scroll-to-selection.board.tsx | 6 ++-- .../use-scroll-list-scroll-to-selected.ts | 19 ++++++++---- .../src/scroll-list/scroll-list.tsx | 6 ++-- .../src/tree/boards/tree-focus.board.tsx | 7 +++-- .../src/tree/boards/tree-with-lanes.board.tsx | 24 +++++++-------- packages/components/src/tree/tree.tsx | 2 +- packages/components/src/tree/types.ts | 3 +- 13 files changed, 80 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index ea280273..173f0d53 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,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.0", diff --git a/packages/components/src/auto-complete/auto-complete.tsx b/packages/components/src/auto-complete/auto-complete.tsx index 7082ae8e..497dce9e 100644 --- a/packages/components/src/auto-complete/auto-complete.tsx +++ b/packages/components/src/auto-complete/auto-complete.tsx @@ -20,7 +20,7 @@ export type AutoComplete = (props: A export function AutoComplete(props: AutoCompleteProps): JSX.Element { const { searchControl, getTextContent, items, focusControl, selectionControl, getId, ...listProps } = props; const [focused, setFocused] = useStateControls(focusControl, undefined); - const [selected, setSelected] = useStateControls(selectionControl, undefined); + const [selected, setSelected] = useStateControls(selectionControl, []); const inputRef = useRef(null); const scrollListRef = useRef(null); const [searchText, updateSearchText] = useStateControls(searchControl, undefined); @@ -48,13 +48,17 @@ export function AutoComplete(props: }, [getTextContent, items, match, searchText]); const onListSelect = useCallback( - (selectedId?: string) => { - const item = items.find((item) => getId(item) === selectedId); - updateSearchText(item ? getTextContent(item) : ''); - setSelected(selectedId); + (selectedIds: string[]) => { + const selectedItems = items.filter((item) => selectedIds.includes(getId(item))); + updateSearchText( + selectedItems.length + ? selectedItems.map((selectedItem) => getTextContent(selectedItem)).join(', ') + : '', + ); + setSelected(selectedIds); close(); }, - [close, getId, getTextContent, items, setSelected, updateSearchText] + [close, getId, getTextContent, items, setSelected, updateSearchText], ); const scrollListRoot = createListRoot(Area, { className: classes.scrollListRoot, @@ -74,7 +78,7 @@ export function AutoComplete(props: onKeyPress(ev); } }, - [close, onKeyPress, open] + [close, onKeyPress, open], ); useEffect(() => { if (filteredData[0]) { diff --git a/packages/components/src/hooks/use-id-based-event.ts b/packages/components/src/hooks/use-id-based-event.ts index 12face39..e0f9aa84 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'; export function useIdListener( - idSetter: (id: string | undefined, element?: Element) => void + idSetter: (id: string | undefined, element?: Element) => void, ): (ev: EVType) => any { return useCallback( (ev: EVType) => { @@ -12,6 +12,6 @@ export function useIdListener, focusedId: string | undefined, setFocusedId: (id: string) => void, - setSelectedId: (id: string) => void, + setSelectedIds: (ids: string[]) => void, ) => { const onKeyPress = (ev: React.KeyboardEvent) => { if ( @@ -117,7 +117,7 @@ export const getHandleKeyboardNav = ( break; case KeyCodes.Space: case KeyCodes.Enter: - setSelectedId(focusedId); + setSelectedIds([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 13f94651..beacb6bd 100644 --- a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts +++ b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts @@ -9,7 +9,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; @@ -60,7 +60,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!itemId) return; if (selectionFollowsFocus) { - select(itemId, 'keyboard'); + select([itemId], 'keyboard'); } else { focus(itemId); } @@ -72,7 +72,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!focusedItemId) { return; } - select(focusedItemId); + select([focusedItemId]); }, [focusedItemId, select]); const handleArrowRight = useCallback(() => { diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 0ce792cb..75b79058 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -42,7 +42,7 @@ export interface ListItemProps { isFocused: boolean; isSelected: boolean; focus: (id?: string) => void; - select: (id?: string) => void; + select: (id: string[]) => void; } export interface ListProps { @@ -51,7 +51,8 @@ export interface ListProps { items: T[]; ItemRenderer: React.ComponentType>; focusControl?: StateControls; - selectionControl?: StateControls; + // selectionControl?: StateControls; + selectionControl?: StateControls; transmitKeyPress?: UseTransmit; onItemMount?: (item: T) => void; onItemUnmount?: (item: T) => void; @@ -72,21 +73,27 @@ export function List({ onItemUnmount, disableKeyboard, }: ListProps): JSX.Element { - const [selectedId, setSelectedId] = useStateControls(selectionControl, undefined); + const [selectedIds, setSelectedIds] = useStateControls(selectionControl, []); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); - const [prevSelectedId, setPrevSelectedId] = useState(selectedId); - if (selectedId !== prevSelectedId) { - setFocusedId(selectedId); - setPrevSelectedId(selectedId); + const [prevSelectedId, setPrevSelectedId] = useState(selectedIds); + if (selectedIds !== prevSelectedId) { + setFocusedId(selectedIds[-1]); + setPrevSelectedId(selectedIds); } const defaultRef = useRef(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; - const onClick = useIdListener(setSelectedId); + const onClick = useIdListener((id) => { + if (selectedIds.findIndex((selectedId) => selectedId === id) !== -1) { + return; + } + setSelectedIds(id ? [id] : []); + }); + const onKeyPress = disableKeyboard ? () => {} - : getHandleKeyboardNav(actualRef as React.RefObject, focusedId, setFocusedId, setSelectedId); + : getHandleKeyboardNav(actualRef as React.RefObject, focusedId, setFocusedId, setSelectedIds); if (transmitKeyPress) { transmitKeyPress(callInternalFirst(onKeyPress, listRoot?.props?.onKeyPress)); } @@ -113,8 +120,8 @@ export function List({ data={item} focus={setFocusedId} isFocused={focusedId === id} - isSelected={selectedId === id} - select={setSelectedId} + isSelected={selectedIds.findIndex((selectedId) => selectedId === id) !== -1} + select={setSelectedIds} /> ); })} 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 81109d31..8aae2e46 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 @@ -38,7 +38,7 @@ export default createBoard({ name: 'ScrollList — scroll to selected item', Board: () => { const initialSelectedIndex = 442; - const [selectedItem, setSelectedItem] = useState(`a${initialSelectedIndex}`); + const [selectedItems, setSelectedItems] = useState([`a${initialSelectedIndex}`]); const [input, setInput] = useState(initialSelectedIndex); return ( @@ -60,7 +60,7 @@ export default createBoard({ /> - @@ -71,7 +71,7 @@ export default createBoard({ items={items} itemSize={() => 50} getId={getId} - selectionControl={[selectedItem, noop]} + selectionControl={[selectedItems, noop]} scrollToSelection={true} scrollListRoot={{ el: 'div', 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 77d3f434..5ce2b546 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 @@ -23,7 +23,7 @@ export const useScrollListScrollToSelected = ({ scrollListRef: RefObject; items: ListProps['items']; getId: ListProps['getId']; - selected: string | undefined; + selected: string[]; averageItemSize: number; itemsDimensions: MutableRefObject; mountedItems: MutableRefObject>; @@ -34,7 +34,7 @@ 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 selectedIndex = useMemo(() => items.findIndex((i) => getId(i) === selected[0]), [items, getId, selected]); const calculateDistance = useCallback( ({ itemIndex, direction }: { itemIndex: number; direction: 'up' | 'down' }) => { let distance = 0; @@ -59,7 +59,16 @@ 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, + selectedIndex, + ], ); const cleanUp = () => { isScrollingToSelection.current = false; @@ -78,7 +87,7 @@ export const useScrollListScrollToSelected = ({ const node = scrollListRef.current?.querySelector(`[data-id='${getId(items[selected]!)}']`); if (!node) { timeout.current = window.setTimeout( - () => isScrollingToSelection.current && scrollTo(selected, true) + () => isScrollingToSelection.current && scrollTo(selected, true), ); } else { scrollIntoViewIfNeeded(node, { @@ -108,7 +117,7 @@ export const useScrollListScrollToSelected = ({ timeout.current = window.setTimeout(() => scrollIntoView(selectedIndex, repeated ? 'center' : position)); }, - [scrollListRef, scrollWindow, mountedItems, items, getId, calculateDistance] + [scrollListRef, scrollWindow, mountedItems, items, getId, calculateDistance], ); useEffect(() => { diff --git a/packages/components/src/scroll-list/scroll-list.tsx b/packages/components/src/scroll-list/scroll-list.tsx index d50f3699..dd5a060e 100644 --- a/packages/components/src/scroll-list/scroll-list.tsx +++ b/packages/components/src/scroll-list/scroll-list.tsx @@ -169,14 +169,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, []); const [focused, setFocused] = useStateControls(focusControl, undefined); const getItemInfo = useCallback( (data: T): ScrollListItemInfo => ({ data, isFocused: focused === getId(data), - isSelected: selected === getId(data), + isSelected: selected.some((id) => getId(data) === id), }), [getId, focused, selected], ); @@ -283,7 +283,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-focus.board.tsx b/packages/components/src/tree/boards/tree-focus.board.tsx index 22cd23a2..0d52d56b 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -31,7 +31,8 @@ export default createBoard({ Board: () => { const openItemsControl = useState([]); const scrollRef = useRef(null); - const selectionControl = useState(); + const selectionControl = useState([]); + const [, setSelection] = selectionControl; return (
@@ -50,10 +51,10 @@ export default createBoard({ }} eventRoots={[scrollRef]} /> - -
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 e1b57cb6..c137b07c 100644 --- a/packages/components/src/tree/boards/tree-with-lanes.board.tsx +++ b/packages/components/src/tree/boards/tree-with-lanes.board.tsx @@ -93,12 +93,12 @@ const mockedLane = lane( el('p', [ lane( [laneKinds.repeater], - [el('span')] + [el('span')], ), ]), ]), ]), - ] + ], ), el('span', [ el('Comp', [ @@ -109,32 +109,32 @@ const mockedLane = lane( el('p', [ lane( [laneKinds.repeater], - [el('span')] + [el('span')], ), ]), ]), ]), - ] + ], ), - ] + ], ), ]), ]), ]), - ] + ], ), el('span', [ el('Comp', [marker('children'), marker('header', [el('div')])]), el('div', [el('p', [lane([laneKinds.repeater], [el('span')])])]), ]), - ] + ], ), - ] + ], ), ]), ]), ]), - ] + ], ); const data: TreeItemWithLaneData = el('div', [mockedLane, mockedLane]); @@ -170,7 +170,7 @@ const treeOverlay = createTreeOverlay(OverlayRenderer, {}); export default createBoard({ name: 'Tree with lanes', Board: () => { - const [selection, updateSelection] = useState(undefined); + const [selection, updateSelection] = useState([]); const [openItems, updateOpen] = useState(allIds); return ( @@ -180,10 +180,10 @@ export default createBoard({ getIndent, getParents, selectItem: (item) => { - updateSelection(item.id); + updateSelection([item.id]); }, }), - [] + [], )} > diff --git a/packages/components/src/tree/tree.tsx b/packages/components/src/tree/tree.tsx index b0d2b215..e51fa2b0 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 }), diff --git a/packages/components/src/tree/types.ts b/packages/components/src/tree/types.ts index 58dcc03e..098a613e 100644 --- a/packages/components/src/tree/types.ts +++ b/packages/components/src/tree/types.ts @@ -38,7 +38,8 @@ export interface TreeAddedProps { eventRoots?: TreeViewKeyboardInteractionsParams['eventRoots']; ItemRenderer: React.ComponentType>; overlay?: typeof overlayRoot; - selectionControl?: StateControls; + // selectionControl?: StateControls; + selectionControl?: StateControls; } export type TreeProps = Omit< From 456704a23617c5211c04da6ff49f3d064c8ede4a Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Sun, 8 Dec 2024 14:26:35 +0200 Subject: [PATCH 02/21] replace faulty `[-1]` notation --- packages/components/src/list/list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 75b79058..7c827afd 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -77,7 +77,7 @@ export function List({ const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); const [prevSelectedId, setPrevSelectedId] = useState(selectedIds); if (selectedIds !== prevSelectedId) { - setFocusedId(selectedIds[-1]); + setFocusedId(selectedIds[selectedIds.length - 1]); setPrevSelectedId(selectedIds); } const defaultRef = useRef(); From 2e3feb601d98a8a71149268a4a3b71c022cb0892 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Sun, 8 Dec 2024 17:46:17 +0200 Subject: [PATCH 03/21] restore auto-complete behavior --- packages/components/src/auto-complete/auto-complete.tsx | 8 ++------ packages/components/src/list/list.tsx | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/components/src/auto-complete/auto-complete.tsx b/packages/components/src/auto-complete/auto-complete.tsx index 497dce9e..e05abdc9 100644 --- a/packages/components/src/auto-complete/auto-complete.tsx +++ b/packages/components/src/auto-complete/auto-complete.tsx @@ -49,12 +49,8 @@ export function AutoComplete(props: const onListSelect = useCallback( (selectedIds: string[]) => { - const selectedItems = items.filter((item) => selectedIds.includes(getId(item))); - updateSearchText( - selectedItems.length - ? selectedItems.map((selectedItem) => getTextContent(selectedItem)).join(', ') - : '', - ); + const item = items.find((item) => getId(item) === selectedIds[0]); + updateSearchText(item ? getTextContent(item) : ''); setSelected(selectedIds); close(); }, diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 7c827afd..e88c746a 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -42,7 +42,7 @@ export interface ListItemProps { isFocused: boolean; isSelected: boolean; focus: (id?: string) => void; - select: (id: string[]) => void; + select: (ids: string[]) => void; } export interface ListProps { From 1953edad4ef7e574c53d11a3d91dec5a62d9b920 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Mon, 9 Dec 2024 15:36:03 +0200 Subject: [PATCH 04/21] refactor: change scroll hook to focused item --- .../boards/scroll-to-selection.board.tsx | 2 +- .../use-scroll-list-scroll-to-selected.ts | 50 +++++++------------ .../src/scroll-list/scroll-list.tsx | 12 ++--- 3 files changed, 25 insertions(+), 39 deletions(-) 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 8aae2e46..5ee7bb6e 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 @@ -72,7 +72,7 @@ export default createBoard({ itemSize={() => 50} getId={getId} selectionControl={[selectedItems, noop]} - scrollToSelection={true} + scrollToFocused={true} scrollListRoot={{ el: 'div', props: { 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 5ce2b546..d919ebe4 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'; import type { ListProps } from '../../list/list'; import type { ScrollListProps } from '../../scroll-list/scroll-list'; -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[]; + 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[0]), [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 dd5a060e..1ba482f7 100644 --- a/packages/components/src/scroll-list/scroll-list.tsx +++ b/packages/components/src/scroll-list/scroll-list.tsx @@ -20,7 +20,7 @@ import { ScrollListPositioningProps, useLoadMoreOnScroll, useScrollListPosition, - useScrollListScrollToSelected, + useScrollListScrollToFocused, } from './hooks'; import { classes } from './scroll-list.st.css'; @@ -121,7 +121,7 @@ export interface ScrollListProps({ scrollListRoot, listRoot, selectionControl, - scrollToSelection = false, + scrollToFocused = false, extraRenderSize = 0.5, unmountItems, preloader, @@ -247,13 +247,13 @@ export function ScrollList({ loadedItemsNumber: items.length, }); - useScrollListScrollToSelected({ + useScrollListScrollToFocused({ scrollWindow, scrollListRef, - scrollToSelection, + scrollToFocused, items, getId, - selected, + focused, averageItemSize, mountedItems, isHorizontal, From a91c7a35926b4eb42621102c1eaeeae32b3aa5d4 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Mon, 9 Dec 2024 18:05:59 +0200 Subject: [PATCH 05/21] wip: add `enableMultiselect` boolean to select/deselect using Ctrl --- .../components/src/hooks/use-id-based-event.ts | 4 ++-- packages/components/src/list/list.tsx | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/components/src/hooks/use-id-based-event.ts b/packages/components/src/hooks/use-id-based-event.ts index e0f9aa84..5e22ced5 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'; 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 { 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) => JSX.Element; @@ -72,8 +72,10 @@ export function List({ onItemMount, onItemUnmount, disableKeyboard, + enableMultiselect = true, }: ListProps): JSX.Element { const [selectedIds, setSelectedIds] = useStateControls(selectionControl, []); + console.log('selectedIds: ', JSON.stringify(selectedIds)); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); const [prevSelectedId, setPrevSelectedId] = useState(selectedIds); if (selectedIds !== prevSelectedId) { @@ -84,11 +86,19 @@ export function List({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; - const onClick = useIdListener((id) => { + const onClick = useIdListener((id, ev) => { + const isCtrlPressed = ev.ctrlKey || ev.metaKey; if (selectedIds.findIndex((selectedId) => selectedId === id) !== -1) { + if (enableMultiselect && isCtrlPressed) { + setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); + } return; } - setSelectedIds(id ? [id] : []); + if (id) { + setSelectedIds(enableMultiselect && isCtrlPressed ? [...selectedIds, id] : [id]); + } else { + setSelectedIds([]); + } }); const onKeyPress = disableKeyboard From 63380ce11f69537f38adb8ea5bbc15c98487cbbf Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Tue, 10 Dec 2024 13:10:31 +0200 Subject: [PATCH 06/21] feat: support multiselect and deselection in on-click listener --- packages/components/src/list/list.tsx | 51 +++++++++++++++++++-------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index bdff5af2..1a2147fe 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, { useCallback, useEffect, useRef, useState } from 'react'; import { callInternalFirst, defaultRoot, @@ -86,20 +86,41 @@ export function List({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; - const onClick = useIdListener((id, ev) => { - const isCtrlPressed = ev.ctrlKey || ev.metaKey; - if (selectedIds.findIndex((selectedId) => selectedId === id) !== -1) { - if (enableMultiselect && isCtrlPressed) { - setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); - } - return; - } - if (id) { - setSelectedIds(enableMultiselect && isCtrlPressed ? [...selectedIds, id] : [id]); - } else { - setSelectedIds([]); - } - }); + const onClick = useIdListener( + useCallback( + (id: string | undefined, ev: React.MouseEvent): void => { + if (!id) { + setSelectedIds([]); + return; + } + + const isSameSelected = selectedIds.includes(id); + + if (!enableMultiselect) { + if (isSameSelected) { + return; + } + + setSelectedIds([id]); + return; + } + + const isCtrlPressed = ev.ctrlKey || ev.metaKey; + + if (isSameSelected && isCtrlPressed) { + setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); + return; + } + + if (isCtrlPressed) { + setSelectedIds([...selectedIds, id]); + } else { + setSelectedIds([id]); + } + }, + [enableMultiselect, selectedIds, setSelectedIds], + ), + ); const onKeyPress = disableKeyboard ? () => {} From 9c0aeb38e4d85a2f87d0aa51ec872b3d9042e9d7 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Tue, 10 Dec 2024 13:12:33 +0200 Subject: [PATCH 07/21] feat: support multiselect and deselection in on-click listener --- packages/components/src/list/list.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 1a2147fe..5d73f671 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -75,7 +75,6 @@ export function List({ enableMultiselect = true, }: ListProps): JSX.Element { const [selectedIds, setSelectedIds] = useStateControls(selectionControl, []); - console.log('selectedIds: ', JSON.stringify(selectedIds)); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); const [prevSelectedId, setPrevSelectedId] = useState(selectedIds); if (selectedIds !== prevSelectedId) { From f24b57d8b2964940b2a1b66eb0dd954a3ee79387 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Tue, 10 Dec 2024 14:51:40 +0200 Subject: [PATCH 08/21] fix: adjust focused item behavior --- packages/components/src/list/list.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 5d73f671..4199b773 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { callInternalFirst, defaultRoot, @@ -76,11 +76,6 @@ export function List({ }: ListProps): JSX.Element { const [selectedIds, setSelectedIds] = useStateControls(selectionControl, []); const [focusedId, setFocusedId] = useStateControls(focusControl, undefined); - const [prevSelectedId, setPrevSelectedId] = useState(selectedIds); - if (selectedIds !== prevSelectedId) { - setFocusedId(selectedIds[selectedIds.length - 1]); - setPrevSelectedId(selectedIds); - } const defaultRef = useRef(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; @@ -93,6 +88,8 @@ export function List({ return; } + setFocusedId(id); + const isSameSelected = selectedIds.includes(id); if (!enableMultiselect) { @@ -117,7 +114,7 @@ export function List({ setSelectedIds([id]); } }, - [enableMultiselect, selectedIds, setSelectedIds], + [enableMultiselect, selectedIds, setFocusedId, setSelectedIds], ), ); From 025ca19873662048f2f62ad840813315664a37d3 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Wed, 11 Dec 2024 19:10:48 +0200 Subject: [PATCH 09/21] fix: adjust tests following changes to selection and focus --- packages/components/src/list/list.tsx | 1 + .../scroll-list/boards/scroll-to-selection.board.tsx | 10 +++++----- packages/components/src/scroll-list/scroll-list.tsx | 2 +- .../components/src/tree/boards/tree-focus.board.tsx | 11 ++++++----- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index b8885718..357a0542 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -85,6 +85,7 @@ export function List({ (id: string | undefined, ev: React.MouseEvent): void => { if (!id) { setSelectedIds([]); + setFocusedId(undefined); return; } 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 4a4b6cbc..bdcfa585 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 [selectedItems, setSelectedItems] = useState([`a${initialSelectedIndex}`]); const [input, setInput] = useState(initialSelectedIndex); + const [focused, setFocused] = useState(`a${initialSelectedIndex}`); return ( <> @@ -60,7 +60,7 @@ export default createBoard({ /> - @@ -71,7 +71,7 @@ export default createBoard({ items={items} itemSize={() => 50} getId={getId} - selectionControl={[selectedItems, noop]} + focusControl={[focused, noop]} scrollToFocused={true} scrollListRoot={{ el: 'div', @@ -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/scroll-list.tsx b/packages/components/src/scroll-list/scroll-list.tsx index 1bad6f71..29add9d0 100644 --- a/packages/components/src/scroll-list/scroll-list.tsx +++ b/packages/components/src/scroll-list/scroll-list.tsx @@ -176,7 +176,7 @@ export function ScrollList({ (data: T): ScrollListItemInfo => ({ data, isFocused: focused === getId(data), - isSelected: selected.some((id) => getId(data) === id), + isSelected: selected.includes(getId(data)), }), [getId, focused, selected], ); diff --git a/packages/components/src/tree/boards/tree-focus.board.tsx b/packages/components/src/tree/boards/tree-focus.board.tsx index 0d52d56b..53803bf1 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -31,8 +31,9 @@ export default createBoard({ Board: () => { const openItemsControl = useState([]); const scrollRef = useRef(null); - const selectionControl = useState([]); - const [, setSelection] = selectionControl; + const focusControl = useState(undefined); + const [, setFocus] = focusControl; + return (
@@ -41,7 +42,6 @@ export default createBoard({ ItemRenderer={TreeItemRenderer} getChildren={(it) => it.children || []} openItemsControls={openItemsControl} - selectionControl={selectionControl} overlay={{ el: () => null, props: {} }} listRoot={{ props: { @@ -50,11 +50,12 @@ export default createBoard({ }, }} eventRoots={[scrollRef]} + focusControl={focusControl} /> - -
From 64ffeee17022150088863c2d21c5f00bda6bedc7 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Wed, 11 Dec 2024 19:11:49 +0200 Subject: [PATCH 10/21] remove comment --- packages/components/src/tree/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/tree/types.ts b/packages/components/src/tree/types.ts index 098a613e..8d24ded1 100644 --- a/packages/components/src/tree/types.ts +++ b/packages/components/src/tree/types.ts @@ -38,7 +38,6 @@ export interface TreeAddedProps { eventRoots?: TreeViewKeyboardInteractionsParams['eventRoots']; ItemRenderer: React.ComponentType>; overlay?: typeof overlayRoot; - // selectionControl?: StateControls; selectionControl?: StateControls; } From 6e2deba9e9fa5c68a138c142693634cceb9b4ecd Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Sun, 15 Dec 2024 17:48:52 +0200 Subject: [PATCH 11/21] base --- packages/components/src/board-assets/items.ts | 3 +- .../tree-items/tree-item-renderer.st.css | 23 ++-- .../tree-items/tree-item-renderer.tsx | 2 +- .../use-tree-view-keyboard-interaction.ts | 51 +++++++-- packages/components/src/icons/simulation.tsx | 2 +- packages/components/src/list/list.tsx | 101 ++++++++++++++---- .../src/tree/boards/tree-keyboard.board.tsx | 19 +++- packages/components/src/tree/tree.tsx | 5 +- packages/components/src/tree/types.ts | 2 - 9 files changed, 163 insertions(+), 45 deletions(-) diff --git a/packages/components/src/board-assets/items.ts b/packages/components/src/board-assets/items.ts index 3735ba4f..1f37a770 100644 --- a/packages/components/src/board-assets/items.ts +++ b/packages/components/src/board-assets/items.ts @@ -5,6 +5,7 @@ export interface ItemData { export interface TreeItemData extends ItemData { children?: TreeItemData[]; + index?: number; } export const getId = ({ id }: ItemData) => id; @@ -22,5 +23,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..fe61c225 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; + cursor: pointer; + outline: none; + 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 8fc7efe8..b273a43f 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/hooks/use-tree-view-keyboard-interaction.ts b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts index 8b672c9f..54134c9a 100644 --- a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts +++ b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts @@ -18,6 +18,7 @@ export interface TreeViewKeyboardInteractionsParams { getFirstChild: (itemId: string) => string | undefined; getFirst: () => string | undefined; getLast: () => string | undefined; + selectedIds: string[]; } export interface KeyboardInteractionConfiguration { @@ -54,6 +55,7 @@ export const useTreeViewKeyboardInteraction = ({ select, endNodeExpandSelectsNext, selectionFollowsFocus, + selectedIds, }: TreeViewKeyboardInteractionsParams & KeyboardInteractionConfiguration) => { const handleFocus = useCallback( (itemId: string | undefined) => { @@ -99,17 +101,44 @@ 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([...selectedIds, previous]); + } else { + select(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([...selectedIds, next]); + } else { + select(selectedIds.filter((id) => id !== focusedItemId)); + } + } + } + }, + [focusedItemId, getNext, handleFocus, select, selectedIds], + ); const handleHome = useCallback(() => handleFocus(getFirst()), [getFirst, handleFocus]); @@ -131,7 +160,7 @@ export const useTreeViewKeyboardInteraction = ({ event.preventDefault(); - handler(); + handler(event); }, [handleArrowRight, handleArrowLeft, handleArrowUp, handleArrowDown, handleHome, handleEnd, selectFocused], ); diff --git a/packages/components/src/icons/simulation.tsx b/packages/components/src/icons/simulation.tsx index 4dae25a5..8675965a 100644 --- a/packages/components/src/icons/simulation.tsx +++ b/packages/components/src/icons/simulation.tsx @@ -30,5 +30,5 @@ export const SimulationIcon = IconFactory( , 'SimulationIcon', 18, - 18 + 18, ); diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index b8885718..8ba5f922 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { JSX, useCallback, useEffect, useMemo, useRef } from 'react'; import { callInternalFirst, defaultRoot, @@ -80,14 +80,69 @@ export function List({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; + const indexMap = useRef(new Map()); + + const itemsToRender = useMemo(() => { + indexMap.current.clear(); + const jsxElements: JSX.Element[] = []; + + for (const [index, item] of items.entries()) { + const id = getId(item); + indexMap.current.set(id, index); + + jsxElements.push( + selectedId === id) !== -1} + select={setSelectedIds} + />, + ); + } + + return jsxElements; + }, [ItemRenderer, focusedId, getId, items, onItemMount, onItemUnmount, selectedIds, setFocusedId, setSelectedIds]); + + const rangeSelectionAnchor = useRef(focusedId); + const onClick = useIdListener( useCallback( (id: string | undefined, ev: React.MouseEvent): void => { + // allowing to clear selection when providing an empty select ids array if (!id) { setSelectedIds([]); + setFocusedId(undefined); return; } + if (!ev.shiftKey) { + // Given a focused item, if the user clicks on an item while holding shift, + // the range selection will start from the first-focused item, and consider it as the starting point + // for other selection made while holding shift. + + // Consider the following steps for the following example: + // - item 1 + // - item 2 + // - item 3 + // - item 4 + // - item 5 + + // 1. focus on item 2 <- this selects item 2 and sets it as the rangeSelectionAnchor + // 2. click on item 4 while holding shift + // the expected behavior is to select items 2, 3, and 4 + // 3. now click on item 1 while holding shift + // the expected behavior is to select items 1 and 2, instead of 1, 2, 3, and 4 + // since item 2 is the anchored item. + + rangeSelectionAnchor.current = id; + } + setFocusedId(id); const isSameSelected = selectedIds.includes(id); @@ -102,6 +157,7 @@ export function List({ } const isCtrlPressed = ev.ctrlKey || ev.metaKey; + const isShiftPressed = ev.shiftKey; if (isSameSelected && isCtrlPressed) { setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); @@ -110,11 +166,34 @@ export function List({ if (isCtrlPressed) { setSelectedIds([...selectedIds, id]); + } else if (isShiftPressed) { + const [first] = selectedIds; + + if (!first) { + setSelectedIds([id]); + return; + } + + // if the `rangeSelectionAnchor` is not set, we will consider the first selected item as the + // starting point of the range selection. + const firstIndex = indexMap.current.get(rangeSelectionAnchor.current || first); + const selectedIndex = indexMap.current.get(id); + + if (firstIndex === undefined || selectedIndex === undefined) { + setSelectedIds([id]); + return; + } + + const startIndex = Math.min(firstIndex, selectedIndex); + const endIndex = Math.max(firstIndex, selectedIndex); + + // we add 1 to the endIndex to include the last item in the selection + setSelectedIds(items.slice(startIndex, endIndex + 1).map(getId)); } else { setSelectedIds([id]); } }, - [enableMultiselect, selectedIds, setFocusedId, setSelectedIds], + [enableMultiselect, getId, items, selectedIds, setFocusedId, setSelectedIds], ), ); @@ -140,23 +219,7 @@ export function List({ tabIndex: 0, }} > - {items.map((item) => { - const id = getId(item); - return ( - selectedId === id) !== -1} - select={setSelectedIds} - /> - ); - })} + {itemsToRender} ); } diff --git a/packages/components/src/tree/boards/tree-keyboard.board.tsx b/packages/components/src/tree/boards/tree-keyboard.board.tsx index d81ffc51..30640190 100644 --- a/packages/components/src/tree/boards/tree-keyboard.board.tsx +++ b/packages/components/src/tree/boards/tree-keyboard.board.tsx @@ -10,7 +10,21 @@ const data: TreeItemData = { id: '1', title: 'item 1', children: [ - { id: '2', title: 'item 2' }, + { + 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' }, @@ -35,6 +49,7 @@ export default createBoard({ props: { ref: scrollRef, id: 'LIST', + style: { outline: 'none', width: '12rem' }, }, }} eventRoots={[scrollRef]} @@ -63,6 +78,6 @@ export default createBoard({ ], environmentProps: { windowWidth: 600, - windowHeight: 400, + windowHeight: 661, }, }); diff --git a/packages/components/src/tree/tree.tsx b/packages/components/src/tree/tree.tsx index 3f382a2a..e167fa46 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 098a613e..e0478a63 100644 --- a/packages/components/src/tree/types.ts +++ b/packages/components/src/tree/types.ts @@ -18,9 +18,7 @@ export interface TreeItemProps extends ListItemProps { isOpen: boolean; hasChildren: boolean; indent: number; - open(): void; - close(): void; } From e309741200abb4734ab61a1ff89ee2f9cace7518 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Wed, 18 Dec 2024 15:00:53 +0200 Subject: [PATCH 12/21] fix "enter" issue with range selection --- packages/components/src/board-assets/items.ts | 1 - .../tree-items/tree-item-renderer.st.css | 2 +- packages/components/src/list/list.tsx | 64 ++++++++----------- .../src/tree/boards/tree-keyboard.board.tsx | 2 +- 4 files changed, 29 insertions(+), 40 deletions(-) diff --git a/packages/components/src/board-assets/items.ts b/packages/components/src/board-assets/items.ts index 1f37a770..be03cf08 100644 --- a/packages/components/src/board-assets/items.ts +++ b/packages/components/src/board-assets/items.ts @@ -5,7 +5,6 @@ export interface ItemData { export interface TreeItemData extends ItemData { children?: TreeItemData[]; - index?: number; } export const getId = ({ id }: ItemData) => id; 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 fe61c225..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 @@ -9,8 +9,8 @@ padding-left: calc(var(--indent) * 8px); height: 24px; user-select: none; - cursor: pointer; outline: none; + cursor: pointer; border-radius: 12px; align-items: center; margin: 4px 0px; diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index e7ea063a..9ca3e135 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -80,6 +80,23 @@ export function List({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; + // 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.length === 1) { + rangeSelectionAnchorId.current = selectedIds[0]; + } else if (selectedIds.length === 0) { + rangeSelectionAnchorId.current = undefined; + } + }, [selectedIds]); + const indexMap = useRef(new Map()); const itemsToRender = useMemo(() => { @@ -100,20 +117,18 @@ export function List({ data={item} focus={setFocusedId} isFocused={focusedId === id} - isSelected={selectedIds.findIndex((selectedId) => selectedId === id) !== -1} + isSelected={selectedIds.includes(id)} select={setSelectedIds} />, ); } return jsxElements; - }, [ItemRenderer, focusedId, getId, items, onItemMount, onItemUnmount, selectedIds, setFocusedId, setSelectedIds]); - - const rangeSelectionAnchor = useRef(focusedId); + }, [items, getId, ItemRenderer, onItemMount, onItemUnmount, setFocusedId, focusedId, selectedIds, setSelectedIds]); const onClick = useIdListener( useCallback( - (id: string | undefined, ev: React.MouseEvent): void => { + (id, ev: React.MouseEvent): void => { // allowing to clear selection when providing an empty select ids array if (!id) { setSelectedIds([]); @@ -121,28 +136,6 @@ export function List({ return; } - if (!ev.shiftKey) { - // Given a focused item, if the user clicks on an item while holding shift, - // the range selection will start from the first-focused item, and consider it as the starting point - // for other selection made while holding shift. - - // Consider the following steps for the following example: - // - item 1 - // - item 2 - // - item 3 - // - item 4 - // - item 5 - - // 1. focus on item 2 <- this selects item 2 and sets it as the rangeSelectionAnchor - // 2. click on item 4 while holding shift - // the expected behavior is to select items 2, 3, and 4 - // 3. now click on item 1 while holding shift - // the expected behavior is to select items 1 and 2, instead of 1, 2, 3, and 4 - // since item 2 is the anchored item. - - rangeSelectionAnchor.current = id; - } - setFocusedId(id); const isSameSelected = selectedIds.includes(id); @@ -159,12 +152,9 @@ export function List({ const isCtrlPressed = ev.ctrlKey || ev.metaKey; const isShiftPressed = ev.shiftKey; - if (isSameSelected && isCtrlPressed) { + if (isCtrlPressed && isSameSelected) { setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); - return; - } - - if (isCtrlPressed) { + } else if (isCtrlPressed) { setSelectedIds([...selectedIds, id]); } else if (isShiftPressed) { const [first] = selectedIds; @@ -174,9 +164,9 @@ export function List({ return; } - // if the `rangeSelectionAnchor` is not set, we will consider the first selected item as the - // starting point of the range selection. - const firstIndex = indexMap.current.get(rangeSelectionAnchor.current || first); + // if the `rangeSelectionAnchorId` is not set, we will consider the + // first selected item as the starting point of the range selection. + const firstIndex = indexMap.current.get(rangeSelectionAnchorId.current || first); const selectedIndex = indexMap.current.get(id); if (firstIndex === undefined || selectedIndex === undefined) { @@ -187,13 +177,13 @@ export function List({ const startIndex = Math.min(firstIndex, selectedIndex); const endIndex = Math.max(firstIndex, selectedIndex); - // we add 1 to the endIndex to include the last item in the selection + // we add 1 to `endIndex` to include the last item in the selection setSelectedIds(items.slice(startIndex, endIndex + 1).map(getId)); } else { setSelectedIds([id]); } }, - [enableMultiselect, getId, items, selectedIds, setFocusedId, setSelectedIds], + [enableMultiselect, getId, setSelectedIds, items, rangeSelectionAnchorId, selectedIds, setFocusedId], ), ); diff --git a/packages/components/src/tree/boards/tree-keyboard.board.tsx b/packages/components/src/tree/boards/tree-keyboard.board.tsx index 8a7dc34f..c1ec1146 100644 --- a/packages/components/src/tree/boards/tree-keyboard.board.tsx +++ b/packages/components/src/tree/boards/tree-keyboard.board.tsx @@ -84,6 +84,6 @@ export default createBoard({ ], environmentProps: { windowWidth: 600, - windowHeight: 661, + windowHeight: 400, }, }); From 75c2e47792c0f31163416e1cf75c8f53fbf98479 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Wed, 18 Dec 2024 15:40:10 +0200 Subject: [PATCH 13/21] passing tests --- packages/components/src/tree/boards/consts.ts | 3 + .../src/tree/boards/tree-focus.board.tsx | 7 +- .../src/tree/boards/tree-keyboard.board.tsx | 28 ++----- .../src/tree/boards/tree-multi-selection.tsx | 81 +++++++++++++++++++ 4 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 packages/components/src/tree/boards/consts.ts create mode 100644 packages/components/src/tree/boards/tree-multi-selection.tsx 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 88801c74..a54f4601 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', @@ -70,13 +71,13 @@ export default createBoard({ 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) + expectElementStyle('[data-id="2"]', FOCUSED_STYLE), // blue (focused) clickAction('#clear'), - expectElementStyle('[data-id="2"]', { color: 'rgb(0, 0, 0)' }), //black (not focused) + expectElementStyle('[data-id="2"]', DEFAULT_STYLE), // not focused clickAction('#select'), focusAction('#LIST'), keyDownAction('[data-id="5"]', KeyCodes.ArrowDown, 40), - expectElementStyle('[data-id="6"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + 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 c1ec1146..1d7151a3 100644 --- a/packages/components/src/tree/boards/tree-keyboard.board.tsx +++ b/packages/components/src/tree/boards/tree-keyboard.board.tsx @@ -11,26 +11,13 @@ 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', 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: '2', title: 'item 2' }, { id: '3', title: 'item 3' }, { id: '4', title: 'item 4' }, { id: '5', title: 'item 5' }, @@ -55,7 +42,6 @@ export default createBoard({ props: { ref: scrollRef, id: 'LIST', - style: { outline: 'none', width: '12rem' }, }, }} eventRoots={[scrollRef]} @@ -70,15 +56,15 @@ export default createBoard({ keyDownAction('#LIST', KeyCodes.ArrowRight, 39), expectElement('[data-id="2"]'), keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, 40), - expectElementStyle('[data-id="2"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + expectElementStyle('[data-id="2"]', FOCUSED_STYLE), //blue (focused) keyDownAction('[data-id="2"]', KeyCodes.Space, 32), - expectElementStyle('[data-id="2"]', { textDecorationLine: 'underline' }), + expectElementStyle('[data-id="2"]', SELECTED_STYLE), keyDownAction('[data-id="2"]', KeyCodes.Home, 36), - expectElementStyle('[data-id="1"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + expectElementStyle('[data-id="1"]', FOCUSED_STYLE), //blue (focused) keyDownAction('[data-id="1"]', KeyCodes.End, 35), - expectElementStyle('[data-id="6"]', { color: 'rgb(0, 0, 255)' }), //blue (focused) + expectElementStyle('[data-id="6"]', FOCUSED_STYLE), //blue (focused) keyDownAction('[data-id="6"]', KeyCodes.Enter, 13), - expectElementStyle('[data-id="6"]', { textDecorationLine: 'underline' }), //selected + expectElementStyle('[data-id="6"]', SELECTED_STYLE), // selected ], }), ], diff --git a/packages/components/src/tree/boards/tree-multi-selection.tsx b/packages/components/src/tree/boards/tree-multi-selection.tsx new file mode 100644 index 00000000..58d1d70c --- /dev/null +++ b/packages/components/src/tree/boards/tree-multi-selection.tsx @@ -0,0 +1,81 @@ +import { createBoard } from '@wixc3/react-board'; +import React, { 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'; + +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); + 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]} + /> + ); + }, + // plugins: [ + // scenarioPlugin.use({ + // title: 'tree focus test', + // events: [ + // clickAction('[data-id="1"]'), + // keyDownAction('#LIST', KeyCodes.ArrowRight, 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 + // ], + // }), + // ], + environmentProps: { + windowWidth: 600, + windowHeight: 400, + }, +}); From 91f7ed58f92e44616cedebc91661681027e70e70 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Wed, 18 Dec 2024 16:29:14 +0200 Subject: [PATCH 14/21] new tests --- packages/components/src/board-index.ts | 2 + .../scenario-plugin/scenario-plugin.tsx | 9 +- .../src/tree/boards/tree-focus.board.tsx | 6 +- .../src/tree/boards/tree-keyboard.board.tsx | 12 +- .../boards/tree-multi-selection.board.tsx | 148 ++++++++++++++++++ .../src/tree/boards/tree-multi-selection.tsx | 81 ---------- 6 files changed, 165 insertions(+), 93 deletions(-) create mode 100644 packages/components/src/tree/boards/tree-multi-selection.board.tsx delete mode 100644 packages/components/src/tree/boards/tree-multi-selection.tsx 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..70691f0b 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, }), ); }, diff --git a/packages/components/src/tree/boards/tree-focus.board.tsx b/packages/components/src/tree/boards/tree-focus.board.tsx index a54f4601..31076320 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -67,16 +67,16 @@ 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), + keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, { which: 40 }), expectElementStyle('[data-id="2"]', FOCUSED_STYLE), // blue (focused) clickAction('#clear'), expectElementStyle('[data-id="2"]', DEFAULT_STYLE), // not focused clickAction('#select'), focusAction('#LIST'), - keyDownAction('[data-id="5"]', KeyCodes.ArrowDown, 40), + 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 1d7151a3..47c7d693 100644 --- a/packages/components/src/tree/boards/tree-keyboard.board.tsx +++ b/packages/components/src/tree/boards/tree-keyboard.board.tsx @@ -53,17 +53,17 @@ 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), + keyDownAction('[data-id="1"]', KeyCodes.ArrowDown, { which: 40 }), expectElementStyle('[data-id="2"]', FOCUSED_STYLE), //blue (focused) - keyDownAction('[data-id="2"]', KeyCodes.Space, 32), + keyDownAction('[data-id="2"]', KeyCodes.Space, { which: 32 }), expectElementStyle('[data-id="2"]', SELECTED_STYLE), - keyDownAction('[data-id="2"]', KeyCodes.Home, 36), + keyDownAction('[data-id="2"]', KeyCodes.Home, { which: 36 }), expectElementStyle('[data-id="1"]', FOCUSED_STYLE), //blue (focused) - keyDownAction('[data-id="1"]', KeyCodes.End, 35), + keyDownAction('[data-id="1"]', KeyCodes.End, { which: 35 }), expectElementStyle('[data-id="6"]', FOCUSED_STYLE), //blue (focused) - keyDownAction('[data-id="6"]', KeyCodes.Enter, 13), + 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..7e70b6ea --- /dev/null +++ b/packages/components/src/tree/boards/tree-multi-selection.board.tsx @@ -0,0 +1,148 @@ +import { createBoard } from '@wixc3/react-board'; +import React, { 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); + 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]} + /> + ); + }, + 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"`]: SELECTED_STYLE, + [`data-id="3"`]: DEFAULT_STYLE, + [`data-id="4"`]: SELECTED_STYLE, + [`data-id="5"`]: DEFAULT_STYLE, + [`data-id="6"`]: SELECTED_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: 400, + }, +}); diff --git a/packages/components/src/tree/boards/tree-multi-selection.tsx b/packages/components/src/tree/boards/tree-multi-selection.tsx deleted file mode 100644 index 58d1d70c..00000000 --- a/packages/components/src/tree/boards/tree-multi-selection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { createBoard } from '@wixc3/react-board'; -import React, { 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'; - -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); - 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]} - /> - ); - }, - // plugins: [ - // scenarioPlugin.use({ - // title: 'tree focus test', - // events: [ - // clickAction('[data-id="1"]'), - // keyDownAction('#LIST', KeyCodes.ArrowRight, 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 - // ], - // }), - // ], - environmentProps: { - windowWidth: 600, - windowHeight: 400, - }, -}); From 29a22d5c4aca7d4d851c18cce59e06aca3d3c859 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Wed, 18 Dec 2024 16:50:39 +0200 Subject: [PATCH 15/21] refactor list component to improve range selection logic --- packages/components/src/list/list.tsx | 96 ++++++++++++++++++--------- 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 9ca3e135..c8d157c2 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -97,15 +97,15 @@ export function List({ } }, [selectedIds]); - const indexMap = useRef(new Map()); + const { current: indexMap } = useRef(new Map()); const itemsToRender = useMemo(() => { - indexMap.current.clear(); + indexMap.clear(); const jsxElements: JSX.Element[] = []; for (const [index, item] of items.entries()) { const id = getId(item); - indexMap.current.set(id, index); + indexMap.set(id, index); jsxElements.push( ({ } return jsxElements; - }, [items, getId, ItemRenderer, onItemMount, onItemUnmount, setFocusedId, focusedId, selectedIds, setSelectedIds]); + }, [ + indexMap, + items, + getId, + ItemRenderer, + onItemMount, + onItemUnmount, + setFocusedId, + focusedId, + selectedIds, + setSelectedIds, + ]); const onClick = useIdListener( useCallback( @@ -138,10 +149,10 @@ export function List({ setFocusedId(id); - const isSameSelected = selectedIds.includes(id); + const isAlreadySelected = selectedIds.includes(id); if (!enableMultiselect) { - if (isSameSelected) { + if (isAlreadySelected) { return; } @@ -152,38 +163,26 @@ export function List({ const isCtrlPressed = ev.ctrlKey || ev.metaKey; const isShiftPressed = ev.shiftKey; - if (isCtrlPressed && isSameSelected) { + if (isCtrlPressed && isAlreadySelected) { setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); } else if (isCtrlPressed) { setSelectedIds([...selectedIds, id]); } else if (isShiftPressed) { - const [first] = selectedIds; - - if (!first) { - setSelectedIds([id]); - return; - } - - // if the `rangeSelectionAnchorId` is not set, we will consider the - // first selected item as the starting point of the range selection. - const firstIndex = indexMap.current.get(rangeSelectionAnchorId.current || first); - const selectedIndex = indexMap.current.get(id); - - if (firstIndex === undefined || selectedIndex === undefined) { - setSelectedIds([id]); - return; - } - - 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 - setSelectedIds(items.slice(startIndex, endIndex + 1).map(getId)); + setSelectedIds( + getRangeSelection({ + items, + id, + indexMap, + selectedIds, + rangeSelectionAnchorId: rangeSelectionAnchorId.current, + getId, + }), + ); } else { setSelectedIds([id]); } }, - [enableMultiselect, getId, setSelectedIds, items, rangeSelectionAnchorId, selectedIds, setFocusedId], + [setFocusedId, selectedIds, enableMultiselect, setSelectedIds, items, indexMap, getId], ), ); @@ -234,3 +233,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); +} From c69cf017a1e5f2fb414c385da414160dc5ea215e Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Thu, 19 Dec 2024 16:03:55 +0200 Subject: [PATCH 16/21] 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< From 26adf4f95d49b4915ca2430059d5ba638d0b4f25 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Thu, 19 Dec 2024 18:24:45 +0200 Subject: [PATCH 17/21] rename `mainSelection` to `lastSelectedId` --- .../components/src/hooks/use-keyboard-nav.ts | 2 +- .../use-tree-view-keyboard-interaction.ts | 18 ++++++++++++------ packages/components/src/list/list.tsx | 10 +++++----- packages/components/src/list/types.ts | 2 +- .../src/tree/boards/tree-with-lanes.board.tsx | 2 +- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/components/src/hooks/use-keyboard-nav.ts b/packages/components/src/hooks/use-keyboard-nav.ts index e7774fe9..96f50699 100644 --- a/packages/components/src/hooks/use-keyboard-nav.ts +++ b/packages/components/src/hooks/use-keyboard-nav.ts @@ -118,7 +118,7 @@ export const getHandleKeyboardNav = ( break; case KeyCodes.Space: case KeyCodes.Enter: - setSelectedIds({ mainSelection: focusedId, ids: [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 754a25d0..d8849140 100644 --- a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts +++ b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts @@ -63,7 +63,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!itemId) return; if (selectionFollowsFocus) { - select({ mainSelection: itemId, ids: [itemId] }, 'keyboard'); + select({ lastSelectedId: itemId, ids: [itemId] }, 'keyboard'); } else { focus(itemId); } @@ -75,7 +75,7 @@ export const useTreeViewKeyboardInteraction = ({ if (!focusedItemId) { return; } - select({ mainSelection: focusedItemId, ids: [focusedItemId] }); + select({ lastSelectedId: focusedItemId, ids: [focusedItemId] }); }, [focusedItemId, select]); const handleArrowRight = useCallback(() => { @@ -112,9 +112,12 @@ export const useTreeViewKeyboardInteraction = ({ if (event.shiftKey) { if (!selectedIds.includes(previous)) { - select({ mainSelection: previous, ids: [...selectedIds, previous] }); + select({ lastSelectedId: previous, ids: [...selectedIds, previous] }); } else { - select({ mainSelection: focusedItemId, ids: selectedIds.filter((id) => id !== focusedItemId) }); + select({ + lastSelectedId: focusedItemId, + ids: selectedIds.filter((id) => id !== focusedItemId), + }); } } } @@ -131,9 +134,12 @@ export const useTreeViewKeyboardInteraction = ({ if (event.shiftKey) { if (!selectedIds.includes(next)) { - select({ mainSelection: next, ids: [...selectedIds, next] }); + select({ lastSelectedId: next, ids: [...selectedIds, next] }); } else { - select({ mainSelection: focusedItemId, ids: selectedIds.filter((id) => id !== focusedItemId) }); + select({ + lastSelectedId: focusedItemId, + ids: selectedIds.filter((id) => id !== focusedItemId), + }); } } } diff --git a/packages/components/src/list/list.tsx b/packages/components/src/list/list.tsx index 506224ce..57a9deef 100644 --- a/packages/components/src/list/list.tsx +++ b/packages/components/src/list/list.tsx @@ -157,7 +157,7 @@ export function List({ return; } - setSelectedIds({ mainSelection: id, ids: [id] }); + setSelectedIds({ lastSelectedId: id, ids: [id] }); return; } @@ -166,14 +166,14 @@ export function List({ if (isCtrlPressed && isAlreadySelected) { setSelectedIds({ - mainSelection: id, + lastSelectedId: id, ids: selectedIds.ids.filter((selectedId) => selectedId !== id), }); } else if (isCtrlPressed) { - setSelectedIds({ mainSelection: id, ids: [...selectedIds.ids, id] }); + setSelectedIds({ lastSelectedId: id, ids: [...selectedIds.ids, id] }); } else if (isShiftPressed) { setSelectedIds({ - mainSelection: id, + lastSelectedId: id, ids: getRangeSelection({ items, id, @@ -184,7 +184,7 @@ export function List({ }), }); } else { - setSelectedIds({ mainSelection: id, ids: [id] }); + setSelectedIds({ lastSelectedId: 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 index fc73c7b0..a502208e 100644 --- a/packages/components/src/list/types.ts +++ b/packages/components/src/list/types.ts @@ -1,4 +1,4 @@ export interface ListSelection { - mainSelection?: string; + lastSelectedId?: string; ids: string[]; } 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 0c599050..7c81654e 100644 --- a/packages/components/src/tree/boards/tree-with-lanes.board.tsx +++ b/packages/components/src/tree/boards/tree-with-lanes.board.tsx @@ -183,7 +183,7 @@ export default createBoard({ getParents, selectItem: (item) => { updateSelection({ - mainSelection: item.id, + lastSelectedId: item.id, ids: [item.id], }); }, From ba9d8ffa68b2ed921ee63d6ab3bc6b710bf7b081 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Thu, 9 Jan 2025 11:37:29 +0200 Subject: [PATCH 18/21] export list types to expose `ListSelection` --- packages/components/src/list/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From a0a3fde18de3df0829c6bdb87432de9cbf08bf95 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Sun, 12 Jan 2025 15:54:52 +0200 Subject: [PATCH 19/21] fix focus in keyboard interaction --- .../components/src/hooks/use-tree-view-keyboard-interaction.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 d8849140..4ba68040 100644 --- a/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts +++ b/packages/components/src/hooks/use-tree-view-keyboard-interaction.ts @@ -62,10 +62,9 @@ export const useTreeViewKeyboardInteraction = ({ (itemId: string | undefined) => { if (!itemId) return; + focus(itemId); if (selectionFollowsFocus) { select({ lastSelectedId: itemId, ids: [itemId] }, 'keyboard'); - } else { - focus(itemId); } }, [focus, select, selectionFollowsFocus], From 8e31d6e496c146f75c609251a69c41f7b326e286 Mon Sep 17 00:00:00 2001 From: PeterShershov Date: Mon, 13 Jan 2025 12:59:45 +0200 Subject: [PATCH 20/21] fix expectElementsStyle and multi-select tree board --- .../scenario-plugin/scenario-plugin.tsx | 13 ++++- .../boards/tree-multi-selection.board.tsx | 54 +++++++++---------- 2 files changed, 39 insertions(+), 28 deletions(-) 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 70691f0b..783af166 100644 --- a/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx +++ b/packages/components/src/board-plugins/scenario-plugin/scenario-plugin.tsx @@ -493,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/tree/boards/tree-multi-selection.board.tsx b/packages/components/src/tree/boards/tree-multi-selection.board.tsx index ef1f99ff..05dc364e 100644 --- a/packages/components/src/tree/boards/tree-multi-selection.board.tsx +++ b/packages/components/src/tree/boards/tree-multi-selection.board.tsx @@ -103,8 +103,8 @@ export default createBoard({ // 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, + [`[data-id="1"]`]: SELECTED_STYLE, + [`[data-id="2"]`]: SELECTED_STYLE, }), // checking two sets of ranges // 1 <- selected @@ -115,54 +115,54 @@ export default createBoard({ 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, + [`[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, + [`[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, + [`[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, + [`[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, + [`[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"`]: SELECTED_STYLE, - [`data-id="3"`]: DEFAULT_STYLE, - [`data-id="4"`]: SELECTED_STYLE, - [`data-id="5"`]: DEFAULT_STYLE, - [`data-id="6"`]: SELECTED_STYLE, + [`[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 }), From 7d85fa585e90d46113c7e273a304ec9bb458b7b1 Mon Sep 17 00:00:00 2001 From: Sagiv Dayan Date: Mon, 13 Jan 2025 16:07:13 +0200 Subject: [PATCH 21/21] change tree-focus board --- packages/components/src/tree/boards/tree-focus.board.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/tree/boards/tree-focus.board.tsx b/packages/components/src/tree/boards/tree-focus.board.tsx index 31076320..e496d720 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -54,10 +54,10 @@ export default createBoard({ focusControl={focusControl} /> - ); @@ -74,7 +74,7 @@ export default createBoard({ expectElementStyle('[data-id="2"]', FOCUSED_STYLE), // blue (focused) clickAction('#clear'), expectElementStyle('[data-id="2"]', DEFAULT_STYLE), // not focused - clickAction('#select'), + clickAction('#focus'), focusAction('#LIST'), keyDownAction('[data-id="5"]', KeyCodes.ArrowDown, { which: 40 }), expectElementStyle('[data-id="6"]', FOCUSED_STYLE), // blue (focused)