Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions packages/components/src/components/TagList/TagList.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + click, <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>A</kbd>, or press <kbd>Space</kbd> while a tag is focused.
To select tags, use <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + click, <kbd>Shift</kbd> + click, <kbd>Shift</kbd> + arrow keys, <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>A</kbd>, or press <kbd>Space</kbd> while a tag is focused.

<Story of={Stories.ModifierSelection} />

Expand Down Expand Up @@ -102,16 +102,19 @@ or automatically derived from the values passed to the `items` prop.

## Keyboard

| Key | Behavior |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| <kbd>→</kbd> / <kbd>←</kbd> | Move focus to the next / previous tag (respects RTL direction). |
| <kbd>Home</kbd> / <kbd>End</kbd> | Move focus to the first / last tag. |
| <kbd>Space</kbd> | Toggle selection on the focused tag. |
| <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + click | Toggle selection without clearing the previous one. |
| <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>A</kbd> | Select all tags. |
| <kbd>Escape</kbd> | Clear selection. Override with `escapeKeyBehavior="none"`. |
| <kbd>Backspace</kbd> / <kbd>Delete</kbd> | Remove the focused tag, or every selected tag if the focused tag is part of the selection. Requires `onRemove`. |
| <kbd>Tab</kbd> | Move focus into / out of the group as a single tab stop. |
| Key | Behavior |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| <kbd>→</kbd> / <kbd>←</kbd> | Move focus to the next / previous tag (respects RTL direction). |
| <kbd>Home</kbd> / <kbd>End</kbd> | Move focus to the first / last tag. |
| <kbd>Shift</kbd> + <kbd>→</kbd> / <kbd>←</kbd> | Move focus and extend the selected range to the next / previous tag. |
| <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Home</kbd> / <kbd>End</kbd> | Move focus and extend the selected range to the first / last tag. |
| <kbd>Space</kbd> | Toggle selection on the focused tag. |
| <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + click | Toggle selection without clearing the previous one. |
| <kbd>Shift</kbd> + click | Add the range from the focused tag to the clicked tag to the selection. |
| <kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>A</kbd> | Select all tags. |
| <kbd>Escape</kbd> | Clear selection. Override with `escapeKeyBehavior="none"`. |
| <kbd>Backspace</kbd> / <kbd>Delete</kbd> | Remove the focused tag, or every selected tag if the focused tag is part of the selection. Requires `onRemove`. |
| <kbd>Tab</kbd> | Move focus into / out of the group as a single tab stop. |

## CSS Variables

Expand Down
97 changes: 83 additions & 14 deletions packages/components/src/components/TagList/TagList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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' })}
<button data-testid="outside">outside</button>
</>
);

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 () => {
Expand Down
58 changes: 18 additions & 40 deletions packages/primitives/src/behaviors/useTagList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -63,23 +59,11 @@ export function useTagList<T extends object>(
]
);

// 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,
Expand All @@ -89,13 +73,16 @@ export function useTagList<T extends object>(
autoFocus,
collection: state.collection,
disabledKeys: state.disabledKeys,
selectionManager: selectionManagerWithoutRangeSelection,
selectionManager: state.selectionManager,
ref: ref as AriaRefObject<HTMLElement | null>,
});

// 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;
Expand All @@ -104,30 +91,21 @@ export function useTagList<T extends object>(
'data-collection'?: string;
};

const isArrowKey = (key: string) =>
key === 'ArrowDown' ||
key === 'ArrowUp' ||
key === 'ArrowLeft' ||
key === 'ArrowRight';

const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
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();
}
};
Expand Down
19 changes: 19 additions & 0 deletions packages/primitives/src/behaviors/useTagListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -164,6 +165,14 @@ export function useTagListItem<T extends object>(
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]);
Expand All @@ -185,10 +194,20 @@ export function useTagListItem<T extends object>(
return;
}

const anchorKey = selectionManager.isFocused
? selectionManager.focusedKey
: null;

focusTag();

if (event.pointerType === 'keyboard') return;

if (event.shiftKey) {
extendSelectionFrom(anchorKey);

return;
}

if (isCommandModifier(event)) {
toggleSelection();
}
Expand Down
Loading
Loading