Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(list): support multiple selection #1076

Closed
wants to merge 11 commits into from
Closed
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/hooks/use-id-based-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import React, { useCallback } from 'react';
import { getElementWithId } from '../common';

export function useIdListener<EVType extends React.KeyboardEvent | React.MouseEvent>(
idSetter: (id: string | undefined, element?: Element) => void
idSetter: (id: string | undefined, ev: EVType, element?: Element) => void,
): (ev: EVType) => any {
return useCallback(
(ev: EVType) => {
if (!ev.currentTarget || !ev.target) {
return;
}
const res = getElementWithId(ev.target as Element, ev.currentTarget as unknown as Element);
idSetter(res?.id || undefined, res?.element);
idSetter(res?.id || undefined, ev, res?.element);
},
[idSetter]
[idSetter],
);
}
4 changes: 2 additions & 2 deletions packages/components/src/hooks/use-keyboard-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const getHandleKeyboardNav = (
elementsParent: React.RefObject<HTMLElement | null>,
focusedId: string | undefined,
setFocusedId: (id: string) => void,
setSelectedId: (id: string) => void,
setSelectedIds: (ids: string[]) => void,
) => {
const onKeyPress = (ev: React.KeyboardEvent) => {
if (
Expand Down Expand Up @@ -117,7 +117,7 @@ export const getHandleKeyboardNav = (
break;
case KeyCodes.Space:
case KeyCodes.Enter:
setSelectedId(focusedId);
setSelectedIds([focusedId]);
break;
default:
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface TreeViewKeyboardInteractionsParams {
open: (itemId: string) => void;
close: (itemId: string) => void;
focus: (itemId: string) => void;
select: ProcessedControlledState<string, KeyboardSelectMeta>[1];
select: ProcessedControlledState<string[], KeyboardSelectMeta>[1];
isOpen: (itemId: string) => boolean;
isEndNode: (itemId: string) => boolean;
getPrevious: (itemId: string) => string | undefined;
Expand Down Expand Up @@ -60,7 +60,7 @@ export const useTreeViewKeyboardInteraction = ({
if (!itemId) return;

if (selectionFollowsFocus) {
select(itemId, 'keyboard');
select([itemId], 'keyboard');
} else {
focus(itemId);
}
Expand All @@ -72,7 +72,7 @@ export const useTreeViewKeyboardInteraction = ({
if (!focusedItemId) {
return;
}
select(focusedItemId);
select([focusedItemId]);
}, [focusedItemId, select]);

const handleArrowRight = useCallback(() => {
Expand Down
61 changes: 48 additions & 13 deletions packages/components/src/list/list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import {
callInternalFirst,
defaultRoot,
Expand Down Expand Up @@ -42,7 +42,7 @@ export interface ListItemProps<T> {
isFocused: boolean;
isSelected: boolean;
focus: (id?: string) => void;
select: (id?: string) => void;
select: (ids: string[]) => void;
}

export interface ListProps<T> {
Expand All @@ -51,11 +51,12 @@ export interface ListProps<T> {
items: T[];
ItemRenderer: React.ComponentType<ListItemProps<T>>;
focusControl?: StateControls<string | undefined>;
selectionControl?: StateControls<string | undefined>;
selectionControl?: StateControls<string[]>;
transmitKeyPress?: UseTransmit<React.KeyboardEventHandler>;
onItemMount?: (item: T) => void;
onItemUnmount?: (item: T) => void;
disableKeyboard?: boolean;
enableMultiselect?: boolean;
}

export type List<T> = (props: ListProps<T>) => React.ReactElement;
Expand All @@ -71,26 +72,60 @@ export function List<T, EL extends HTMLElement = HTMLDivElement>({
onItemMount,
onItemUnmount,
disableKeyboard,
enableMultiselect = true,
}: ListProps<T>): 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<EL>(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<Element, 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<HTMLElement | null>,
focusedId,
setFocusedId,
setSelectedId,
setSelectedIds,
);
if (transmitKeyPress) {
transmitKeyPress(callInternalFirst(onKeyPress, listRoot?.props?.onKeyPress));
Expand Down Expand Up @@ -118,8 +153,8 @@ export function List<T, EL extends HTMLElement = HTMLDivElement>({
data={item}
focus={setFocusedId}
isFocused={focusedId === id}
isSelected={selectedId === id}
select={setSelectedId}
isSelected={selectedIds.findIndex((selectedId) => selectedId === id) !== -1}
select={setSelectedIds}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ const ItemRenderer: React.FC<ListItemProps<ItemData>> = (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<string | undefined>(`a${initialSelectedIndex}`);

return (
<>
Expand All @@ -60,7 +60,7 @@ export default createBoard({
/>
</label>

<button id={selectItemButton} onClick={() => setSelectedItem(`a${input}`)}>
<button id={selectItemButton} onClick={() => setFocused(`a${input}`)}>
Select
</button>
</div>
Expand All @@ -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: {
Expand All @@ -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);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@ import type { DimensionsById } from '../../common';
import type { ListProps } from '../../list/list';
import type { ScrollListProps } from '../../scroll-list/scroll-list';

export const useScrollListScrollToSelected = <T, EL extends HTMLElement>({
scrollToSelection,
export const useScrollListScrollToFocused = <T, EL extends HTMLElement>({
scrollToFocused,
scrollWindow,
scrollListRef,
items,
getId,
selected,
focused,
averageItemSize,
itemsDimensions,
mountedItems,
isHorizontal,
extraRenderSize,
scrollWindowSize,
}: {
scrollToSelection: boolean;
scrollToFocused: ScrollListProps<T, EL>['scrollToFocused'];
scrollWindow?: ScrollListProps<T, EL>['scrollWindow'];
scrollListRef: RefObject<EL | null>;
items: ListProps<T>['items'];
getId: ListProps<T>['getId'];
selected: string | undefined;
focused?: string;
averageItemSize: number;
itemsDimensions: MutableRefObject<DimensionsById>;
mountedItems: MutableRefObject<Set<string>>;
Expand All @@ -34,12 +34,12 @@ export const useScrollListScrollToSelected = <T, EL extends HTMLElement>({
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] || {
Expand All @@ -59,35 +59,26 @@ export const useScrollListScrollToSelected = <T, EL extends HTMLElement>({

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;
loadingTimeout.current = 0;
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, {
Expand All @@ -107,33 +98,28 @@ export const useScrollListScrollToSelected = <T, EL extends HTMLElement>({

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]);
};
Loading
Loading