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