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;
+}