diff --git a/src/__mocks__/set-a11y-status.js b/src/__mocks__/set-a11y-status.js index b4bbacb11..6fbbaa1d2 100644 --- a/src/__mocks__/set-a11y-status.js +++ b/src/__mocks__/set-a11y-status.js @@ -1 +1 @@ -module.exports = jest.fn() +module.exports = {setStatus: jest.fn(), cleanupStatusDiv: jest.fn()} diff --git a/src/__tests__/downshift.lifecycle.js b/src/__tests__/downshift.lifecycle.js index f341ece10..dfd3782ea 100644 --- a/src/__tests__/downshift.lifecycle.js +++ b/src/__tests__/downshift.lifecycle.js @@ -1,7 +1,7 @@ import * as React from 'react' import {act, fireEvent, render, screen} from '@testing-library/react' import Downshift from '../' -import setA11yStatus from '../set-a11y-status' +import {setStatus} from '../set-a11y-status' import * as utils from '../utils' jest.useFakeTimers() @@ -124,7 +124,7 @@ test('handles state change for touchevent events', () => { }) test('props update causes the a11y status to be updated', () => { - setA11yStatus.mockReset() + setStatus.mockReset() const MyComponent = () => ( {({getInputProps, getItemProps, isOpen}) => ( @@ -139,11 +139,11 @@ test('props update causes the a11y status to be updated', () => { const {container, unmount} = render() render(, {container}) jest.runAllTimers() - expect(setA11yStatus).toHaveBeenCalledTimes(1) + expect(setStatus).toHaveBeenCalledTimes(1) render(, {container}) unmount() jest.runAllTimers() - expect(setA11yStatus).toHaveBeenCalledTimes(1) + expect(setStatus).toHaveBeenCalledTimes(1) }) test('inputValue initializes properly if the selectedItem is controlled and set', () => { diff --git a/src/__tests__/set-a11y-status.js b/src/__tests__/set-a11y-status.js index 83072b853..1fb3b013d 100644 --- a/src/__tests__/set-a11y-status.js +++ b/src/__tests__/set-a11y-status.js @@ -80,5 +80,5 @@ test('creates no status div if there is no document', () => { function setup() { jest.resetModules() - return require('../set-a11y-status').default + return require('../set-a11y-status').setStatus } diff --git a/src/downshift.js b/src/downshift.js index 704f892d2..49bdda0f6 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import {Component, cloneElement} from 'react' import {isForwardRef} from 'react-is' import {isPreact, isReactNative, isReactNativeWeb} from './is.macro' -import setA11yStatus from './set-a11y-status' +import {setStatus} from './set-a11y-status' import * as stateChangeTypes from './stateChangeTypes' import { handleRefs, @@ -1057,7 +1057,7 @@ class Downshift extends Component { }) this.previousResultCount = resultCount - setA11yStatus(status, this.props.environment.document) + setStatus(status, this.props.environment.document) }, 200) componentDidMount() { diff --git a/src/hooks/MIGRATION_V9.md b/src/hooks/MIGRATION_V9.md new file mode 100644 index 000000000..66c69a44d --- /dev/null +++ b/src/hooks/MIGRATION_V9.md @@ -0,0 +1,94 @@ +# Migration from v8 to v9 + +Downshift v8 receives a list of breaking changes, which are necessary to improve +both the user and the developer experience. The changes are only affecting the +hooks and are detailed below. + +## Table of Contents + + + + +- [onChange Typescript Improvements](#onchange-typescript-improvements) +- [getA11ySelectionMessage](#geta11yselectionmessage) +- [getA11yRemovalMessage](#geta11yremovalmessage) +- [getA11yStatusMessage](#geta11ystatusmessage) +- [selectedItemChanged](#selecteditemchanged) + + + +## onChange Typescript Improvements + +The handlers below have their types improved to reflect that they will always +get called with their corresponding state prop: + +- useCombobox + + - onSelectedItemChange: selectedItem is non optional + - onIsOpenChange: isOpen is non optional + - onHighlightedIndexChange: highlightedIndex is non optional + +- useSelect + + - onSelectedItemChange: selectedItem is non optional + - onIsOpenChange: isOpen is non optional + - onHighlightedIndexChange: highlightedIndex is non optional + - onInputValueChange: inputValue is non optional + +- useMultipleSelection + - onActiveIndexChange: activeIndex is non optional + - onSelectedItemsChange: selectedItems is non optional + + + +## getA11ySelectionMessage + +The prop has been removed from useSelect and useCombobox. If you still need an +a11y selection message, use either `getA11yStatusMessage` or your own aria-live +implementation inside a `onStateChange` callback. + +## getA11yRemovalMessage + +The prop has been removed from useMultipleSelection. If you still need an a11y +removal message, use either `getA11yStatusMessage` or your own aria-live +implementation inside a `onStateChange` callback. + +## getA11yStatusMessage + +The prop has been also added to useMultipleSelection, but has some changes +reflected in each of the hook's readme. + +- there is no default function provided, so you will not get any aria-live + message anymore if you don't provide the prop directly to the hooks. +- the function is called only with the hook's state, and you should already have + access to the props, such as items or itemToString. Values such as + highlightedItem or resultsCount have been removed, so you need to compute them + yourself if needed. +- `Downshift` is not affected, it has the same `getA11yStatusMessage` as before, + no changes there at all. + +The HTML markup with the ARIA attributes we provide through the getter props +should be enough for screen readers to report: + +- results count. +- highlighted item. +- item selection. +- what actions the user can take. + +If you need anything more specific as part of an aria-live region, please use +the new version of `getA11yStatusMessage` or your own aria-live implementation. + +References: + +- [useCombobox docs](https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#geta11ystatusmessage) +- [useSelect docs](https://github.com/downshift-js/downshift/blob/master/src/hooks/useSelect/README.md#geta11ystatusmessage) +- [useMultipleSelection docs](https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/README.md#geta11ystatusmessage) + +## selectedItemChanged + +This prop has been removed from `useCombobox`. You should use `itemToKey` +instead. + +Reference: + +[itemToKey docs](https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#itemtokey) diff --git a/src/hooks/testUtils.js b/src/hooks/testUtils.js index 42c378079..6aa10f9fe 100644 --- a/src/hooks/testUtils.js +++ b/src/hooks/testUtils.js @@ -48,8 +48,8 @@ export const defaultIds = { inputId: 'downshift-test-id-input', } -export const waitForDebouncedA11yStatusUpdate = () => - act(() => jest.advanceTimersByTime(200)) +export const waitForDebouncedA11yStatusUpdate = (shouldBeCleared = false) => + act(() => jest.advanceTimersByTime(shouldBeCleared ? 700 : 200)) export const MemoizedItem = React.memo(function Item({ index, diff --git a/src/hooks/useCombobox/README.md b/src/hooks/useCombobox/README.md index 299e6a595..91d033a36 100644 --- a/src/hooks/useCombobox/README.md +++ b/src/hooks/useCombobox/README.md @@ -53,12 +53,14 @@ follows: - inline autocomplete based on the highlighted item in the menu is also performed by the consumer. -## Migration to v7 +## Migration through breaking changes -`useCombobox` received some changes related to how it works in version 7, as a -conequence of adapting it to the ARIA 1.2 combobox pattern. If you were using -_useCombobox_ previous to 7.0.0, check the [migration guide][migration-guide-v7] -and update if necessary. +The hook received breaking changes related to how it works, as well as the API, +starting with v7. They are documented here: + +- [v7 migration guide][migration-guide-v7] +- [v8 migration guide][migration-guide-v8] +- [v9 migration guide][migration-guide-v9] ## Table of Contents @@ -82,9 +84,7 @@ and update if necessary. - [defaultHighlightedIndex](#defaulthighlightedindex) - [defaultInputValue](#defaultinputvalue) - [itemToKey](#itemtokey) - - [selectedItemChanged](#selecteditemchanged) - [getA11yStatusMessage](#geta11ystatusmessage) - - [getA11ySelectionMessage](#geta11yselectionmessage) - [onHighlightedIndexChange](#onhighlightedindexchange) - [onIsOpenChange](#onisopenchange) - [onInputValueChange](#oninputvaluechange) @@ -412,8 +412,8 @@ function itemToKey(item) { ``` > This deprecates the "selectedItemChanged" prop. If you are using the prop -> already, make sure you change to "itemToKey" as the former will be removed in -> the next Breaking Change update. A migration example: +> already, make sure you change to "itemToKey" as the former is removed in v9. A +> migration example: ```js // initial items. @@ -439,60 +439,45 @@ function itemToKey(item) { } ``` -### selectedItemChanged - -> DEPRECATED. Please use "itemToKey". - -> `function(prevItem: any, item: any)` | defaults to: -> `(prevItem, item) => (prevItem !== item)` - -Used to determine if the new `selectedItem` has changed compared to the previous -`selectedItem` and properly update Downshift's internal state. - ### getA11yStatusMessage > `function({/* see below */})` | default messages provided in English This function is passed as props to a status updating function nested within -that allows you to create your own ARIA statuses. It is called when one of the -following props change: `items`, `highlightedIndex`, `inputValue` or `isOpen`. - -A default `getA11yStatusMessage` function is provided that will check -`resultCount` and return "No results are available." or if there are results , -"`resultCount` results are available, use up and down arrow keys to navigate. -Press Enter key to select." - -> Note: `resultCount` is `items.length` in our default version of the function. - -### getA11ySelectionMessage +that allows you to create your own ARIA statuses. It is called when the state +changes: `selectedItem`, `highlightedIndex`, `inputValue` or `isOpen`. -> `function({/* see below */})` | default messages provided in English +There is no default function provided anymore since v9, so if there's no prop +passed, no aria live status message is created. An implementation that resembles +the previous default is written below, should you want to keep pre v9 behaviour. -This function is similar to the `getA11yStatusMessage` but it is generating a -message when an item is selected. It is passed as props to a status updating -function nested within that allows you to create your own ARIA statuses. It is -called when `selectedItem` changes. +We don't provide this as a default anymore since we consider that screen readers +have been significantly improved and they can convey information about items +count, possible actions and highlighted items only from the HTML markup, without +the need for aria-live regions. -A default `getA11ySelectionMessage` function is provided. When an item is -selected, the message is a selection related one, narrating -"`itemToString(selectedItem)` has been selected". +```js +function getA11yStatusMessage(state) { + if (!state.isOpen) { + return '' + } + // you need to get resultCount and previousResultCount yourself now, since we don't pass them as arguments anymore + const resultCount = items.length + const previousResultCount = previousResultCountRef.current -The object you are passed to generate your status message, for both -`getA11yStatusMessage` and `getA11ySelectionMessage`, has the following -properties: + if (!resultCount) { + return 'No results are available.' + } - + if (resultCount !== previousResultCount) { + return `${resultCount} result${ + resultCount === 1 ? ' is' : 's are' + } available, use up and down arrow keys to navigate. Press Enter key to select.` + } -| property | type | description | -| --------------------- | --------------- | -------------------------------------------------------------------------------------------- | -| `highlightedIndex` | `number` | The currently highlighted index | -| `highlightedItem` | `any` | The value of the highlighted item | -| `isOpen` | `boolean` | The `isOpen` state | -| `inputValue` | `string` | The value in the text input. | -| `itemToString` | `function(any)` | The `itemToString` function (see props) for getting the string value from one of the options | -| `previousResultCount` | `number` | The total items showing in the dropdown the last time the status was updated | -| `resultCount` | `number` | The total items showing in the dropdown | -| `selectedItem` | `any` | The value of the currently selected item | + return '' +} +``` ### onHighlightedIndexChange @@ -1168,3 +1153,7 @@ suggestion and the Codesandbox for it, and we will take it from there. https://github.com/downshift-js/downshift#advanced-react-component-patterns-course [migration-guide-v7]: https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V7.md#usecombobox +[migration-guide-v8]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V8.md +[migration-guide-v9]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V9.md diff --git a/src/hooks/useCombobox/__tests__/props.test.js b/src/hooks/useCombobox/__tests__/props.test.js index 895e942ad..71691ffe4 100644 --- a/src/hooks/useCombobox/__tests__/props.test.js +++ b/src/hooks/useCombobox/__tests__/props.test.js @@ -66,145 +66,8 @@ describe('props', () => { }) await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusContainer()).toHaveTextContent( - 'aaa has been selected.', - ) - }) - }) - - describe('itemToString', () => { - test('should provide string version to a11y status message', async () => { - jest.useFakeTimers() - renderCombobox({ - itemToString: () => 'custom-item', - initialIsOpen: true, - }) - - await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - 'custom-item has been selected.', - ) - }) - }) - - describe('selectedItemChanged', () => { - test('props update of selectedItem will update inputValue state with default selectedItemChanged referential equality check', () => { - const initialSelectedItem = {id: 3, value: 'init'} - const selectedItem = {id: 1, value: 'wow'} - const newSelectedItem = {id: 1, value: 'not wow'} - function itemToString(item) { - return item.value - } - const stateReducer = jest - .fn() - .mockImplementation((_state, {changes}) => changes) - - const {rerender} = renderCombobox({ - stateReducer, - itemToString, - selectedItem: initialSelectedItem, - }) - - expect(stateReducer).not.toHaveBeenCalled() // won't get called on first render - - rerender({ - stateReducer, - itemToString, - selectedItem, - }) - - expect(stateReducer).toHaveBeenCalledTimes(1) - expect(stateReducer).toHaveBeenCalledWith( - { - inputValue: itemToString(initialSelectedItem), - selectedItem, - highlightedIndex: -1, - isOpen: false, - }, - expect.objectContaining({ - type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem, - changes: { - inputValue: itemToString(selectedItem), - selectedItem, - highlightedIndex: -1, - isOpen: false, - }, - }), - ) - - stateReducer.mockClear() - rerender({ - stateReducer, - selectedItem: newSelectedItem, - itemToString, - }) - - expect(stateReducer).toHaveBeenCalledTimes(1) - expect(stateReducer).toHaveBeenCalledWith( - { - inputValue: itemToString(selectedItem), - selectedItem: newSelectedItem, - highlightedIndex: -1, - isOpen: false, - }, - expect.objectContaining({ - changes: { - inputValue: itemToString(newSelectedItem), - selectedItem: newSelectedItem, - highlightedIndex: -1, - isOpen: false, - }, - type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem, - }), - ) - expect(getInput()).toHaveValue(itemToString(newSelectedItem)) - }) - - test('props update of selectedItem will not update inputValue state if selectedItemChanged returns false', () => { - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}) - const initialSelectedItem = {id: 1, value: 'hmm'} - const selectedItem = {id: 1, value: 'wow'} - function itemToString(item) { - return item.value - } - const selectedItemChanged = jest - .fn() - .mockImplementation((prev, next) => prev.id !== next.id) - const stateReducer = jest - .fn() - .mockImplementation((_state, {changes}) => changes) - - const {rerender} = renderCombobox({ - selectedItemChanged, - stateReducer, - selectedItem: initialSelectedItem, - itemToString, - }) - - rerender({ - selectedItemChanged, - stateReducer, - selectedItem, - itemToString, - }) - - expect(getInput()).toHaveValue(itemToString(initialSelectedItem)) - expect(selectedItemChanged).toHaveBeenCalledTimes(1) - expect(selectedItemChanged).toHaveBeenCalledWith( - initialSelectedItem, - selectedItem, - ) - expect(consoleWarnSpy).toHaveBeenCalledTimes(1) - expect(consoleWarnSpy).toHaveBeenCalledWith( - `The "selectedItemChanged" is deprecated. Please use "itemToKey instead". https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#selecteditemchanged`, - ) - consoleWarnSpy.mockRestore() + expect(getInput()).toHaveValue('aaa') }) }) @@ -313,222 +176,93 @@ describe('props', () => { }) }) - describe('getA11ySelectionMessage', () => { - beforeEach(() => jest.useFakeTimers()) - afterEach(() => { - act(jest.runAllTimers) - }) - afterAll(jest.useRealTimers) - - test('reports that an item has been selected', async () => { - const itemIndex = 0 - renderCombobox({ - initialIsOpen: true, - }) - - await clickOnItemAtIndex(itemIndex) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - `${items[itemIndex]} has been selected.`, - ) - }) - - test('reports nothing if item is removed', async () => { - renderCombobox({ - initialSelectedItem: items[0], - }) - - await keyDownOnInput('{Escape}') - waitForDebouncedA11yStatusUpdate() - jest.runAllTimers() - - expect(getA11yStatusContainer()).toBeEmptyDOMElement() - }) - - test('is called with object that contains specific props', async () => { - const getA11ySelectionMessage = jest.fn() - const inputValue = 'a' - const isOpen = true - const highlightedIndex = 0 - renderCombobox({ - inputValue, - isOpen, - highlightedIndex, - items, - getA11ySelectionMessage, - }) - - await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() - - expect(getA11ySelectionMessage).toHaveBeenCalledWith({ - inputValue, - isOpen, - highlightedIndex, - resultCount: items.length, - highlightedItem: items[0], - itemToString: expect.any(Function), - selectedItem: items[0], - previousResultCount: undefined, - }) - }) - - test('is replaced with the user provided one', async () => { - renderCombobox({ - isOpen: true, - getA11ySelectionMessage: () => 'custom message', - }) - - await clickOnItemAtIndex(3) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent('custom message') - }) - }) - describe('getA11yStatusMessage', () => { beforeEach(() => jest.useFakeTimers()) afterEach(() => { - act(jest.runAllTimers) - }) - afterAll(jest.useRealTimers) - - test('reports that no results are available if items list is empty', async () => { - renderCombobox({ - items: [], - }) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - 'No results are available', - ) + act(() => jest.runAllTimers()) }) + afterAll(() => jest.useRealTimers()) - test('reports that one result is available if one item is shown', async () => { + test('adds no status message element to the DOM if not passed', async () => { renderCombobox({ - items: ['bla'], + items, }) await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusContainer()).toHaveTextContent( - '1 result is available, use up and down arrow keys to navigate. Press Enter key to select.', - ) + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) - test('reports the number of results available if more than one item are shown', async () => { + test('adds a status message element with the text returned', async () => { + const a11yStatusMessage1 = 'Dropdown is open' + const a11yStatusMessage2 = 'Dropdown is still open' + const getA11yStatusMessage = jest + .fn() + .mockReturnValueOnce(a11yStatusMessage1) + .mockReturnValueOnce(a11yStatusMessage2) renderCombobox({ - items: ['bla', 'blabla'], + items, + getA11yStatusMessage, }) await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusContainer()).toHaveTextContent( - '2 results are available, use up and down arrow keys to navigate. Press Enter key to select.', - ) - }) - - test('is empty on menu close', async () => { - renderCombobox({ - items: ['bla', 'blabla'], - initialIsOpen: true, + expect(getA11yStatusContainer()).toHaveTextContent(a11yStatusMessage1) + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + highlightedIndex: -1, + inputValue: '', + isOpen: true, + selectedItem: null, }) - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toBeEmptyDOMElement() - }) + getA11yStatusMessage.mockClear() - test('is removed after 500ms as a cleanup', async () => { - renderCombobox() + await keyDownOnInput('{ArrowDown}') - await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - act(() => jest.advanceTimersByTime(500)) - expect(getA11yStatusContainer()).toHaveTextContent('') - }) - - test('is replaced with the user provided one', async () => { - renderCombobox({ - getA11yStatusMessage: () => 'custom message', + expect(getA11yStatusContainer()).toHaveTextContent(a11yStatusMessage2) + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + highlightedIndex: 0, + inputValue: '', + isOpen: true, + selectedItem: null, }) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent('custom message') }) - test('is called with previousResultCount that gets updated correctly', async () => { - const getA11yStatusMessage = jest.fn() - const inputItems = ['aaa', 'bbb'] + test('clears the text content after 500ms', async () => { renderCombobox({ - getA11yStatusMessage, - items: inputItems, + items, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() + waitForDebouncedA11yStatusUpdate(true) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({ - previousResultCount: undefined, - resultCount: inputItems.length, - }), - ) - - inputItems.pop() - await changeInputValue('a') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledTimes(2) - expect(getA11yStatusMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - previousResultCount: inputItems.length + 1, - resultCount: inputItems.length, - }), - ) + expect(getA11yStatusContainer()).toBeEmptyDOMElement() }) - test('is called with object that contains specific props at toggle', async () => { - const getA11yStatusMessage = jest.fn() - const inputValue = 'a' - const highlightedIndex = 1 - const initialSelectedItem = items[highlightedIndex] - renderCombobox({ - inputValue, - initialSelectedItem, + test('removes the message element from the DOM on unmount', async () => { + const {unmount} = renderCombobox({ items, - getA11yStatusMessage, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() + waitForDebouncedA11yStatusUpdate(true) + unmount() - expect(getA11yStatusMessage).toHaveBeenCalledWith({ - highlightedIndex, - inputValue, - isOpen: true, - itemToString: expect.any(Function), - previousResultCount: undefined, - resultCount: items.length, - highlightedItem: items[highlightedIndex], - selectedItem: items[highlightedIndex], - }) + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) test('is added to the document provided by the user as prop', async () => { const environment = { document: { - getElementById: jest.fn(() => ({})), + getElementById: jest.fn().mockReturnValue({remove: jest.fn()}), createElement: jest.fn(), activeElement: {}, body: {}, @@ -537,63 +271,19 @@ describe('props', () => { removeEventListener: jest.fn(), Node, } - renderCombobox({items: [], environment}) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(environment.document.getElementById).toHaveBeenCalledTimes(1) - }) - - test('is called when isOpen, highlightedIndex, inputValue or items change', async () => { - const getA11yStatusMessage = jest.fn() - const inputItems = ['aaa', 'bbb'] - const {rerender} = renderCombobox({ - getA11yStatusMessage, + renderCombobox({ items, + environment, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).not.toHaveBeenCalled() - - // should not be called when any other prop is changed. - rerender({getA11yStatusMessage, items}) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).not.toHaveBeenCalled() - - rerender({getA11yStatusMessage, items: inputItems}) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({resultCount: inputItems.length}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) - await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({isOpen: true}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(2) - - await changeInputValue('b') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({inputValue: 'b'}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(3) - - await keyDownOnInput('{ArrowDown}') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({highlightedIndex: 0}), + expect(environment.document.getElementById).toHaveBeenCalledTimes(1) + expect(environment.document.getElementById).toHaveBeenCalledWith( + 'a11y-status-message', ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(4) }) }) diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index bd256ccd2..10c78b3f7 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -2,7 +2,6 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {isPreact, isReactNative, isReactNativeWeb} from '../../is.macro' import {handleRefs, normalizeArrowKey, callAllEventHandlers} from '../../utils' import { - useA11yMessageSetter, useMouseAndTouchTracker, useGetterPropsCalledChecker, useLatestRef, @@ -13,6 +12,7 @@ import { getInitialValue, isDropdownsStateEqual, useIsInitialMount, + useA11yMessageStatus, } from '../utils' import { getInitialState, @@ -32,14 +32,7 @@ function useCombobox(userProps = {}) { ...defaultProps, ...userProps, } - const { - items, - scrollIntoView, - environment, - getA11yStatusMessage, - getA11ySelectionMessage, - itemToString, - } = props + const {items, scrollIntoView, environment, getA11yStatusMessage} = props // Initial state depending on controlled props. const [state, dispatch] = useControlledReducer( downshiftUseComboboxReducer, @@ -69,26 +62,13 @@ function useCombobox(userProps = {}) { ) // Effects. - // Sets a11y status message on changes in state. - useA11yMessageSetter( + // Adds an a11y aria live status message if getA11yStatusMessage is passed. + useA11yMessageStatus( getA11yStatusMessage, - [isOpen, highlightedIndex, inputValue, items], - { - previousResultCount: previousResultCountRef.current, - items, - environment, - itemToString, - ...state, - }, - ) - // Sets a11y status message on changes in selectedItem. - useA11yMessageSetter(getA11ySelectionMessage, [selectedItem], { - previousResultCount: previousResultCountRef.current, - items, + state, + [isOpen, highlightedIndex, selectedItem, inputValue], environment, - itemToString, - ...state, - }) + ) // Scroll on highlighted item if change comes from keyboard. const shouldScrollRef = useScrollIntoView({ menuElement: menuRef.current, diff --git a/src/hooks/useCombobox/utils.js b/src/hooks/useCombobox/utils.js index 49c7ba563..290c0bd28 100644 --- a/src/hooks/useCombobox/utils.js +++ b/src/hooks/useCombobox/utils.js @@ -1,11 +1,6 @@ import {useRef, useEffect} from 'react' import PropTypes from 'prop-types' -import { - getA11yStatusMessage, - isControlledProp, - getState, - noop, -} from '../../utils' +import {isControlledProp, getState, noop} from '../../utils' import { commonDropdownPropTypes, defaultProps as defaultPropsCommon, @@ -40,8 +35,6 @@ const propTypes = { ...commonDropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, - selectedItemChanged: PropTypes.func, - getA11ySelectionMessage: PropTypes.func, inputValue: PropTypes.string, defaultInputValue: PropTypes.string, initialInputValue: PropTypes.string, @@ -85,22 +78,9 @@ export function useControlledReducer( if ( !isInitialMount // on first mount we already have the proper inputValue for a initial selected item. ) { - let shouldCallDispatch - - if (props.selectedItemChanged === undefined) { - shouldCallDispatch = - props.itemToKey(props.selectedItem) !== - props.itemToKey(previousSelectedItemRef.current) - } else { - console.warn( - `The "selectedItemChanged" is deprecated. Please use "itemToKey instead". https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#selecteditemchanged`, - ) - - shouldCallDispatch = props.selectedItemChanged( - previousSelectedItemRef.current, - props.selectedItem, - ) - } + const shouldCallDispatch = + props.itemToKey(props.selectedItem) !== + props.itemToKey(previousSelectedItemRef.current) if (shouldCallDispatch) { dispatch({ @@ -131,7 +111,6 @@ if (process.env.NODE_ENV !== 'production') { export const defaultProps = { ...defaultPropsCommon, - getA11yStatusMessage, isItemDisabled() { return false }, diff --git a/src/hooks/useMultipleSelection/README.md b/src/hooks/useMultipleSelection/README.md index 9d16dc9e5..367583d95 100644 --- a/src/hooks/useMultipleSelection/README.md +++ b/src/hooks/useMultipleSelection/README.md @@ -27,6 +27,14 @@ arrow navigation between dropdown and items, navigation between the items themselves, removing and adding items, and also helpful `aria-live` messages such as when an item has been removed from selection. +## Migration through breaking changes + +The hook received breaking changes related to how it works, as well as the API, +starting with v8. They are documented here: + +- [v8 migration guide][migration-guide-v8] +- [v9 migration guide][migration-guide-v9] + ## Table of Contents @@ -45,7 +53,7 @@ such as when an item has been removed from selection. - [defaultSelectedItems](#defaultselecteditems) - [defaultActiveIndex](#defaultactiveindex) - [itemToKey](#itemtokey) - - [getA11yRemovalMessage](#geta11yremovalmessage) + - [getA11yStatusMessage](#geta11ystatusmessage) - [onActiveIndexChange](#onactiveindexchange) - [onStateChange](#onstatechange) - [activeIndex](#activeindex) @@ -453,33 +461,42 @@ function itemToKey(item) { } ``` -### getA11yRemovalMessage +### getA11yStatusMessage > `function({/* see below */})` | default messages provided in English -This function is similar to the `getA11yStatusMessage` or -`getA11ySelectionMessage` from `useSelect` and `useCombobox` but it is -generating an ARIA a11y message when an item is removed. It is passed as props -to a status updating function nested within that allows you to create your own -ARIA statuses. It is called when an item is removed and the size of -`selectedItems` decreases. +This function is passed as props to a status updating function nested within +that allows you to create your own ARIA statuses. It is called when the state +changes: `selectedItem`, `highlightedIndex`, `inputValue` or `isOpen`. -A default `getA11yRemovalMessage` function is provided. When an item is removed, -the message is a removal related one, narrating "`itemToString(removedItem)` has -been removed". +There is no default function provided anymore since v9, so if there's no prop +passed, no aria live status message is created. An implementation that resembles +the previous default is written below, should you want to keep pre v9 behaviour. -The object you are passed to generate your status message for -`getA11yRemovalMessage` has the following properties: +We don't provide this as a default anymore since we consider that screen readers +have been significantly improved and they can convey information about items +count, possible actions and highlighted items only from the HTML markup, without +the need for aria-live regions. - +```js +function getA11yStatusMessage(state) { + const {selectedItems} = state -| property | type | description | -| --------------------- | --------------- | -------------------------------------------------------------------------------------------- | -| `resultCount` | `number` | The count of selected items in the list. | -| `itemToString` | `function(any)` | The `itemToString` function (see props) for getting the string value from one of the options | -| `removedSelectedItem` | `any` | The value of the currently removed item | -| `activeSelectedItem` | `any` | The value of the currently active item | -| `activeIndex` | `number` | The index of the currently active item. | + if (selectedItems.length === previousSelectedItemsRef.current.length) { + return + } + + const removedSelectedItem = previousSelectedItemsRef.current.find( + selectedItem => + selectedItems.findIndex( + item => props.itemToKey(item) === props.itemToKey(selectedItem), + ) < 0, + ) + + // where itemToString is a function that returns the string equivalent for an item. + return `${itemToString(removedSelectedItem)} has been removed.` +} +``` ### onActiveIndexChange @@ -942,3 +959,7 @@ suggestion and the Codesandbox for it, and we will take it from there. [sandbox-repo]: https://codesandbox.io/s/github/kentcdodds/downshift-examples [advanced-react-component-patterns-course]: https://github.com/downshift-js/downshift#advanced-react-component-patterns-course +[migration-guide-v8]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V8.md +[migration-guide-v9]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V9.md \ No newline at end of file diff --git a/src/hooks/useMultipleSelection/__tests__/props.test.js b/src/hooks/useMultipleSelection/__tests__/props.test.js index e9155764a..5294e57be 100644 --- a/src/hooks/useMultipleSelection/__tests__/props.test.js +++ b/src/hooks/useMultipleSelection/__tests__/props.test.js @@ -13,6 +13,7 @@ import { getInput, items, keyDownOnInput, + waitForDebouncedA11yStatusUpdate, } from '../testUtils' import useMultipleSelection from '..' @@ -32,108 +33,118 @@ describe('props', () => { }) describe('selectedItems', () => { - afterEach(() => { - act(() => jest.runAllTimers()) - }) + test('control the state property if passed', async () => { + const inputItems = [items[0], items[1]] - test('passed as objects should work with custom itemToString', async () => { renderMultipleCombobox({ multipleSelectionProps: { - initialSelectedItems: [{str: 'aaa'}, {str: 'bbb'}], + selectedItems: inputItems, initialActiveIndex: 0, - itemToString: item => item.str, }, }) await keyDownOnSelectedItemAtIndex(0, '{Delete}') - expect(getA11yStatusContainer()).toHaveTextContent( - 'aaa has been removed.', - ) + expect(getSelectedItems()).toHaveLength(2) }) + }) - test('controls the state property if passed', async () => { - const inputItems = [items[0], items[1]] + describe('getA11yStatusMessage', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => { + act(() => jest.runAllTimers()) + }) + afterAll(() => jest.useRealTimers()) + test('adds no status message element to the DOM if not passed', async () => { renderMultipleCombobox({ multipleSelectionProps: { - selectedItems: inputItems, + selectedItems: [items[0], items[1]], initialActiveIndex: 0, }, }) - await keyDownOnSelectedItemAtIndex(0, '{Delete}') - - expect(getSelectedItems()).toHaveLength(2) - }) - }) + await keyDownOnSelectedItemAtIndex(1, '{ArrowLeft}') + waitForDebouncedA11yStatusUpdate() - describe('getA11yRemovalMessage', () => { - afterEach(() => { - act(() => jest.runAllTimers()) + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) - test('is not added if the document in undefined', async () => { + test('adds a status message element with the text returned', async () => { + const a11yStatusMessage1 = 'to the left to the left' + const a11yStatusMessage2 = 'to the right?' + const selectedItems = [items[0], items[1]] + const getA11yStatusMessage = jest + .fn() + .mockReturnValueOnce(a11yStatusMessage1) + .mockReturnValueOnce(a11yStatusMessage2) renderMultipleCombobox({ multipleSelectionProps: { - initialSelectedItems: [items[0], items[1]], + selectedItems, initialActiveIndex: 0, - environment: undefined, + getA11yStatusMessage, }, }) - await keyDownOnSelectedItemAtIndex(0, '{Delete}') + await keyDownOnSelectedItemAtIndex(1, '{ArrowLeft}') + waitForDebouncedA11yStatusUpdate() + + expect(getA11yStatusContainer()).toHaveTextContent(a11yStatusMessage1) + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + activeIndex: 0, + selectedItems, + }) + + getA11yStatusMessage.mockClear() + + await keyDownOnSelectedItemAtIndex(0, '{ArrowRight}') - expect(getA11yStatusContainer()).not.toHaveTextContent() + waitForDebouncedA11yStatusUpdate() + + expect(getA11yStatusContainer()).toHaveTextContent(a11yStatusMessage2) + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + activeIndex: 1, + selectedItems, + }) }) - test('is called with object that contains specific props', async () => { - const getA11yRemovalMessage = jest.fn() - const itemToString = item => item.str - const initialSelectedItems = [{str: 'aaa'}, {str: 'bbb'}] + test('clears the text content after 500ms', async () => { renderMultipleCombobox({ multipleSelectionProps: { - initialSelectedItems, + selectedItems: [items[0], items[1]], initialActiveIndex: 0, - itemToString, - getA11yRemovalMessage, - activeIndex: 0, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }, }) - await keyDownOnSelectedItemAtIndex(0, '{Delete}') + await keyDownOnSelectedItemAtIndex(1, '{ArrowLeft}') + waitForDebouncedA11yStatusUpdate(true) - expect(getA11yRemovalMessage).toHaveBeenCalledWith( - expect.objectContaining({ - itemToString, - resultCount: 1, - removedSelectedItem: initialSelectedItems[0], - activeIndex: 0, - activeSelectedItem: initialSelectedItems[1], - }), - ) + expect(getA11yStatusContainer()).toBeEmptyDOMElement() }) - test('is replaced with the user provided one', async () => { - const initialSelectedItems = [items[0], items[1]] - - renderMultipleCombobox({ + test('removes the message element from the DOM on unmount', async () => { + const {unmount} = renderMultipleCombobox({ multipleSelectionProps: { - initialSelectedItems, + selectedItems: [items[0], items[1]], initialActiveIndex: 0, - getA11yRemovalMessage: () => 'custom message', + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }, }) - await keyDownOnSelectedItemAtIndex(0, '{Delete}') + await keyDownOnSelectedItemAtIndex(1, '{ArrowLeft}') + waitForDebouncedA11yStatusUpdate(true) + unmount() - expect(getA11yStatusContainer()).toHaveTextContent('custom message') + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) test('is added to the document provided by the user as prop', async () => { const environment = { document: { - getElementById: jest.fn(() => ({})), + getElementById: jest.fn().mockReturnValue({remove: jest.fn()}), createElement: jest.fn(), activeElement: {}, body: {}, @@ -142,17 +153,23 @@ describe('props', () => { removeEventListener: jest.fn(), Node, } + renderMultipleCombobox({ multipleSelectionProps: { - initialSelectedItems: [items[0], items[1]], + selectedItems: [items[0], items[1]], initialActiveIndex: 0, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), environment, }, }) - await keyDownOnSelectedItemAtIndex(0, '{Delete}') + await keyDownOnSelectedItemAtIndex(1, '{ArrowLeft}') + waitForDebouncedA11yStatusUpdate() expect(environment.document.getElementById).toHaveBeenCalledTimes(1) + expect(environment.document.getElementById).toHaveBeenCalledWith( + 'a11y-status-message', + ) }) }) diff --git a/src/hooks/useMultipleSelection/index.js b/src/hooks/useMultipleSelection/index.js index 7988f8f5d..1bdea917b 100644 --- a/src/hooks/useMultipleSelection/index.js +++ b/src/hooks/useMultipleSelection/index.js @@ -1,6 +1,4 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' -import {isReactNative} from '../../is.macro' -import setStatus from '../../set-a11y-status' import {handleRefs, callAllEventHandlers, normalizeArrowKey} from '../../utils' import { useControlledReducer, @@ -9,6 +7,7 @@ import { useControlPropsValidator, getItemAndIndex, useIsInitialMount, + useA11yMessageStatus, } from '../utils' import { getInitialState, @@ -30,8 +29,7 @@ function useMultipleSelection(userProps = {}) { ...userProps, } const { - getA11yRemovalMessage, - itemToString, + getA11yStatusMessage, environment, keyNavigationNext, keyNavigationPrevious, @@ -49,42 +47,18 @@ function useMultipleSelection(userProps = {}) { // Refs. const isInitialMount = useIsInitialMount() const dropdownRef = useRef(null) - const previousSelectedItemsRef = useRef(selectedItems) const selectedItemRefs = useRef() selectedItemRefs.current = [] const latest = useLatestRef({state, props}) // Effects. - /* Sets a11y status message on changes in selectedItem. */ - useEffect(() => { - if (isInitialMount || isReactNative || !environment?.document) { - return - } - - if (selectedItems.length < previousSelectedItemsRef.current.length) { - const removedSelectedItem = previousSelectedItemsRef.current.find( - selectedItem => - selectedItems.findIndex( - item => props.itemToKey(item) === props.itemToKey(selectedItem), - ) < 0, - ) - - setStatus( - getA11yRemovalMessage({ - itemToString, - resultCount: selectedItems.length, - removedSelectedItem, - activeIndex, - activeSelectedItem: selectedItems[activeIndex], - }), - environment.document, - ) - } - - previousSelectedItemsRef.current = selectedItems - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedItems.length]) + // Adds an a11y aria live status message if getA11yStatusMessage is passed. + useA11yMessageStatus( + getA11yStatusMessage, + state, + [activeIndex, selectedItems], + environment, + ) // Sets focus on active item. useEffect(() => { if (isInitialMount) { diff --git a/src/hooks/useMultipleSelection/testUtils.js b/src/hooks/useMultipleSelection/testUtils.js index 0a60f14d2..cd648603f 100644 --- a/src/hooks/useMultipleSelection/testUtils.js +++ b/src/hooks/useMultipleSelection/testUtils.js @@ -16,7 +16,7 @@ jest.mock('react', () => { ...jest.requireActual('react'), useId() { return 'test-id' - } + }, } }) @@ -68,18 +68,12 @@ const DropdownMultipleCombobox = ({ }) => { const {getSelectedItemProps, getDropdownProps, selectedItems} = useMultipleSelection(multipleSelectionProps) - const { - getToggleButtonProps, - getLabelProps, - getMenuProps, - getInputProps, - } = useCombobox({ - items, - ...comboboxProps, - }) - const {itemToString} = multipleSelectionProps.itemToString - ? multipleSelectionProps - : defaultProps + const {getToggleButtonProps, getLabelProps, getMenuProps, getInputProps} = + useCombobox({ + items, + ...comboboxProps, + }) + const {itemToString} = defaultProps return (
diff --git a/src/hooks/useMultipleSelection/utils.js b/src/hooks/useMultipleSelection/utils.js index 72b3d7ae6..024455772 100644 --- a/src/hooks/useMultipleSelection/utils.js +++ b/src/hooks/useMultipleSelection/utils.js @@ -82,19 +82,6 @@ function isKeyDownOperationPermitted(event) { return true } -/** - * Returns a message to be added to aria-live region when item is removed. - * - * @param {Object} selectionParameters Parameters required to build the message. - * @returns {string} The a11y message. - */ -function getA11yRemovalMessage(selectionParameters) { - const {removedSelectedItem, itemToString: itemToStringLocal} = - selectionParameters - - return `${itemToStringLocal(removedSelectedItem)} has been removed.` -} - /** * Check if a state is equal for taglist, by comparing active index and selected items. * Used by useSelect and useCombobox. @@ -111,11 +98,13 @@ function isStateEqual(prevState, newState) { } const propTypes = { - ...commonPropTypes, + stateReducer: commonPropTypes.stateReducer, + itemToKey: commonPropTypes.itemToKey, + environment: commonPropTypes.environment, selectedItems: PropTypes.array, initialSelectedItems: PropTypes.array, defaultSelectedItems: PropTypes.array, - getA11yRemovalMessage: PropTypes.func, + getA11yStatusMessage: PropTypes.func, activeIndex: PropTypes.number, initialActiveIndex: PropTypes.number, defaultActiveIndex: PropTypes.number, @@ -126,11 +115,9 @@ const propTypes = { } export const defaultProps = { - itemToString: defaultPropsCommon.itemToString, itemToKey: defaultPropsCommon.itemToKey, stateReducer: defaultPropsCommon.stateReducer, environment: defaultPropsCommon.environment, - getA11yRemovalMessage, keyNavigationNext: 'ArrowRight', keyNavigationPrevious: 'ArrowLeft', } diff --git a/src/hooks/useSelect/README.md b/src/hooks/useSelect/README.md index eaae3c5b2..74c59f135 100644 --- a/src/hooks/useSelect/README.md +++ b/src/hooks/useSelect/README.md @@ -23,12 +23,14 @@ implement the corresponding ARIA pattern. Every functionality needed should be provided out-of-the-box: menu toggle, item selection and up/down movement between them, screen reader support, highlight by character keys etc. -## Migration to v7 +## Migration through breaking changes -`useSelect` received some changes related to its API and how it works in version -7, as a conequence of adapting it to the ARIA 1.2 select-only combobox pattern. -If you were using _useSelect_ previous to 7.0.0, check the [migration -guide][migration-guide-v7] and update if necessary. +The hook received breaking changes related to how it works, as well as the API, +starting with v7. They are documented here: + +- [v7 migration guide][migration-guide-v7] +- [v8 migration guide][migration-guide-v8] +- [v9 migration guide][migration-guide-v9] ## Table of Contents @@ -51,7 +53,6 @@ guide][migration-guide-v7] and update if necessary. - [defaultHighlightedIndex](#defaulthighlightedindex) - [itemToKey](#itemtokey) - [getA11yStatusMessage](#geta11ystatusmessage) - - [getA11ySelectionMessage](#geta11yselectionmessage) - [onHighlightedIndexChange](#onhighlightedindexchange) - [onIsOpenChange](#onisopenchange) - [onStateChange](#onstatechange) @@ -356,45 +357,40 @@ function itemToKey(item) { > `function({/* see below */})` | default messages provided in English This function is passed as props to a status updating function nested within -that allows you to create your own ARIA statuses. It is called when one of the -following props change: `items`, `highlightedIndex`, `inputValue` or `isOpen`. - -A default `getA11yStatusMessage` function is provided that will check -`resultCount` and return "No results are available." or if there are results , -"`resultCount` results are available, use up and down arrow keys to navigate. -Press Enter or Space Bar keys to select." - -> Note: `resultCount` is `items.length` in our default version of the function. +that allows you to create your own ARIA statuses. It is called when the state +changes: `selectedItem`, `highlightedIndex`, `inputValue` or `isOpen`. -### getA11ySelectionMessage +There is no default function provided anymore since v9, so if there's no prop +passed, no aria live status message is created. An implementation that resembles +the previous default is written below, should you want to keep pre v9 behaviour. -> `function({/* see below */})` | default messages provided in English - -This function is similar to the `getA11yStatusMessage` but it is generating a -message when an item is selected. It is passed as props to a status updating -function nested within that allows you to create your own ARIA statuses. It is -called when `selectedItem` changes. +We don't provide this as a default anymore since we consider that screen readers +have been significantly improved and they can convey information about items +count, possible actions and highlighted items only from the HTML markup, without +the need for aria-live regions. -A default `getA11ySelectionMessage` function is provided. When an item is -selected, the message is a selection related one, narrating -"`itemToString(selectedItem)` has been selected". +```js +function getA11yStatusMessage(state) { + if (!state.isOpen) { + return '' + } + // you need to get resultCount and previousResultCount yourself now, since we don't pass them as arguments anymore + const resultCount = items.length + const previousResultCount = previousResultCountRef.current -The object you are passed to generate your status message, for both -`getA11yStatusMessage` and `getA11ySelectionMessage`, has the following -properties: + if (!resultCount) { + return 'No results are available.' + } - + if (resultCount !== previousResultCount) { + return `${resultCount} result${ + resultCount === 1 ? ' is' : 's are' + } available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.` + } -| property | type | description | -| --------------------- | --------------- | -------------------------------------------------------------------------------------------- | -| `highlightedIndex` | `number` | The currently highlighted index | -| `highlightedItem` | `any` | The value of the highlighted item | -| `inputValue` | `string` | The current input value | -| `isOpen` | `boolean` | The `isOpen` state | -| `itemToString` | `function(any)` | The `itemToString` function (see props) for getting the string value from one of the options | -| `previousResultCount` | `number` | The total items showing in the dropdown the last time the status was updated | -| `resultCount` | `number` | The total items showing in the dropdown | -| `selectedItem` | `any` | The value of the currently selected item | + return '' +} +``` ### onHighlightedIndexChange @@ -985,3 +981,7 @@ suggestion and the Codesandbox for it, and we will take it from there. https://github.com/downshift-js/downshift#advanced-react-component-patterns-course [migration-guide-v7]: https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V7.md#useselect +[migration-guide-v8]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V8.md +[migration-guide-v9]: + https://github.com/downshift-js/downshift/tree/master/src/hooks/MIGRATION_V9.md diff --git a/src/hooks/useSelect/__tests__/props.test.js b/src/hooks/useSelect/__tests__/props.test.js index 7f2618da7..814a35967 100644 --- a/src/hooks/useSelect/__tests__/props.test.js +++ b/src/hooks/useSelect/__tests__/props.test.js @@ -53,248 +53,108 @@ describe('props', () => { }) test('passed as objects should work with custom itemToString', async () => { - jest.useFakeTimers() renderSelect({ items: [{str: 'aaa'}, {str: 'bbb'}], itemToString: item => item.str, initialIsOpen: true, }) - await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - 'aaa has been selected.', - ) - }) - }) - - test('itemToString should provide string version to a11y status message', async () => { - jest.useFakeTimers() - renderSelect({ - itemToString: () => 'custom-item', - initialIsOpen: true, - }) - - await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - 'custom-item has been selected.', - ) - jest.useRealTimers() - }) - - describe('getA11ySelectionMessage', () => { - beforeEach(() => jest.useFakeTimers()) - beforeEach(jest.clearAllTimers) - afterAll(jest.useRealTimers) - - test('reports that an item has been selected', async () => { - const itemIndex = 0 - renderSelect({ - initialIsOpen: true, - }) - - await clickOnItemAtIndex(itemIndex) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - `${items[itemIndex]} has been selected.`, - ) - }) - - test('is called with object that contains specific props', async () => { - const getA11ySelectionMessage = jest.fn() - const inputValue = 'a' - const isOpen = true - const highlightedIndex = 0 - renderSelect({ - getA11ySelectionMessage, - inputValue, - isOpen, - highlightedIndex, - items, - }) - - await clickOnItemAtIndex(0) - waitForDebouncedA11yStatusUpdate() + await keyDownOnToggleButton('b') - expect(getA11ySelectionMessage).toHaveBeenCalledTimes(1) - expect(getA11ySelectionMessage).toHaveBeenCalledWith( - expect.objectContaining({ - inputValue, - isOpen, - highlightedIndex, - resultCount: items.length, - previousResultCount: undefined, - highlightedItem: items[0], - itemToString: expect.any(Function), - selectedItem: items[0], - }), + expect(getToggleButton()).toHaveAttribute( + 'aria-activedescendant', + defaultIds.getItemId(1), ) }) - - test('is replaced with the user provided one', async () => { - renderSelect({ - getA11ySelectionMessage: () => 'custom message', - initialIsOpen: true, - }) - - await clickOnItemAtIndex(3) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent('custom message') - }) }) describe('getA11yStatusMessage', () => { beforeEach(() => jest.useFakeTimers()) afterEach(() => { - act(jest.runAllTimers) - }) - afterAll(jest.useRealTimers) - - test('reports that no results are available if items list is empty', async () => { - renderSelect({ - items: [], - }) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent( - 'No results are available', - ) + act(() => jest.runAllTimers()) }) + afterAll(() => jest.useRealTimers()) - test('reports that one result is available if one item is shown', async () => { + test('adds no status message element to the DOM if not passed', async () => { renderSelect({ - items: ['item1'], + items, }) await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusContainer()).toHaveTextContent( - '1 result is available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.', - ) + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) - test('reports the number of results available if more than one item are shown', async () => { + test('adds a status message element with the text returned', async () => { + const a11yStatusMessage1 = 'Dropdown is open' + const a11yStatusMessage2 = 'Dropdown is still open' + const getA11yStatusMessage = jest + .fn() + .mockReturnValueOnce(a11yStatusMessage1) + .mockReturnValueOnce(a11yStatusMessage2) renderSelect({ - items: ['item1', 'item2'], + items, + getA11yStatusMessage, }) await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusContainer()).toHaveTextContent( - '2 results are available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.', - ) - }) - - test('is empty on menu close', async () => { - renderSelect({ - items: ['item1', 'item2'], - initialIsOpen: true, + expect(getA11yStatusContainer()).toHaveTextContent(a11yStatusMessage1) + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + highlightedIndex: -1, + inputValue: '', + isOpen: true, + selectedItem: null, }) - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toBeEmptyDOMElement() - }) + getA11yStatusMessage.mockClear() - test('is removed after 500ms as a cleanup', async () => { - renderSelect({ - items: ['item1', 'item2'], - }) + await keyDownOnToggleButton('{ArrowDown}') - await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - act(() => jest.advanceTimersByTime(500)) - expect(getA11yStatusContainer()).toBeEmptyDOMElement() - }) - - test('is replaced with the user provided one', async () => { - renderSelect({ - getA11yStatusMessage: () => 'custom message', + expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) + expect(getA11yStatusMessage).toHaveBeenCalledWith({ + highlightedIndex: 0, + inputValue: '', + isOpen: true, + selectedItem: null, }) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusContainer()).toHaveTextContent('custom message') }) - test('is called with previousResultCount that gets updated correctly', async () => { - const getA11yStatusMessage = jest.fn() - const inputItems = ['aaa', 'bbb'] + test('clears the text content after 500ms', async () => { renderSelect({ - getA11yStatusMessage, - items: inputItems, + items, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({ - previousResultCount: undefined, - resultCount: inputItems.length, - }), - ) + waitForDebouncedA11yStatusUpdate(true) - inputItems.pop() - await keyDownOnToggleButton('{ArrowDown}') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledTimes(2) - expect(getA11yStatusMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - previousResultCount: inputItems.length + 1, - resultCount: inputItems.length, - }), - ) + expect(getA11yStatusContainer()).toBeEmptyDOMElement() }) - test('is called with object that contains specific props at toggle', async () => { - const getA11yStatusMessage = jest.fn() - const inputValue = 'a' - const highlightedIndex = 1 - const initialSelectedItem = items[highlightedIndex] - renderSelect({ - getA11yStatusMessage, - inputValue, - initialSelectedItem, - selectedItem: items[highlightedIndex], + test('removes the message element from the DOM on unmount', async () => { + const {unmount} = renderSelect({ + items, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() + act(() => jest.advanceTimersByTime(500)) + unmount() - expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({ - highlightedIndex, - inputValue, - isOpen: true, - itemToString: expect.any(Function), - previousResultCount: undefined, - resultCount: items.length, - highlightedItem: items[highlightedIndex], - selectedItem: items[highlightedIndex], - }), - ) + expect(getA11yStatusContainer()).not.toBeInTheDocument() }) test('is added to the document provided by the user as prop', async () => { const environment = { document: { - getElementById: jest.fn(() => ({})), + getElementById: jest.fn().mockReturnValue({remove: jest.fn()}), createElement: jest.fn(), activeElement: {}, body: {}, @@ -303,63 +163,19 @@ describe('props', () => { removeEventListener: jest.fn(), Node, } - renderSelect({items: [], environment}) - - await clickOnToggleButton() - waitForDebouncedA11yStatusUpdate() - - expect(environment.document.getElementById).toHaveBeenCalledTimes(1) - }) - - test('is called when isOpen, highlightedIndex, inputValue or items change', async () => { - const getA11yStatusMessage = jest.fn() - const inputItems = ['aaa', 'bbb'] - const {rerender} = renderSelect({ - getA11yStatusMessage, + renderSelect({ items, + environment, + getA11yStatusMessage: jest.fn().mockReturnValue('bla bla'), }) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).not.toHaveBeenCalled() - - // should not be called when any other prop is changed. - rerender({getA11yStatusMessage, items, id: 'hello'}) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).not.toHaveBeenCalled() - - rerender({getA11yStatusMessage, items: inputItems}) - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({resultCount: inputItems.length}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) - await clickOnToggleButton() waitForDebouncedA11yStatusUpdate() - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({isOpen: true}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(2) - - await keyDownOnToggleButton('b') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({inputValue: 'b', highlightedIndex: 1}), - ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(3) - - await keyDownOnToggleButton('{ArrowUp}') - waitForDebouncedA11yStatusUpdate() - - expect(getA11yStatusMessage).toHaveBeenCalledWith( - expect.objectContaining({highlightedIndex: 0}), + expect(environment.document.getElementById).toHaveBeenCalledTimes(1) + expect(environment.document.getElementById).toHaveBeenCalledWith( + 'a11y-status-message', ) - expect(getA11yStatusMessage).toHaveBeenCalledTimes(4) }) }) diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index bead1dfed..e0187046c 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -5,7 +5,6 @@ import { getInitialState, useGetterPropsCalledChecker, useLatestRef, - useA11yMessageSetter, useScrollIntoView, useControlPropsValidator, useElementIds, @@ -13,7 +12,7 @@ import { getItemAndIndex, getInitialValue, isDropdownsStateEqual, - useIsInitialMount, + useA11yMessageStatus, } from '../utils' import { callAllEventHandlers, @@ -35,14 +34,7 @@ function useSelect(userProps = {}) { ...defaultProps, ...userProps, } - const { - items, - scrollIntoView, - environment, - itemToString, - getA11ySelectionMessage, - getA11yStatusMessage, - } = props + const {scrollIntoView, environment, getA11yStatusMessage} = props // Initial state depending on controlled props. const [state, dispatch] = useControlledReducer( downshiftSelectReducer, @@ -59,9 +51,6 @@ function useSelect(userProps = {}) { const clearTimeoutRef = useRef(null) // prevent id re-generation between renders. const elementIds = useElementIds(props) - // used to keep track of how many items we had on previous cycle. - const previousResultCountRef = useRef() - const isInitialMount = useIsInitialMount() // utility callback to get item element. const latest = useLatestRef({ state, @@ -75,26 +64,13 @@ function useSelect(userProps = {}) { ) // Effects. - // Sets a11y status message on changes in state. - useA11yMessageSetter( + // Adds an a11y aria live status message if getA11yStatusMessage is passed. + useA11yMessageStatus( getA11yStatusMessage, - [isOpen, highlightedIndex, inputValue, items], - { - previousResultCount: previousResultCountRef.current, - items, - environment, - itemToString, - ...state, - }, - ) - // Sets a11y status message on changes in selectedItem. - useA11yMessageSetter(getA11ySelectionMessage, [selectedItem], { - previousResultCount: previousResultCountRef.current, - items, + state, + [isOpen, highlightedIndex, selectedItem, inputValue], environment, - itemToString, - ...state, - }) + ) // Scroll on highlighted item if change comes from keyboard. const shouldScrollRef = useScrollIntoView({ menuElement: menuRef.current, @@ -104,7 +80,6 @@ function useSelect(userProps = {}) { scrollIntoView, getItemNodeFromIndex, }) - // Sets cleanup for the keysSoFar callback, debounded after 500ms. useEffect(() => { // init the clean function here as we need access to dispatch. @@ -120,7 +95,6 @@ function useSelect(userProps = {}) { clearTimeoutRef.current.cancel() } }, []) - // Invokes the keysSoFar callback set up above. useEffect(() => { if (!inputValue) { @@ -134,13 +108,6 @@ function useSelect(userProps = {}) { props, state, }) - useEffect(() => { - if (isInitialMount) { - return - } - - previousResultCountRef.current = items.length - }) // Focus the toggle button on first render if required. useEffect(() => { const focusOnOpen = getInitialValue(props, 'isOpen') diff --git a/src/hooks/useSelect/utils.ts b/src/hooks/useSelect/utils.ts index a1ab3e239..ede313748 100644 --- a/src/hooks/useSelect/utils.ts +++ b/src/hooks/useSelect/utils.ts @@ -4,7 +4,6 @@ import { defaultProps as commonDefaultProps, } from '../utils' import {noop} from '../../utils' -import {A11yStatusMessageOptions} from '../../types' import {GetItemIndexByCharacterKeyOptions} from './types' export function getItemIndexByCharacterKey({ @@ -39,42 +38,10 @@ const propTypes = { ...commonDropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, - getA11ySelectionMessage: PropTypes.func, -} - -/** - * Default implementation for status message. Only added when menu is open. - * Will specift if there are results in the list, and if so, how many, - * and what keys are relevant. - * - * @param {Object} param the downshift state and other relevant properties - * @return {String} the a11y status message - */ -function getA11yStatusMessage({ - isOpen, - resultCount, - previousResultCount, -}: A11yStatusMessageOptions): string { - if (!isOpen) { - return '' - } - - if (!resultCount) { - return 'No results are available.' - } - - if (resultCount !== previousResultCount) { - return `${resultCount} result${ - resultCount === 1 ? ' is' : 's are' - } available, use up and down arrow keys to navigate. Press Enter or Space Bar keys to select.` - } - - return '' } export const defaultProps = { ...commonDefaultProps, - getA11yStatusMessage, isItemDisabled() { return false }, diff --git a/src/hooks/utils.js b/src/hooks/utils.js index 04cb63781..df05a17ec 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -16,7 +16,7 @@ import { noop, targetWithinDownshift, } from '../utils' -import setStatus from '../set-a11y-status' +import {cleanupStatusDiv, setStatus} from '../set-a11y-status' const dropdownDefaultStateValues = { highlightedIndex: -1, @@ -65,23 +65,11 @@ function stateReducer(s, a) { return a.changes } -/** - * Returns a message to be added to aria-live region when item is selected. - * - * @param {Object} selectionParameters Parameters required to build the message. - * @returns {string} The a11y message. - */ -function getA11ySelectionMessage(selectionParameters) { - const {selectedItem, itemToString} = selectionParameters - - return selectedItem ? `${itemToString(selectedItem)} has been selected.` : '' -} - /** * Debounced call for updating the a11y message. */ -const updateA11yStatus = debounce((getA11yMessage, document) => { - setStatus(getA11yMessage(), document) +const updateA11yStatus = debounce((status, document) => { + setStatus(status, document) }, 200) // istanbul ignore next @@ -262,7 +250,6 @@ const defaultProps = { return item }, stateReducer, - getA11ySelectionMessage, scrollIntoView, environment: /* istanbul ignore next (ssr) */ @@ -491,30 +478,42 @@ if (process.env.NODE_ENV !== 'production') { } } -function useA11yMessageSetter( - getA11yMessage, +/** + * Adds an a11y aria live status message if getA11yStatusMessage is passed. + * @param {(options: Object) => string} getA11yStatusMessage The function that builds the status message. + * @param {Object} options The options to be passed to getA11yStatusMessage if called. + * @param {Array} dependencyArray The dependency array that triggers the status message setter via useEffect. + * @param {{document: Document}} environment The environment object containing the document. + */ +function useA11yMessageStatus( + getA11yStatusMessage, + options, dependencyArray, - {highlightedIndex, items, environment, ...rest}, + environment = {}, ) { + const document = environment.document const isInitialMount = useIsInitialMount() - // Sets a11y status message on changes in state. + + // Adds an a11y aria live status message if getA11yStatusMessage is passed. useEffect(() => { - if (isInitialMount || isReactNative || !environment?.document) { + if (!getA11yStatusMessage || isInitialMount || isReactNative || !document) { return } - updateA11yStatus( - () => - getA11yMessage({ - highlightedIndex, - highlightedItem: items[highlightedIndex], - resultCount: items.length, - ...rest, - }), - environment.document, - ) + const status = getA11yStatusMessage(options) + + updateA11yStatus(status, document) + // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencyArray) + }, [dependencyArray]) + + // Cleanup the status message container. + useEffect(() => { + return () => { + updateA11yStatus.cancel() + cleanupStatusDiv(document) + } + }, [document]) } function useScrollIntoView({ @@ -673,7 +672,7 @@ const commonDropdownPropTypes = { export { useControlPropsValidator, useScrollIntoView, - useA11yMessageSetter, + updateA11yStatus, useGetterPropsCalledChecker, useMouseAndTouchTracker, getHighlightedIndexOnOpen, @@ -693,4 +692,5 @@ export { commonDropdownPropTypes, commonPropTypes, useIsInitialMount, + useA11yMessageStatus, } diff --git a/src/set-a11y-status.js b/src/set-a11y-status.js index 29edfa4bb..3beecf35b 100644 --- a/src/set-a11y-status.js +++ b/src/set-a11y-status.js @@ -38,7 +38,7 @@ function getStatusDiv(documentProp) { * @param {String} status the status message * @param {Object} documentProp document passed by the user. */ -export default function setStatus(status, documentProp) { +export function setStatus(status, documentProp) { if (!status || !documentProp) { return } @@ -48,3 +48,15 @@ export default function setStatus(status, documentProp) { div.textContent = status cleanupStatus(documentProp) } + +/** + * Removes the status element from the DOM + * @param {Document} documentProp + */ +export function cleanupStatusDiv(documentProp) { + const statusDiv = documentProp?.getElementById('a11y-status-message') + + if (statusDiv) { + statusDiv.remove() + } +} diff --git a/test/useCombobox.test.tsx b/test/useCombobox.test.tsx index b0752653c..08cd85286 100644 --- a/test/useCombobox.test.tsx +++ b/test/useCombobox.test.tsx @@ -32,12 +32,11 @@ export default function DropdownCombobox() { } = useCombobox({ items: inputItems, onInputValueChange: ({inputValue}) => { - inputValue !== undefined && - setInputItems( - colors.filter(item => - item.toLowerCase().startsWith(inputValue.toLowerCase()), - ), - ) + setInputItems( + colors.filter(item => + item.toLowerCase().startsWith(inputValue.toLowerCase()), + ), + ) }, }) return ( diff --git a/typings/index.d.ts b/typings/index.d.ts index 4fe905429..fcf810fa9 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -342,8 +342,7 @@ export interface UseSelectProps { isItemDisabled?(item: Item, index: number): boolean itemToString?: (item: Item | null) => string itemToKey?: (item: Item | null) => any - getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string - getA11ySelectionMessage?: (options: A11yStatusMessageOptions) => string + getA11yStatusMessage?: (options: UseSelectState) => string highlightedIndex?: number initialHighlightedIndex?: number defaultHighlightedIndex?: number @@ -363,9 +362,11 @@ export interface UseSelectProps { state: UseSelectState, actionAndChanges: UseSelectStateChangeOptions, ) => Partial> - onSelectedItemChange?: (changes: UseSelectStateChange) => void - onIsOpenChange?: (changes: UseSelectStateChange) => void - onHighlightedIndexChange?: (changes: UseSelectStateChange) => void + onSelectedItemChange?: (changes: UseSelectSelectedItemChange) => void + onIsOpenChange?: (changes: UseSelectIsOpenChange) => void + onHighlightedIndexChange?: ( + changes: UseSelectHighlightedIndexChange, + ) => void onStateChange?: (changes: UseSelectStateChange) => void environment?: Environment } @@ -390,6 +391,21 @@ export interface UseSelectStateChange type: UseSelectStateChangeTypes } +export interface UseSelectSelectedItemChange + extends UseSelectStateChange { + selectedItem: Item +} + +export interface UseSelectHighlightedIndexChange + extends UseSelectStateChange { + highlightedIndex: index +} + +export interface UseSelectIsOpenChange + extends UseSelectStateChange { + isOpen: boolean +} + export interface UseSelectGetMenuPropsOptions extends GetPropsWithRefKey, GetMenuPropsOptions {} @@ -539,9 +555,7 @@ export interface UseComboboxProps { isItemDisabled?(item: Item, index: number): boolean itemToString?: (item: Item | null) => string itemToKey?: (item: Item | null) => any - selectedItemChanged?: (prevItem: Item, item: Item) => boolean - getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string - getA11ySelectionMessage?: (options: A11yStatusMessageOptions) => string + getA11yStatusMessage?: (options: UseComboboxState) => string highlightedIndex?: number initialHighlightedIndex?: number defaultHighlightedIndex?: number @@ -565,11 +579,13 @@ export interface UseComboboxProps { state: UseComboboxState, actionAndChanges: UseComboboxStateChangeOptions, ) => Partial> - onSelectedItemChange?: (changes: UseComboboxStateChange) => void - onIsOpenChange?: (changes: UseComboboxStateChange) => void - onHighlightedIndexChange?: (changes: UseComboboxStateChange) => void + onSelectedItemChange?: (changes: UseComboboxSelectedItemChange) => void + onIsOpenChange?: (changes: UseComboboxIsOpenChange) => void + onHighlightedIndexChange?: ( + changes: UseComboboxHighlightedIndexChange, + ) => void onStateChange?: (changes: UseComboboxStateChange) => void - onInputValueChange?: (changes: UseComboboxStateChange) => void + onInputValueChange?: (changes: UseComboboxInputValueChange) => void environment?: Environment } @@ -593,6 +609,25 @@ export interface UseComboboxStateChange type: UseComboboxStateChangeTypes } +export interface UseComboboxSelectedItemChange + extends UseComboboxStateChange { + selectedItem: Item +} +export interface UseComboboxHighlightedIndexChange + extends UseComboboxStateChange { + highlightedIndex: index +} + +export interface UseComboboxIsOpenChange + extends UseComboboxStateChange { + isOpen: boolean +} + +export interface UseComboboxInputValueChange + extends UseComboboxStateChange { + inputValue: string +} + export interface UseComboboxGetMenuPropsOptions extends GetPropsWithRefKey, GetMenuPropsOptions {} @@ -736,9 +771,8 @@ export interface UseMultipleSelectionProps { selectedItems?: Item[] initialSelectedItems?: Item[] defaultSelectedItems?: Item[] - itemToString?: (item: Item) => string itemToKey?: (item: Item | null) => any - getA11yRemovalMessage?: (options: A11yRemovalMessage) => string + getA11yStatusMessage?: (options: UseMultipleSelectionState) => string stateReducer?: ( state: UseMultipleSelectionState, actionAndChanges: UseMultipleSelectionStateChangeOptions, @@ -746,9 +780,11 @@ export interface UseMultipleSelectionProps { activeIndex?: number initialActiveIndex?: number defaultActiveIndex?: number - onActiveIndexChange?: (changes: UseMultipleSelectionStateChange) => void + onActiveIndexChange?: ( + changes: UseMultipleSelectionActiveIndexChange, + ) => void onSelectedItemsChange?: ( - changes: UseMultipleSelectionStateChange, + changes: UseMultipleSelectionSelectedItemsChange, ) => void onStateChange?: (changes: UseMultipleSelectionStateChange) => void keyNavigationNext?: string @@ -774,6 +810,16 @@ export interface UseMultipleSelectionStateChange type: UseMultipleSelectionStateChangeTypes } +export interface UseMultipleSelectionActiveIndexChange + extends UseMultipleSelectionStateChange { + activeIndex: number +} + +export interface UseMultipleSelectionSelectedItemsChange + extends UseMultipleSelectionStateChange { + selectedItems: Item[] +} + export interface A11yRemovalMessage { itemToString: (item: Item) => string resultCount: number