diff --git a/package.json b/package.json index 7fd761ba..e9a9dc88 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/hooks/use-id-based-event.ts b/packages/components/src/hooks/use-id-based-event.ts index 12face39..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,8 +10,8 @@ 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 d633621f..8b672c9f 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 c569886e..357a0542 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 } from 'react'; import { callInternalFirst, defaultRoot, @@ -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 { @@ -51,11 +51,12 @@ export interface ListProps { items: T[]; ItemRenderer: React.ComponentType>; focusControl?: StateControls; - selectionControl?: StateControls; + selectionControl?: StateControls; transmitKeyPress?: UseTransmit; onItemMount?: (item: T) => void; onItemUnmount?: (item: T) => void; disableKeyboard?: boolean; + enableMultiselect?: boolean; } export type List = (props: ListProps) => React.ReactElement; @@ -71,26 +72,60 @@ export function List({ onItemMount, onItemUnmount, disableKeyboard, + enableMultiselect = true, }: ListProps): React.ReactElement { - 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 defaultRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualRef = listRoot?.props?.ref || defaultRef; - const onClick = useIdListener(setSelectedId); + const onClick = useIdListener( + useCallback( + (id: string | undefined, ev: React.MouseEvent): void => { + if (!id) { + setSelectedIds([]); + setFocusedId(undefined); + return; + } + + setFocusedId(id); + + 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, setFocusedId, setSelectedIds], + ), + ); + const onKeyPress = disableKeyboard ? () => {} : getHandleKeyboardNav( actualRef as React.RefObject, focusedId, setFocusedId, - setSelectedId, + setSelectedIds, ); if (transmitKeyPress) { transmitKeyPress(callInternalFirst(onKeyPress, listRoot?.props?.onKeyPress)); @@ -118,8 +153,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 c105ea75..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 [selectedItem, setSelectedItem] = useState(`a${initialSelectedIndex}`); const [input, setInput] = useState(initialSelectedIndex); + const [focused, setFocused] = useState(`a${initialSelectedIndex}`); return ( <> @@ -60,7 +60,7 @@ export default createBoard({ /> - @@ -71,8 +71,8 @@ export default createBoard({ items={items} itemSize={() => 50} getId={getId} - selectionControl={[selectedItem, noop]} - scrollToSelection={true} + focusControl={[focused, noop]} + scrollToFocused={true} scrollListRoot={{ el: 'div', props: { @@ -96,7 +96,7 @@ export default createBoard({ }, plugins: [ scenarioPlugin.use({ - title: 'should scroll selected element into view', + title: 'should scroll focused element into view', resetBoard: () => { window.scrollTo(0, 0); }, diff --git a/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts b/packages/components/src/scroll-list/hooks/use-scroll-list-scroll-to-selected.ts index 973ca014..f7103114 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 | undefined; + focused?: string; averageItemSize: number; itemsDimensions: MutableRefObject; mountedItems: MutableRefObject>; @@ -34,12 +34,12 @@ export const useScrollListScrollToSelected = ({ const loadingTimeout = useRef(0); const timeout = useRef(0); const isScrollingToSelection = useRef(false); - const selectedIndex = useMemo(() => items.findIndex((i) => getId(i) === selected), [items, getId, selected]); + const focusedIndex = useMemo(() => items.findIndex((i) => getId(i) === focused), [items, getId, focused]); const calculateDistance = useCallback( ({ itemIndex, direction }: { itemIndex: number; direction: 'up' | 'down' }) => { let distance = 0; - for (let index = itemIndex; index !== selectedIndex; direction === 'down' ? index++ : index--) { + for (let index = itemIndex; index !== focusedIndex; direction === 'down' ? index++ : index--) { const item = items[index]!; const id = getId(item); const { height, width } = itemsDimensions.current[id] || { @@ -59,16 +59,7 @@ export const useScrollListScrollToSelected = ({ return Math.floor((direction === 'down' ? 1 : -1) * distance); }, - [ - averageItemSize, - extraRenderSize, - getId, - isHorizontal, - items, - itemsDimensions, - scrollWindowSize, - selectedIndex, - ], + [averageItemSize, extraRenderSize, getId, isHorizontal, items, itemsDimensions, scrollWindowSize, focusedIndex], ); const cleanUp = () => { isScrollingToSelection.current = false; @@ -76,18 +67,18 @@ export const useScrollListScrollToSelected = ({ clearTimeout(timeout.current); }; const scrollTo = useCallback( - (selectedIndex: number, repeated = false) => { + (focusedIndex: number, repeated = false) => { if (!scrollListRef.current) { return; } clearTimeout(timeout.current); - const scrollIntoView = (selected: number, position: ScrollLogicalPosition) => { - const node = scrollListRef.current?.querySelector(`[data-id='${getId(items[selected]!)}']`); + const scrollIntoView = (focusedIndex: number, position: ScrollLogicalPosition) => { + const node = scrollListRef.current?.querySelector(`[data-id='${getId(items[focusedIndex]!)}']`); if (!node) { timeout.current = window.setTimeout( - () => isScrollingToSelection.current && scrollTo(selected, true), + () => isScrollingToSelection.current && scrollTo(focusedIndex, true), ); } else { scrollIntoViewIfNeeded(node, { @@ -107,33 +98,28 @@ export const useScrollListScrollToSelected = ({ let position: ScrollLogicalPosition = 'nearest'; - if (selectedIndex < firstIndex) { + if (focusedIndex < firstIndex) { position = 'start'; scrollTarget.scrollBy({ top: calculateDistance({ itemIndex: firstIndex, direction: 'up' }) }); - } else if (lastIndex < selectedIndex) { + } else if (lastIndex < focusedIndex) { position = 'end'; scrollTarget.scrollBy({ top: calculateDistance({ itemIndex: lastIndex, direction: 'down' }) }); } - timeout.current = window.setTimeout(() => scrollIntoView(selectedIndex, repeated ? 'center' : position)); + timeout.current = window.setTimeout(() => scrollIntoView(focusedIndex, repeated ? 'center' : position)); }, [scrollListRef, scrollWindow, mountedItems, items, getId, calculateDistance], ); useEffect(() => { - if ( - scrollToSelection && - selectedIndex > -1 && - mountedItems.current.size > 0 && - !isScrollingToSelection.current - ) { + if (scrollToFocused && focusedIndex > -1 && mountedItems.current.size > 0 && !isScrollingToSelection.current) { isScrollingToSelection.current = true; - scrollTo(selectedIndex); + scrollTo(focusedIndex); } return () => { cleanUp(); }; - }, [scrollToSelection, mountedItems, scrollTo, selectedIndex]); + }, [scrollToFocused, mountedItems, scrollTo, focusedIndex]); }; diff --git a/packages/components/src/scroll-list/scroll-list.tsx b/packages/components/src/scroll-list/scroll-list.tsx index 51f66263..29add9d0 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, @@ -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.includes(getId(data)), }), [getId, focused, selected], ); @@ -247,13 +247,13 @@ export function ScrollList({ loadedItemsNumber: items.length, }); - useScrollListScrollToSelected({ + useScrollListScrollToFocused({ scrollWindow, scrollListRef, - scrollToSelection, + scrollToFocused, items, getId, - selected, + focused, averageItemSize, mountedItems, isHorizontal, @@ -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..53803bf1 100644 --- a/packages/components/src/tree/boards/tree-focus.board.tsx +++ b/packages/components/src/tree/boards/tree-focus.board.tsx @@ -31,7 +31,9 @@ export default createBoard({ Board: () => { const openItemsControl = useState([]); const scrollRef = useRef(null); - const selectionControl = useState(); + const focusControl = useState(undefined); + const [, setFocus] = focusControl; + return (
@@ -40,7 +42,6 @@ export default createBoard({ ItemRenderer={TreeItemRenderer} getChildren={(it) => it.children || []} openItemsControls={openItemsControl} - selectionControl={selectionControl} overlay={{ el: () => null, props: {} }} listRoot={{ props: { @@ -49,11 +50,12 @@ export default createBoard({ }, }} eventRoots={[scrollRef]} + focusControl={focusControl} /> - -
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 2d9fcee3..3f382a2a 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..8d24ded1 100644 --- a/packages/components/src/tree/types.ts +++ b/packages/components/src/tree/types.ts @@ -38,7 +38,7 @@ export interface TreeAddedProps { eventRoots?: TreeViewKeyboardInteractionsParams['eventRoots']; ItemRenderer: React.ComponentType>; overlay?: typeof overlayRoot; - selectionControl?: StateControls; + selectionControl?: StateControls; } export type TreeProps = Omit<