Skip to content

Commit

Permalink
feat(collection): Add removable support (#3036)
Browse files Browse the repository at this point in the history
Fixes: #3025

Adds `remove` event to the `ListModel`. An `onRemove` config should be added to dynamic lists to remove the item from the collection.

The `MultiSelect.Input` uses this new remove event to handle removing items from the Selected pill list when the user uses the “Delete” key. Focus is managed by the collection system when an item is removed.

[category:Components]
  • Loading branch information
NicholasBoll authored Nov 6, 2024
1 parent b0122ce commit cbfbb06
Show file tree
Hide file tree
Showing 21 changed files with 372 additions and 141 deletions.
19 changes: 12 additions & 7 deletions cypress/component/Tabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('Tabs', () => {

context('when the tab key is pressed', () => {
beforeEach(() => {
cy.tab();
cy.realPress('Tab');
});

it('should move focus to the tabpanel', () => {
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('Tabs', () => {

context('when the tab key is pressed', () => {
beforeEach(() => {
cy.tab();
cy.realPress('Tab');
});

it('should focus on the tab panel of the first tab', () => {
Expand All @@ -158,7 +158,9 @@ describe('Tabs', () => {
// verify the original intent is no longer a tab stop
context('when shift + tab keys are pressed', () => {
beforeEach(() => {
cy.tab({shift: true});
// wait for tabindex to reset
cy.findByRole('tab', {name: 'First Tab'}).should('not.have.attr', 'tabindex', '-1');
cy.realPress(['Shift', 'Tab']);
});

it('should not have tabindex=-1 on the first tab', () => {
Expand Down Expand Up @@ -248,7 +250,7 @@ describe('Tabs', () => {

context('when the first tab is active and focused', () => {
beforeEach(() => {
cy.findByRole('tab', {name: 'First Tab'}).click().focus();
cy.findByRole('tab', {name: 'First Tab'}).click();
});

context('when the right arrow key is pressed', () => {
Expand Down Expand Up @@ -416,7 +418,7 @@ describe('Tabs', () => {

context('when the tab key is pressed', () => {
beforeEach(() => {
cy.tab();
cy.realPress('Tab');
});

it('should move focus to the tabpanel', () => {
Expand Down Expand Up @@ -547,12 +549,15 @@ describe('Tabs', () => {

context('when the "First Tab" is focused', () => {
beforeEach(() => {
cy.findByRole('tab', {name: 'First Tab'}).focus().tab();
cy.findByRole('tab', {name: 'First Tab'}).focus();
});

context('when the Tab key is pressed', () => {
beforeEach(() => {
cy.realPress('Tab');
});

it('should focus on the "More" button', () => {
cy.findByRole('button', {name: 'More'}).focus();
cy.findByRole('button', {name: 'More'}).should('have.focus');
});
});
Expand Down
113 changes: 31 additions & 82 deletions modules/preview-react/multi-select/lib/MultiSelectInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,11 @@ import {
import {createStencil, CSProps, handleCsProp} from '@workday/canvas-kit-styling';
import {InputGroup, TextInput} from '@workday/canvas-kit-react/text-input';
import {SystemIcon} from '@workday/canvas-kit-react/icon';
import {
ListBox,
useListItemRegister,
useListItemRovingFocus,
useListModel,
} from '@workday/canvas-kit-react/collection';
import {useComboboxInput, useComboboxInputConstrained} from '@workday/canvas-kit-react/combobox';
import {Pill} from '@workday/canvas-kit-preview-react/pill';

import {useMultiSelectModel} from './useMultiSelectModel';
import {MultiSelectedItemProps} from './MultiSelectedItem';
import {MultiSelectedList} from './MultiSelectedList';

export const multiSelectStencil = createStencil({
base: {
Expand Down Expand Up @@ -121,61 +116,28 @@ export const useMultiSelectInput = composeHooks(
useComboboxInput
);

const removeItem = <T extends unknown>(id: string, model: ReturnType<typeof useListModel>) => {
const index = model.state.items.findIndex(item => item.id === model.state.cursorId);
const nextIndex = index === model.state.items.length - 1 ? index - 1 : index + 1;
const nextId = model.state.items[nextIndex].id;
if (model.state.cursorId === id) {
// We're removing the currently focused item. Focus next item
model.events.goTo({id: nextId});
}
};

const useMultiSelectedItem = composeHooks(
createElemPropsHook(useListModel)((model, ref, elemProps) => {
return {
onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
const id = event.currentTarget.dataset.id || '';
if (event.key === 'Backspace' || event.key === 'Delete') {
model.events.select({id});
removeItem(id, model);
}
},
onClick(event: React.MouseEvent<HTMLElement>) {
const id = event.currentTarget.dataset.id || '';
model.events.select({id});
},
};
}),
useListItemRovingFocus,
useListItemRegister
);

const MultiSelectedItem = createSubcomponent('span')({
modelHook: useListModel,
elemPropsHook: useMultiSelectedItem,
})(({children, ref, ...elemProps}, Element) => {
return (
<Pill as={Element} variant="removable">
{children}
<Pill.IconButton ref={ref} {...(elemProps as any)} />
</Pill>
);
});

export interface MultiSelectInputProps
extends CSProps,
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
'disabled' | 'className' | 'style' | 'aria-labelledby'
> {}
>,
Pick<MultiSelectedItemProps, 'removeLabel'> {}

export const MultiSelectInput = createSubcomponent(TextInput)({
modelHook: useMultiSelectModel,
elemPropsHook: useMultiSelectInput,
})<MultiSelectInputProps>(
(
{className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ...elemProps},
{
className,
cs,
style,
'aria-labelledby': ariaLabelledBy,
removeLabel,
formInputProps,
...elemProps
},
Element,
model
) => {
Expand All @@ -194,20 +156,7 @@ export const MultiSelectInput = createSubcomponent(TextInput)({
<SystemIcon icon={caretDownSmallIcon} />
</InputGroup.InnerEnd>
</InputGroup>
{model.selected.state.items.length ? (
<>
<div data-part="separator" />
<ListBox
model={model.selected}
as="div"
role="listbox"
aria-orientation="horizontal"
aria-labelledby={ariaLabelledBy}
>
{item => <MultiSelectedItem>{item.textValue}</MultiSelectedItem>}
</ListBox>
</>
) : null}
<MultiSelectedList removeLabel={removeLabel} />
</div>
);
}
Expand All @@ -218,7 +167,16 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({
elemPropsHook: useMultiSelectInput,
})<MultiSelectInputProps>(
(
{className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ref, ...elemProps},
{
className,
cs,
style,
'aria-labelledby': ariaLabelledBy,
removeLabel,
formInputProps,
ref,
...elemProps
},
Element,
model
) => {
Expand All @@ -228,34 +186,25 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({
<InputGroup.InnerStart pointerEvents="none" width={system.space.x8}>
<SystemIcon icon={searchIcon} size={system.space.x4} />
</InputGroup.InnerStart>
<InputGroup.Input data-part="form-input" {...formInputProps} />
<InputGroup.Input
data-part="form-input"
placeholder={null as unknown as string} // do not use a placeholder for this input
{...formInputProps}
/>
<InputGroup.Input
data-part="user-input"
as={Element}
aria-labelledby={ariaLabelledBy}
{...elemProps}
/>
<InputGroup.InnerEnd>
<InputGroup.InnerEnd width={system.space.x4}>
<InputGroup.ClearButton />
</InputGroup.InnerEnd>
<InputGroup.InnerEnd pointerEvents="none">
<SystemIcon icon={caretDownSmallIcon} />
</InputGroup.InnerEnd>
</InputGroup>
{model.selected.state.items.length ? (
<>
<div data-part="separator" />
<ListBox
model={model.selected}
as="div"
role="listbox"
aria-orientation="horizontal"
aria-labelledby={ariaLabelledBy}
>
{item => <MultiSelectedItem>{item.textValue}</MultiSelectedItem>}
</ListBox>
</>
) : null}
<MultiSelectedList removeLabel={removeLabel} />
</div>
);
}
Expand Down
46 changes: 46 additions & 0 deletions modules/preview-react/multi-select/lib/MultiSelectedItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';

import {
composeHooks,
createElemPropsHook,
createSubModelElemPropsHook,
createSubcomponent,
} from '@workday/canvas-kit-react/common';
import {useListItemRegister, useListItemRovingFocus} from '@workday/canvas-kit-react/collection';
import {Pill} from '@workday/canvas-kit-preview-react/pill';

import {useMultiSelectItemRemove} from './useMultiSelectItemRemove';
import {useMultiSelectModel} from './useMultiSelectModel';

export interface MultiSelectedItemProps {
/**
* Remove label on a MultiSelectedItem. In English, the label may be "Remove" and the screen
* reader will read out "Remove {option}".
*
* @default "remove"
*/
removeLabel?: string;
}

export const useMultiSelectedItem = composeHooks(
createElemPropsHook(useMultiSelectModel)(model => {
return {
'aria-selected': true,
};
}),
useMultiSelectItemRemove,
createSubModelElemPropsHook(useMultiSelectModel)(m => m.selected, useListItemRovingFocus),
createSubModelElemPropsHook(useMultiSelectModel)(m => m.selected, useListItemRegister)
);

export const MultiSelectedItem = createSubcomponent('span')({
modelHook: useMultiSelectModel,
elemPropsHook: useMultiSelectedItem,
})<MultiSelectedItemProps>(({children, removeLabel, ref, ...elemProps}, Element) => {
return (
<Pill as={Element} variant="removable">
{children}
<Pill.IconButton aria-label={removeLabel} ref={ref} {...(elemProps as any)} />
</Pill>
);
});
30 changes: 30 additions & 0 deletions modules/preview-react/multi-select/lib/MultiSelectedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import {createSubcomponent} from '@workday/canvas-kit-react/common';
import {ListBox} from '@workday/canvas-kit-react/collection';

import {useMultiSelectModel} from './useMultiSelectModel';
import {MultiSelectedItem, MultiSelectedItemProps} from './MultiSelectedItem';

export interface MultiSelectedListProps
extends MultiSelectedItemProps,
React.HTMLAttributes<HTMLDivElement> {}

export const MultiSelectedList = createSubcomponent()({
modelHook: useMultiSelectModel,
})<MultiSelectedListProps>(({'aria-labelledby': ariaLabelledBy, removeLabel}, Element, model) => {
return model.selected.state.items.length ? (
<>
<div data-part="separator" />
<ListBox
model={model.selected}
as="div"
role="listbox"
aria-orientation="horizontal"
aria-labelledby={ariaLabelledBy}
>
{item => <MultiSelectedItem removeLabel={removeLabel}>{item.textValue}</MultiSelectedItem>}
</ListBox>
</>
) : null;
});
48 changes: 48 additions & 0 deletions modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import {createElemPropsHook} from '@workday/canvas-kit-react/common';

import {useMultiSelectModel} from './useMultiSelectModel';
import {focusOnCurrentCursor, listItemRemove} from '@workday/canvas-kit-react/collection';

/**
* This elemProps hook is used when a menu item is expected to be removed. It will advance the cursor to
* another item.
* This elemProps hook is used for cursor navigation by using [Roving
* Tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex). Only a single item in the
* collection has a tab stop. Pressing an arrow key moves the tab stop to a different item in the
* corresponding direction. See the [Roving Tabindex](#roving-tabindex) example. This elemProps hook
* should be applied to an `*.Item` component.
*
* ```ts
* const useMyItem = composeHooks(
* useListItemRovingFocus, // adds the roving tabindex support
* useListItemRegister
* );
* ```
*/
export const useMultiSelectItemRemove = createElemPropsHook(useMultiSelectModel)((model, _ref) => {
return {
onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === 'Backspace' || event.key === 'Delete') {
const id = event.currentTarget.dataset.id || '';
const nextId = listItemRemove(id, model.selected);
model.selected.events.remove({id, event});
if (nextId) {
focusOnCurrentCursor(model.selected, nextId, event.currentTarget);
} else {
model.state.inputRef.current?.focus();
}
}
},
onClick(event: React.MouseEvent<HTMLElement>) {
const id = event.currentTarget.dataset.id || '';
const nextId = listItemRemove(id, model.selected);
model.selected.events.remove({id, nextId, event});
if (nextId) {
focusOnCurrentCursor(model.selected, nextId, event.currentTarget);
} else {
model.state.inputRef.current?.focus();
}
},
};
});
Loading

0 comments on commit cbfbb06

Please sign in to comment.