diff --git a/packages/components/src/components/TagList/TagList.mdx b/packages/components/src/components/TagList/TagList.mdx index 454620bec..8e8de35fe 100644 --- a/packages/components/src/components/TagList/TagList.mdx +++ b/packages/components/src/components/TagList/TagList.mdx @@ -33,7 +33,7 @@ import { TagList } from '@koobiq/react-components'; ## Selection TagList keeps pointer selection explicit: a regular click only focuses a tag. -To select tags, use Ctrl / Cmd + click, Ctrl / Cmd + A, or press Space while a tag is focused. +To select tags, use Ctrl / Cmd + click, Shift + click, Shift + arrow keys, Ctrl / Cmd + A, or press Space while a tag is focused. @@ -102,16 +102,19 @@ or automatically derived from the values passed to the `items` prop. ## Keyboard -| Key | Behavior | -| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| / | Move focus to the next / previous tag (respects RTL direction). | -| Home / End | Move focus to the first / last tag. | -| Space | Toggle selection on the focused tag. | -| Ctrl / Cmd + click | Toggle selection without clearing the previous one. | -| Ctrl / Cmd + A | Select all tags. | -| Escape | Clear selection. Override with `escapeKeyBehavior="none"`. | -| Backspace / Delete | Remove the focused tag, or every selected tag if the focused tag is part of the selection. Requires `onRemove`. | -| Tab | Move focus into / out of the group as a single tab stop. | +| Key | Behavior | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| / | Move focus to the next / previous tag (respects RTL direction). | +| Home / End | Move focus to the first / last tag. | +| Shift + / | Move focus and extend the selected range to the next / previous tag. | +| Ctrl / Cmd + Shift + Home / End | Move focus and extend the selected range to the first / last tag. | +| Space | Toggle selection on the focused tag. | +| Ctrl / Cmd + click | Toggle selection without clearing the previous one. | +| Shift + click | Add the range from the focused tag to the clicked tag to the selection. | +| Ctrl / Cmd + A | Select all tags. | +| Escape | Clear selection. Override with `escapeKeyBehavior="none"`. | +| Backspace / Delete | Remove the focused tag, or every selected tag if the focused tag is part of the selection. Requires `onRemove`. | +| Tab | Move focus into / out of the group as a single tab stop. | ## CSS Variables diff --git a/packages/components/src/components/TagList/TagList.test.tsx b/packages/components/src/components/TagList/TagList.test.tsx index 2226f5994..9b23cb88e 100644 --- a/packages/components/src/components/TagList/TagList.test.tsx +++ b/packages/components/src/components/TagList/TagList.test.tsx @@ -184,7 +184,7 @@ describe('TagList', () => { await waitFor(() => expect(getTag()).toHaveFocus()); }); - it('should move focus without extending selection on Shift+Arrow', async () => { + it('should extend selection and move focus on Shift+Arrow', async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); @@ -202,33 +202,102 @@ describe('TagList', () => { const thirdTag = screen.getByText('three').closest('[role="row"]'); await waitFor(() => expect(thirdTag).toHaveFocus()); - expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(getTag()).toHaveAttribute('data-selected', 'true'); - expect(thirdTag).not.toHaveAttribute('data-selected'); + expect(thirdTag).toHaveAttribute('data-selected', 'true'); }); - it('should ignore Shift+modified arrow shortcuts', async () => { + it('should start Shift+Arrow range from the focused tag', async () => { const user = userEvent.setup(); - const onSelectionChange = vi.fn(); - render(renderComponent({ selectionMode: 'multiple', onSelectionChange })); + render(renderComponent({ selectionMode: 'multiple' })); await user.click(getTag()); - await user.keyboard('{Space}'); + await user.keyboard('{Shift>}{ArrowRight}{/Shift}'); + const thirdTag = screen.getByText('three').closest('[role="row"]'); + + await waitFor(() => expect(thirdTag).toHaveFocus()); expect(getTag()).toHaveAttribute('data-selected', 'true'); + expect(thirdTag).toHaveAttribute('data-selected', 'true'); + }); - onSelectionChange.mockClear(); + it('should shrink Shift+Arrow range when moving back toward the anchor', async () => { + const user = userEvent.setup(); - await user.keyboard('{Shift>}{Meta>}{ArrowRight}{/Meta}{/Shift}'); + render(renderComponent({ selectionMode: 'multiple' })); - expect(getTag()).toHaveFocus(); - expect(onSelectionChange).not.toHaveBeenCalled(); + await user.click(getTag()); + await user.keyboard('{Shift>}{ArrowRight}{ArrowRight}{ArrowLeft}{/Shift}'); + + const thirdTag = screen.getByText('three').closest('[role="row"]'); + const fourthTag = screen.getByText('four').closest('[role="row"]'); + + await waitFor(() => expect(thirdTag).toHaveFocus()); expect(getTag()).toHaveAttribute('data-selected', 'true'); + expect(thirdTag).toHaveAttribute('data-selected', 'true'); + expect(fourthTag).not.toHaveAttribute('data-selected'); + }); + + it('should start a new Shift+Arrow range after selection is cleared', async () => { + const user = userEvent.setup(); - expect( - screen.getByText('three').closest('[role="row"]') - ).not.toHaveAttribute('data-selected'); + render(renderComponent({ selectionMode: 'multiple' })); + + await user.click(getTag()); + await user.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await user.keyboard('{Escape}'); + await user.keyboard('{Shift>}{ArrowRight}{/Shift}'); + + const thirdTag = screen.getByText('three').closest('[role="row"]'); + const fourthTag = screen.getByText('four').closest('[role="row"]'); + + await waitFor(() => expect(fourthTag).toHaveFocus()); + expect(getTag()).not.toHaveAttribute('data-selected'); + expect(thirdTag).toHaveAttribute('data-selected', 'true'); + expect(fourthTag).toHaveAttribute('data-selected', 'true'); + }); + + it('should add range selection on Shift+Click', async () => { + const user = userEvent.setup(); + + render(renderComponent({ selectionMode: 'multiple' })); + + const thirdTag = screen.getByText('three').closest('[role="row"]'); + const fourthTag = screen.getByText('four').closest('[role="row"]'); + + await user.click(getTag()); + await user.keyboard('{Shift>}'); + await user.click(fourthTag as HTMLElement); + await user.keyboard('{/Shift}'); + + expect(getTag()).toHaveAttribute('data-selected', 'true'); + expect(thirdTag).toHaveAttribute('data-selected', 'true'); + expect(fourthTag).toHaveAttribute('data-selected', 'true'); + }); + + it('should not use a stale focused tag as a Shift+Click anchor', async () => { + const user = userEvent.setup(); + + render( + <> + {renderComponent({ selectionMode: 'multiple' })} + + + ); + + const thirdTag = screen.getByText('three').closest('[role="row"]'); + const fourthTag = screen.getByText('four').closest('[role="row"]'); + + await user.click(getTag()); + await user.click(screen.getByTestId('outside')); + await user.keyboard('{Shift>}'); + await user.click(fourthTag as HTMLElement); + await user.keyboard('{/Shift}'); + + expect(getTag()).not.toHaveAttribute('data-selected'); + expect(thirdTag).not.toHaveAttribute('data-selected'); + expect(fourthTag).toHaveAttribute('data-selected', 'true'); }); it('should not wrap focus past the last tag with ArrowRight', async () => { diff --git a/packages/primitives/src/behaviors/useTagList.ts b/packages/primitives/src/behaviors/useTagList.ts index b8eb1484e..232f719b9 100644 --- a/packages/primitives/src/behaviors/useTagList.ts +++ b/packages/primitives/src/behaviors/useTagList.ts @@ -9,12 +9,8 @@ import type { import { mergeProps, useFocusWithin, useLocale } from '@koobiq/react-core'; import { ListKeyboardDelegate, useSelectableList } from '@react-aria/selection'; import type { ListState } from '@react-stately/list'; -import type { MultipleSelectionManager } from '@react-stately/selection'; -const extendSelectionMethod: keyof MultipleSelectionManager = 'extendSelection'; - -const noopExtendSelection: MultipleSelectionManager['extendSelection'] = () => - undefined; +import { useTagListRangeSelection } from './useTagListRangeSelection'; export type AriaTagListProps = { /** @@ -63,23 +59,11 @@ export function useTagList( ] ); - // Let React Aria keep owning keyboard focus movement, but disable its - // range-selection branch for tags. - const selectionManagerWithoutRangeSelection = useMemo( - () => - new Proxy(state.selectionManager, { - get(target, property) { - if (property === extendSelectionMethod) { - return noopExtendSelection; - } - - const value = Reflect.get(target, property, target); - - return typeof value === 'function' ? value.bind(target) : value; - }, - }) as MultipleSelectionManager, - [state.selectionManager] - ); + const rangeSelection = useTagListRangeSelection({ + state, + direction, + keyboardDelegate, + }); const { listProps } = useSelectableList({ keyboardDelegate, @@ -89,13 +73,16 @@ export function useTagList( autoFocus, collection: state.collection, disabledKeys: state.disabledKeys, - selectionManager: selectionManagerWithoutRangeSelection, + selectionManager: state.selectionManager, ref: ref as AriaRefObject, }); // Clear selection when focus leaves the group. const { focusWithinProps } = useFocusWithin({ - onBlurWithin: () => state.selectionManager.clearSelection(), + onBlurWithin: () => { + rangeSelection.clearRangeSelection(); + state.selectionManager.clearSelection(); + }, }); const { onKeyDown: selectableKeyDown, ...selectableListProps } = listProps; @@ -104,30 +91,21 @@ export function useTagList( 'data-collection'?: string; }; - const isArrowKey = (key: string) => - key === 'ArrowDown' || - key === 'ArrowUp' || - key === 'ArrowLeft' || - key === 'ArrowRight'; - const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if ( - event.shiftKey && - (event.metaKey || event.ctrlKey || event.altKey) && - isArrowKey(event.key) - ) { - event.preventDefault(); - - return; - } + if (rangeSelection.onKeyDown(event)) return; selectableKeyDown?.(event); // Keep the page from scrolling while arrow-navigating the tags — the // collection only calls preventDefault when it actually moves focus. - if (isArrowKey(event.key)) { + if ( + event.key === 'ArrowDown' || + event.key === 'ArrowUp' || + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' + ) { event.preventDefault(); } }; diff --git a/packages/primitives/src/behaviors/useTagListItem.ts b/packages/primitives/src/behaviors/useTagListItem.ts index 1bd03083f..aacb310c3 100644 --- a/packages/primitives/src/behaviors/useTagListItem.ts +++ b/packages/primitives/src/behaviors/useTagListItem.ts @@ -16,6 +16,7 @@ import { import type { ListState } from '@react-stately/list'; import intlMessages from '../intl/tag-list-item.json'; +import { addTagListRangeSelection } from '../utils'; /** True if Ctrl (Windows/Linux) or Cmd (macOS) is held during the event. */ export function isCommandModifier(event: { @@ -164,6 +165,14 @@ export function useTagListItem( if (allowsSelection) selectionManager.toggleSelection(key); }; + const extendSelectionFrom = (anchorKey: Key | null) => { + if (!allowsSelection || selectionManager.selectionMode !== 'multiple') { + return false; + } + + return addTagListRangeSelection(state, anchorKey ?? key, key); + }; + const getKeysToRemove = () => { if (!isSelected) { return new Set([key]); @@ -185,10 +194,20 @@ export function useTagListItem( return; } + const anchorKey = selectionManager.isFocused + ? selectionManager.focusedKey + : null; + focusTag(); if (event.pointerType === 'keyboard') return; + if (event.shiftKey) { + extendSelectionFrom(anchorKey); + + return; + } + if (isCommandModifier(event)) { toggleSelection(); } diff --git a/packages/primitives/src/behaviors/useTagListRangeSelection.ts b/packages/primitives/src/behaviors/useTagListRangeSelection.ts new file mode 100644 index 000000000..b26d5af6d --- /dev/null +++ b/packages/primitives/src/behaviors/useTagListRangeSelection.ts @@ -0,0 +1,178 @@ +import { useRef } from 'react'; +import type { KeyboardEvent } from 'react'; + +import type { FocusStrategy, Key } from '@koobiq/react-core'; +import type { ListKeyboardDelegate } from '@react-aria/selection'; +import { isCtrlKeyPressed } from '@react-aria/utils'; +import type { ListState } from '@react-stately/list'; + +import { addTagListRangeSelection } from '../utils'; + +type RangeNavigationTarget = { + key: Key; + childFocus?: FocusStrategy; +}; + +type UseTagListRangeSelectionProps = { + state: ListState; + direction: 'ltr' | 'rtl'; + keyboardDelegate: ListKeyboardDelegate; +}; + +export function useTagListRangeSelection({ + state, + direction, + keyboardDelegate, +}: UseTagListRangeSelectionProps) { + const rangeAnchorRef = useRef(null); + const rangeCurrentRef = useRef(null); + const rangeSelectedKeysRef = useRef | null>(null); + + const clearRangeSelection = () => { + rangeAnchorRef.current = null; + rangeCurrentRef.current = null; + rangeSelectedKeysRef.current = null; + }; + + const resetStaleRangeAnchor = () => { + const { focusedKey, selectedKeys } = state.selectionManager; + + if ( + rangeCurrentRef.current != null && + rangeCurrentRef.current !== focusedKey + ) { + clearRangeSelection(); + + return; + } + + if ( + rangeSelectedKeysRef.current != null && + !areKeySetsEqual(rangeSelectedKeysRef.current, selectedKeys) + ) { + clearRangeSelection(); + } + }; + + const getRangeNavigationTarget = ( + event: KeyboardEvent + ): RangeNavigationTarget | null => { + const { selectionManager } = state; + const { focusedKey } = selectionManager; + + if ( + !event.shiftKey || + selectionManager.selectionMode !== 'multiple' || + focusedKey == null + ) { + return null; + } + + switch (event.key) { + case 'ArrowDown': + return toRangeNavigationTarget( + keyboardDelegate.getKeyBelow?.(focusedKey) + ); + case 'ArrowUp': + return toRangeNavigationTarget( + keyboardDelegate.getKeyAbove?.(focusedKey) + ); + case 'ArrowLeft': + return toRangeNavigationTarget( + keyboardDelegate.getKeyLeftOf?.(focusedKey), + direction === 'rtl' ? 'first' : 'last' + ); + case 'ArrowRight': + return toRangeNavigationTarget( + keyboardDelegate.getKeyRightOf?.(focusedKey), + direction === 'rtl' ? 'last' : 'first' + ); + case 'PageDown': + return toRangeNavigationTarget( + keyboardDelegate.getKeyPageBelow?.(focusedKey) + ); + case 'PageUp': + return toRangeNavigationTarget( + keyboardDelegate.getKeyPageAbove?.(focusedKey) + ); + case 'Home': + return isCtrlKeyPressed(event) + ? toRangeNavigationTarget(keyboardDelegate.getFirstKey?.()) + : null; + case 'End': + return isCtrlKeyPressed(event) + ? toRangeNavigationTarget(keyboardDelegate.getLastKey?.()) + : null; + default: + return null; + } + }; + + const extendRangeSelection = (anchorKey: Key | null, currentKey: Key) => { + if (anchorKey == null) return false; + + const previousRange = + rangeAnchorRef.current != null && rangeCurrentRef.current != null + ? { + anchorKey: rangeAnchorRef.current, + currentKey: rangeCurrentRef.current, + } + : undefined; + + const selectedKeys = addTagListRangeSelection( + state, + anchorKey, + currentKey, + previousRange + ); + + if (selectedKeys) { + rangeAnchorRef.current = anchorKey; + rangeCurrentRef.current = currentKey; + rangeSelectedKeysRef.current = selectedKeys; + } + + return Boolean(selectedKeys); + }; + + const onKeyDown = (event: KeyboardEvent) => { + resetStaleRangeAnchor(); + + const rangeTarget = getRangeNavigationTarget(event); + + if (!rangeTarget) return false; + + const anchorKey = + rangeAnchorRef.current ?? state.selectionManager.focusedKey; + + event.preventDefault(); + + state.selectionManager.setFocusedKey( + rangeTarget.key, + rangeTarget.childFocus + ); + + extendRangeSelection(anchorKey, rangeTarget.key); + + return true; + }; + + return { clearRangeSelection, onKeyDown }; +} + +function toRangeNavigationTarget( + key: Key | null | undefined, + childFocus?: FocusStrategy +): RangeNavigationTarget | null { + return key != null ? { key, childFocus } : null; +} + +function areKeySetsEqual(a: Set, b: Set) { + if (a.size !== b.size) return false; + + for (const key of a) { + if (!b.has(key)) return false; + } + + return true; +} diff --git a/packages/primitives/src/utils/index.tsx b/packages/primitives/src/utils/index.ts similarity index 98% rename from packages/primitives/src/utils/index.tsx rename to packages/primitives/src/utils/index.ts index e2be24005..ef2a88f69 100644 --- a/packages/primitives/src/utils/index.tsx +++ b/packages/primitives/src/utils/index.ts @@ -57,3 +57,5 @@ export function removeDataAttributes(props: T): T { return filteredProps; } + +export * from './tagListSelection'; diff --git a/packages/primitives/src/utils/tagListSelection.ts b/packages/primitives/src/utils/tagListSelection.ts new file mode 100644 index 000000000..24f2b5cec --- /dev/null +++ b/packages/primitives/src/utils/tagListSelection.ts @@ -0,0 +1,79 @@ +import type { Key } from '@koobiq/react-core'; +import type { ListState } from '@react-stately/list'; + +function getForwardKeyRange( + state: ListState, + fromKey: Key, + toKey: Key +) { + const keys: Key[] = []; + let key: Key | null = fromKey; + + while (key != null) { + keys.push(key); + + if (key === toKey) { + return keys; + } + + key = state.collection.getKeyAfter(key); + } + + return null; +} + +export function getTagListKeyRange( + state: ListState, + fromKey: Key, + toKey: Key +) { + if (!state.collection.getItem(fromKey) || !state.collection.getItem(toKey)) { + return []; + } + + return ( + getForwardKeyRange(state, fromKey, toKey) ?? + getForwardKeyRange(state, toKey, fromKey) ?? + [] + ); +} + +export function addTagListRangeSelection( + state: ListState, + anchorKey: Key, + currentKey: Key, + previousRange?: { anchorKey: Key; currentKey: Key } +) { + const { selectionManager } = state; + + if (selectionManager.selectionMode !== 'multiple') { + return null; + } + + const selectedKeys = new Set(selectionManager.selectedKeys); + const rangeKeys = getTagListKeyRange(state, anchorKey, currentKey); + + if (!rangeKeys.length) { + return null; + } + + if (previousRange) { + for (const key of getTagListKeyRange( + state, + previousRange.anchorKey, + previousRange.currentKey + )) { + selectedKeys.delete(key); + } + } + + for (const key of rangeKeys) { + if (selectionManager.canSelectItem(key)) { + selectedKeys.add(key); + } + } + + selectionManager.setSelectedKeys(selectedKeys); + + return selectedKeys; +}