From cbfbb06dd0be8cd5feca6ee2114cd2b039da66db Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Wed, 6 Nov 2024 12:00:02 -0700 Subject: [PATCH] feat(collection): Add removable support (#3036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- cypress/component/Tabs.spec.tsx | 19 +-- .../multi-select/lib/MultiSelectInput.tsx | 113 +++++------------- .../multi-select/lib/MultiSelectedItem.tsx | 46 +++++++ .../multi-select/lib/MultiSelectedList.tsx | 30 +++++ .../lib/useMultiSelectItemRemove.ts | 48 ++++++++ .../multi-select/lib/useMultiSelectModel.ts | 25 +++- .../multi-select/stories/examples/Basic.tsx | 8 +- .../multi-select/stories/examples/Complex.tsx | 1 + .../stories/examples/Controlled.tsx | 1 + .../multi-select/stories/examples/Icons.tsx | 6 +- .../stories/examples/Searching.tsx | 1 + modules/react/collection/index.ts | 3 + .../collection/lib/focusOnCurrentCursor.ts | 55 +++++++++ .../react/collection/lib/listItemRemove.ts | 21 ++++ .../lib/useListItemRemoveOnDeleteKey.tsx | 40 +++++++ .../collection/lib/useListItemRovingFocus.tsx | 42 ++----- modules/react/collection/lib/useListLoader.ts | 2 + .../lib/useListResetCursorOnBlur.tsx | 18 ++- .../collection/lib/useSelectionListModel.tsx | 10 ++ .../combobox/lib/hooks/useComboboxInput.ts | 17 ++- .../select/stories/examples/Controlled.tsx | 7 +- 21 files changed, 372 insertions(+), 141 deletions(-) create mode 100644 modules/preview-react/multi-select/lib/MultiSelectedItem.tsx create mode 100644 modules/preview-react/multi-select/lib/MultiSelectedList.tsx create mode 100644 modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts create mode 100644 modules/react/collection/lib/focusOnCurrentCursor.ts create mode 100644 modules/react/collection/lib/listItemRemove.ts create mode 100644 modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx diff --git a/cypress/component/Tabs.spec.tsx b/cypress/component/Tabs.spec.tsx index 027a23761d..1a83048366 100644 --- a/cypress/component/Tabs.spec.tsx +++ b/cypress/component/Tabs.spec.tsx @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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'); }); }); diff --git a/modules/preview-react/multi-select/lib/MultiSelectInput.tsx b/modules/preview-react/multi-select/lib/MultiSelectInput.tsx index 03eca34017..f3ca49d0e4 100644 --- a/modules/preview-react/multi-select/lib/MultiSelectInput.tsx +++ b/modules/preview-react/multi-select/lib/MultiSelectInput.tsx @@ -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: { @@ -121,61 +116,28 @@ export const useMultiSelectInput = composeHooks( useComboboxInput ); -const removeItem = (id: string, model: ReturnType) => { - 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) { - const id = event.currentTarget.dataset.id || ''; - if (event.key === 'Backspace' || event.key === 'Delete') { - model.events.select({id}); - removeItem(id, model); - } - }, - onClick(event: React.MouseEvent) { - 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 ( - - {children} - - - ); -}); - export interface MultiSelectInputProps extends CSProps, Pick< React.InputHTMLAttributes, 'disabled' | 'className' | 'style' | 'aria-labelledby' - > {} + >, + Pick {} export const MultiSelectInput = createSubcomponent(TextInput)({ modelHook: useMultiSelectModel, elemPropsHook: useMultiSelectInput, })( ( - {className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ...elemProps}, + { + className, + cs, + style, + 'aria-labelledby': ariaLabelledBy, + removeLabel, + formInputProps, + ...elemProps + }, Element, model ) => { @@ -194,20 +156,7 @@ export const MultiSelectInput = createSubcomponent(TextInput)({ - {model.selected.state.items.length ? ( - <> -
- - {item => {item.textValue}} - - - ) : null} +
); } @@ -218,7 +167,16 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({ elemPropsHook: useMultiSelectInput, })( ( - {className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ref, ...elemProps}, + { + className, + cs, + style, + 'aria-labelledby': ariaLabelledBy, + removeLabel, + formInputProps, + ref, + ...elemProps + }, Element, model ) => { @@ -228,34 +186,25 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({ - + - + - {model.selected.state.items.length ? ( - <> -
- - {item => {item.textValue}} - - - ) : null} +
); } diff --git a/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx b/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx new file mode 100644 index 0000000000..9e23587392 --- /dev/null +++ b/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx @@ -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, +})(({children, removeLabel, ref, ...elemProps}, Element) => { + return ( + + {children} + + + ); +}); diff --git a/modules/preview-react/multi-select/lib/MultiSelectedList.tsx b/modules/preview-react/multi-select/lib/MultiSelectedList.tsx new file mode 100644 index 0000000000..671a9092d8 --- /dev/null +++ b/modules/preview-react/multi-select/lib/MultiSelectedList.tsx @@ -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 {} + +export const MultiSelectedList = createSubcomponent()({ + modelHook: useMultiSelectModel, +})(({'aria-labelledby': ariaLabelledBy, removeLabel}, Element, model) => { + return model.selected.state.items.length ? ( + <> +
+ + {item => {item.textValue}} + + + ) : null; +}); diff --git a/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts b/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts new file mode 100644 index 0000000000..1c183d6224 --- /dev/null +++ b/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts @@ -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) { + 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) { + 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(); + } + }, + }; +}); diff --git a/modules/preview-react/multi-select/lib/useMultiSelectModel.ts b/modules/preview-react/multi-select/lib/useMultiSelectModel.ts index f96377619f..dc4a369028 100644 --- a/modules/preview-react/multi-select/lib/useMultiSelectModel.ts +++ b/modules/preview-react/multi-select/lib/useMultiSelectModel.ts @@ -34,6 +34,10 @@ export const useMultiSelectModel = createModelHook({ useComboboxModel.mergeConfig(config, { onHide() { setSelectedItems(cachedSelected); + model.events.goTo({id: ''}); + }, + onFilterChange() { + model.events.goTo({id: ''}); }, }) ); @@ -60,7 +64,7 @@ export const useMultiSelectModel = createModelHook({ // The `listbox` of pills under the MultiSelect combobox input. const selected = useListModel({ orientation: 'horizontal', - onSelect({id}) { + onRemove({id}) { model.events.select({id}); }, shouldVirtualize: false, @@ -75,5 +79,22 @@ export const useMultiSelectModel = createModelHook({ ...model.events, }; - return {selected, ...model, state, events}; + return { + selected: { + ...selected, + state: { + ...selected.state, + cursorId: React.useMemo( + () => + selected.state.items.find(item => item.id === selected.state.cursorId) + ? selected.state.cursorId + : selected.state.items[0]?.id || '', + [selected.state.items, selected.state.cursorId] + ), + }, + }, + ...model, + state, + events, + }; }); diff --git a/modules/preview-react/multi-select/stories/examples/Basic.tsx b/modules/preview-react/multi-select/stories/examples/Basic.tsx index 01267e597b..1fe783607f 100644 --- a/modules/preview-react/multi-select/stories/examples/Basic.tsx +++ b/modules/preview-react/multi-select/stories/examples/Basic.tsx @@ -8,10 +8,14 @@ const items = ['Cheese', 'Olives', 'Onions', 'Pepperoni', 'Peppers']; export const Basic = () => { return ( <> - + Toppings - + diff --git a/modules/preview-react/multi-select/stories/examples/Complex.tsx b/modules/preview-react/multi-select/stories/examples/Complex.tsx index a247532c8b..04d34f3844 100644 --- a/modules/preview-react/multi-select/stories/examples/Complex.tsx +++ b/modules/preview-react/multi-select/stories/examples/Complex.tsx @@ -39,6 +39,7 @@ export const Complex = () => { { const value = e.currentTarget.value; diff --git a/modules/preview-react/multi-select/stories/examples/Controlled.tsx b/modules/preview-react/multi-select/stories/examples/Controlled.tsx index ae03e4886f..5aa4bfdbe6 100644 --- a/modules/preview-react/multi-select/stories/examples/Controlled.tsx +++ b/modules/preview-react/multi-select/stories/examples/Controlled.tsx @@ -46,6 +46,7 @@ export const Controlled = () => { { Controls - + diff --git a/modules/preview-react/multi-select/stories/examples/Searching.tsx b/modules/preview-react/multi-select/stories/examples/Searching.tsx index 7d08f00652..8034ad1362 100644 --- a/modules/preview-react/multi-select/stories/examples/Searching.tsx +++ b/modules/preview-react/multi-select/stories/examples/Searching.tsx @@ -88,6 +88,7 @@ export const Searching = () => { { setValue(e.currentTarget.value); diff --git a/modules/react/collection/index.ts b/modules/react/collection/index.ts index 77681d1026..f390fcb936 100644 --- a/modules/react/collection/index.ts +++ b/modules/react/collection/index.ts @@ -15,6 +15,9 @@ export * from './lib/useGridModel'; export * from './lib/useListActiveDescendant'; export * from './lib/useListItemActiveDescendant'; export * from './lib/useListItemAllowChildStrings'; +export * from './lib/useListItemRemoveOnDeleteKey'; +export * from './lib/focusOnCurrentCursor'; +export * from './lib/listItemRemove'; export {ListBox, ListBoxProps} from './lib/ListBox'; export {keyboardEventToCursorEvents} from './lib/keyUtils'; export { diff --git a/modules/react/collection/lib/focusOnCurrentCursor.ts b/modules/react/collection/lib/focusOnCurrentCursor.ts new file mode 100644 index 0000000000..f6b51abae4 --- /dev/null +++ b/modules/react/collection/lib/focusOnCurrentCursor.ts @@ -0,0 +1,55 @@ +import {useCursorListModel} from './useCursorListModel'; + +// retry a function each frame so we don't rely on the timing mechanism of React's render cycle. +const retryEachFrame = (cb: () => boolean, iterations: number, reject?: (reason?: any) => void) => { + if (cb() === false && iterations > 1) { + requestAnimationFrame(() => retryEachFrame(cb, iterations - 1)); + } + reject?.('Retry timeout'); +}; + +export const focusOnCurrentCursor = ( + model: ReturnType, + nextId: string, + /** + * This can be any element in the list. It is used only to get the client-id from the element in + * case it is different than the server ID when DOM is hydrated. + */ + element?: HTMLElement +) => { + return new Promise((resolve, reject) => { + // Attempt to extract the ID from the DOM element. This fixes issues where the server and client + // do not agree on a generated ID + const clientId = (element?.dataset?.focusId || '').split('-')[0] || model.state.id; + + const item = model.navigation.getItem(nextId, model); + + if (item) { + // If the list is virtualized, we need to manually call out to the virtual list's + // `scrollToIndex` + if (model.state.isVirtualized) { + model.state.UNSTABLE_virtual.scrollToIndex(item.index); + } + + const getElement = (id?: string) => { + return document.querySelector(`[data-focus-id="${`${id}-${item.id}`}"]`); + }; + + // In React concurrent mode, there could be several render attempts before the element we're + // looking for could be available in the DOM + retryEachFrame( + () => { + const element = getElement(clientId) || getElement(model.state.id); + + if (element) { + element.focus(); + resolve(element); + } + return !!element; + }, + 5, + reject + ); // 5 should be enough, right?! + } + }); +}; diff --git a/modules/react/collection/lib/listItemRemove.ts b/modules/react/collection/lib/listItemRemove.ts new file mode 100644 index 0000000000..dac25170cf --- /dev/null +++ b/modules/react/collection/lib/listItemRemove.ts @@ -0,0 +1,21 @@ +import {useSelectionListModel} from './useSelectionListModel'; + +export const listItemRemove = ( + id: string, + model: ReturnType +): string | undefined => { + // bail early if an ID isn't available + if (!id) { + return; + } + + 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 (nextId && model.state.cursorId === id) { + // We're removing the currently focused item. Focus next item + model.events.goTo({id: nextId}); + } + + return nextId; +}; diff --git a/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx b/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx new file mode 100644 index 0000000000..1671ac23ed --- /dev/null +++ b/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import {createElemPropsHook} from '@workday/canvas-kit-react/common'; + +import {useSelectionListModel} from './useSelectionListModel'; +import {focusOnCurrentCursor} from './focusOnCurrentCursor'; +import {listItemRemove} from './listItemRemove'; + +/** + * 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 useListItemRemoveOnDeleteKey = createElemPropsHook(useSelectionListModel)(model => { + return { + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Backspace' || event.key === 'Delete') { + const id = event.currentTarget.dataset.id || ''; + const nextId = listItemRemove(id, model); + model.events.remove({id, nextId, event}); + if (nextId) { + // use an animation frame to wait for any other model changes that may happen + requestAnimationFrame(() => { + focusOnCurrentCursor(model, nextId, event.currentTarget); + }); + } + } + }, + }; +}); diff --git a/modules/react/collection/lib/useListItemRovingFocus.tsx b/modules/react/collection/lib/useListItemRovingFocus.tsx index c7eebd0695..36894a477e 100644 --- a/modules/react/collection/lib/useListItemRovingFocus.tsx +++ b/modules/react/collection/lib/useListItemRovingFocus.tsx @@ -3,13 +3,7 @@ import {useIsRTL, createElemPropsHook} from '@workday/canvas-kit-react/common'; import {useCursorListModel} from './useCursorListModel'; import {keyboardEventToCursorEvents} from './keyUtils'; - -// retry a function each frame so we don't rely on the timing mechanism of React's render cycle. -const retryEachFrame = (cb: () => boolean, iterations: number) => { - if (cb() === false && iterations > 1) { - requestAnimationFrame(() => retryEachFrame(cb, iterations - 1)); - } -}; +import {focusOnCurrentCursor} from './focusOnCurrentCursor'; /** * This elemProps hook is used for cursor navigation by using [Roving @@ -33,36 +27,16 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)( const stateRef = React.useRef(model.state); stateRef.current = model.state; - const keyElementRef = React.useRef(null); + const keyElementRef = React.useRef(null); const isRTL = useIsRTL(); React.useEffect(() => { + // If the cursor change was triggered by this hook, we should change focus if (keyElementRef.current) { - const item = model.navigation.getItem(model.state.cursorId, model); - if (item) { - if (model.state.isVirtualized) { - model.state.UNSTABLE_virtual.scrollToIndex(item.index); - } - - const selector = (id?: string) => { - return document.querySelector(`[data-focus-id="${`${id}-${item.id}`}"]`); - }; - - // In React concurrent mode, there could be several render attempts before the element we're - // looking for could be available in the DOM - retryEachFrame(() => { - // Attempt to extract the ID from the DOM element. This fixes issues where the server and client - // do not agree on a generated ID - const clientId = keyElementRef.current?.getAttribute('data-focus-id')?.split('-')[0]; - const element = selector(clientId) || selector(model.state.id); - - element?.focus(); - if (element) { - keyElementRef.current = null; - } - return !!element; - }, 5); // 5 should be enough, right?! - } + focusOnCurrentCursor(model, model.state.cursorId, keyElementRef.current).then(() => { + // Reset key element since focus was successful + keyElementRef.current = null; + }); } // we only want to run this effect if the cursor changes and not any other time // eslint-disable-next-line react-hooks/exhaustive-deps @@ -76,7 +50,7 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)( }, [model.state.cursorId, model.state.items, model.events]); return { - onKeyDown(event: React.KeyboardEvent) { + onKeyDown(event: React.KeyboardEvent) { const handled = keyboardEventToCursorEvents(event, model, isRTL); if (handled) { event.preventDefault(); diff --git a/modules/react/collection/lib/useListLoader.ts b/modules/react/collection/lib/useListLoader.ts index a64589f16f..b85d77b52f 100644 --- a/modules/react/collection/lib/useListLoader.ts +++ b/modules/react/collection/lib/useListLoader.ts @@ -269,6 +269,8 @@ export function useListLoader< const model = modelHook( modelHook.mergeConfig(config, { + // Loaders should virtualize by default. If they do not, it is an infinite scroll list + shouldVirtualize: true, items, shouldGoToNext: shouldLoadIndex('getNext', 'goToNext'), shouldGoToPrevious: shouldLoadIndex('getPrevious', 'goToPrevious'), diff --git a/modules/react/collection/lib/useListResetCursorOnBlur.tsx b/modules/react/collection/lib/useListResetCursorOnBlur.tsx index 4de675189d..7b32650e89 100644 --- a/modules/react/collection/lib/useListResetCursorOnBlur.tsx +++ b/modules/react/collection/lib/useListResetCursorOnBlur.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {createElemPropsHook} from '@workday/canvas-kit-react/common'; +import {createElemPropsHook, useMountLayout} from '@workday/canvas-kit-react/common'; import {orientationKeyMap} from './keyUtils'; import {useListModel} from './useListModel'; @@ -20,6 +20,15 @@ import {useListModel} from './useListModel'; */ export const useListResetCursorOnBlur = createElemPropsHook(useListModel)(({state, events}) => { const programmaticFocusRef = React.useRef(false); + const requestAnimationFrameRef = React.useRef(0); + + useMountLayout(() => { + return () => { + // Cancelling the animation frame prevents React unmount errors + cancelAnimationFrame(requestAnimationFrameRef.current); + }; + }); + return { onKeyDown(event: React.KeyboardEvent) { // Programmatic focus only on any focus change via keyboard @@ -32,7 +41,12 @@ export const useListResetCursorOnBlur = createElemPropsHook(useListModel)(({stat }, onBlur() { if (!programmaticFocusRef.current) { - events.goTo({id: state.selectedIds[0]}); + // use an animation frame to wait for any other model changes that may happen on a blur + requestAnimationFrameRef.current = requestAnimationFrame(() => { + if (state.selectedIds[0] !== state.cursorId) { + events.goTo({id: state.selectedIds[0]}); + } + }); } }, }; diff --git a/modules/react/collection/lib/useSelectionListModel.tsx b/modules/react/collection/lib/useSelectionListModel.tsx index 6363860bb9..259e35832a 100644 --- a/modules/react/collection/lib/useSelectionListModel.tsx +++ b/modules/react/collection/lib/useSelectionListModel.tsx @@ -109,6 +109,16 @@ export const useSelectionListModel = createModelHook({ setSelectedIds(ids: 'all' | string[]) { setSelectedIds(ids); }, + /** + * The `remove` event can be called by Behavior Hooks based on user interaction. The `onRemove` + * can be added to the model config to signal the user wishes to remove the item in the list. + * The `remove` event requires the dynamic API where `items` are passed to the model. It is up + * to you to remove the item from the list. Focus redirection should be automatically managed, + * if necessary. + */ + remove(data: {id: string; nextId?: string; event?: Event | React.SyntheticEvent}) { + // nothing to do here. It is a signal event + }, }; return {...cursor, state, events, selection}; diff --git a/modules/react/combobox/lib/hooks/useComboboxInput.ts b/modules/react/combobox/lib/hooks/useComboboxInput.ts index 8fe6b4303d..9702bf21a2 100644 --- a/modules/react/combobox/lib/hooks/useComboboxInput.ts +++ b/modules/react/combobox/lib/hooks/useComboboxInput.ts @@ -26,16 +26,13 @@ export const useComboboxInput = composeHooks( if (model.state.isVirtualized && item) { model.state.UNSTABLE_virtual.scrollToIndex(item.index); } else { - const listboxId = model.state.inputRef.current?.getAttribute('aria-controls'); - if (listboxId) { - const menuItem = document.querySelector( - `[id="${listboxId}"] [data-id="${model.state.cursorId}"]` - ); - if (menuItem) { - requestAnimationFrame(() => { - menuItem.scrollIntoView({block: 'nearest'}); - }); - } + const menuItem = document.querySelector( + `[id="${model.state.id}-list"] [data-id="${model.state.cursorId}"]` + ); + if (menuItem) { + requestAnimationFrame(() => { + menuItem.scrollIntoView({block: 'nearest'}); + }); } } } diff --git a/modules/react/select/stories/examples/Controlled.tsx b/modules/react/select/stories/examples/Controlled.tsx index f170db78d4..c6984ad29f 100644 --- a/modules/react/select/stories/examples/Controlled.tsx +++ b/modules/react/select/stories/examples/Controlled.tsx @@ -36,7 +36,12 @@ export const Controlled = () => { Contact