diff --git a/cypress/e2e/useTagGroup.cy.js b/cypress/e2e/useTagGroup.cy.js new file mode 100644 index 000000000..ba636a691 --- /dev/null +++ b/cypress/e2e/useTagGroup.cy.js @@ -0,0 +1,84 @@ +describe('useTagGroup', () => { + const colors = ['Black', 'Red', 'Green', 'Blue', 'Orange'] + + beforeEach(() => { + cy.visit('/useTagGroup') + + // Ensure the listbox exists + cy.findByRole('listbox', {name: /colors example/i}).should('exist') + + // Ensure it has 5 color tags + cy.findAllByRole('option').should('have.length', 5) + }) + + it('clicks a tag and navigates with circular arrow keys', () => { + // Click first tag ("Black") + cy.findByRole('option', {name: /Black/i}).click().should('have.focus') + + // Arrow Right navigation through all tags + for (let index = 0; index < colors.length; index++) { + const nextIndex = (index + 1) % colors.length + cy.focused().trigger('keydown', {key: 'ArrowRight'}) + cy.findByRole('option', {name: colors[nextIndex]}).should('have.focus') + } + + // Arrow Left navigation through all tags (circular) + for (let index = colors.length - 1; index >= 0; index--) { + const prevIndex = (index + colors.length) % colors.length + cy.focused().trigger('keydown', {key: 'ArrowLeft'}) + cy.findByRole('option', {name: colors[prevIndex]}).should('have.focus') + } + + // Circular on the left. + cy.focused().trigger('keydown', {key: 'ArrowLeft'}) + cy.findByRole('option', {name: colors[colors.length - 1]}).should( + 'have.focus', + ) + }) + + it('deletes a tag using Delete and Backspace', () => { + // Focus "Red" + cy.findByRole('option', {name: /Red/i}).click() + + // Delete key + cy.focused().trigger('keydown', {key: 'Delete'}) + cy.findAllByRole('option').should('have.length', 4) + + // Next tag should be "Green" + cy.focused().should('contain.text', 'Green') + + // Backspace key removes "Green" + cy.focused().trigger('keydown', {key: 'Backspace'}) + cy.findAllByRole('option').should('have.length', 3) + + // Focus should now be on "Blue" + cy.focused().should('contain.text', 'Blue') + }) + + it('removes a tag via remove button', () => { + // Remove "Blue" via its remove button + cy.findByRole('option', {name: /Blue/i}).within(() => { + cy.findByRole('button', {name: /remove/i}).click() + }) + + // Verify 4 tags remain + cy.findAllByRole('option').should('have.length', 4) + + // Orange tag should have focus. + cy.findByRole('option', {name: /Orange/i}).should('have.focus') + }) + + it('adds a tag from the list', () => { + // Focus "Red" + cy.findByRole('option', {name: /Red/i}).click() + + // Clicks the Lime option from the add tags list. + cy.findByRole('button', {name: /Lime/i}).click() + + // Verify 6 tags are visible + cy.findAllByRole('option').should('have.length', 6) + + cy.findByRole('option', {name: /Lime/i}).should('be.visible') + // Including the new option + }) +}) diff --git a/docusaurus.config.js b/docusaurus.config.js index c0ab1ed5b..3ffe3494f 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -31,7 +31,7 @@ const config = { blog: false, pages: { path: 'docusaurus/pages', - include: ['**/*.{js,jsx}'], + include: ['**/*.{js,jsx,tsx}'], }, }), ], diff --git a/docusaurus/pages/index.js b/docusaurus/pages/index.js index e1409a344..1e3ae0060 100644 --- a/docusaurus/pages/index.js +++ b/docusaurus/pages/index.js @@ -20,6 +20,12 @@ export default function Docs() {
  • Downshift
  • +
  • + useTagGroup +
  • +
  • + useTagGroupCombobox +
  • ) diff --git a/docusaurus/pages/useMultipleCombobox.js b/docusaurus/pages/useMultipleCombobox.js index d33c79493..d63a71230 100644 --- a/docusaurus/pages/useMultipleCombobox.js +++ b/docusaurus/pages/useMultipleCombobox.js @@ -5,8 +5,8 @@ import { colors, containerStyles, menuStyles, - selectedItemsContainerSyles, - selectedItemStyles, + tagGroupSyles, + tagStyles, } from '../utils' const initialSelectedItems = [colors[0], colors[1]] @@ -105,14 +105,14 @@ export default function DropdownMultipleCombobox() { > Choose an element: -
    +
    {selectedItems.map(function renderSelectedItem( selectedItemForRender, index, ) { return ( Choose an element: -
    +
    {selectedItems.map(function renderSelectedItem( selectedItemForRender, index, ) { return ( !items.includes(color)) + + return ( +
    +
    + {items.map((color, index) => ( + + {color} + + + ))} +
    +
    Add more items:
    +
      + {itemsToAdd.map(item => ( +
    • + +
    • + ))} +
    +
    + ) +} diff --git a/docusaurus/pages/useTagGroupCombobox.css b/docusaurus/pages/useTagGroupCombobox.css new file mode 100644 index 000000000..6bda7be3b --- /dev/null +++ b/docusaurus/pages/useTagGroupCombobox.css @@ -0,0 +1,54 @@ +.wrapper { + width: 18rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wrapper label { + width: fit-content; +} + +.input-wrapper { + display: flex; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + background-color: white; + gap: 0.125rem; +} + +.text-input { + width: 100%; + padding: 0.375rem; +} + +.toggle-button { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.menu { + position: absolute; + width: 18rem; + background-color: white; + margin-top: 0.25rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 20rem; + overflow-y: scroll; + padding: 0; + z-index: 10; +} + +.menu.hidden { + display: none; +} + +.menu-item { + padding: 0.5rem 0.75rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; +} + +.menu-item.highlighted { + background-color: #93c5fd; +} diff --git a/docusaurus/pages/useTagGroupCombobox.tsx b/docusaurus/pages/useTagGroupCombobox.tsx new file mode 100644 index 000000000..33c1b5fbd --- /dev/null +++ b/docusaurus/pages/useTagGroupCombobox.tsx @@ -0,0 +1,115 @@ +import * as React from 'react' + +import {UseComboboxInterface} from '../../typings' +import {useTagGroup, useCombobox as useComboboxUntyped} from '../../src' +import {colors} from '../utils' + +import './useTagGroupCombobox.css' + +export default function TagGroup() { + const initialItems = colors.slice(0, 5) + const [inputValue, setInputValue] = React.useState('') + const { + addItem, + getTagProps, + getTagRemoveProps, + getTagGroupProps, + items, + activeIndex, + } = useTagGroup({initialItems}) + + const itemsToAdd = colors.filter( + color => + !items.includes(color) && + (!inputValue || color.toLowerCase().includes(inputValue.toLowerCase())), + ) + const useCombobox = useComboboxUntyped as UseComboboxInterface + + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + } = useCombobox({ + items: itemsToAdd, + inputValue, + onInputValueChange: changes => { + setInputValue(changes.inputValue) + }, + onSelectedItemChange(changes) { + if (changes.selectedItem) { + addItem(changes.selectedItem) + } + }, + selectedItem: null, + stateReducer(_state, actionAndChanges) { + const {changes} = actionAndChanges + + if (changes.selectedItem) { + return {...changes, inputValue: '', highlightedIndex: 0, isOpen: true} + } + + return changes + }, + }) + + return ( +
    +
    + {items.map((color, index) => ( + + {color} + + + ))} +
    +
    + +
    + + +
    +
    +
      + {isOpen + ? itemsToAdd.map((item, index) => ( +
    • + {item} +
    • + )) + : null} +
    +
    + ) +} diff --git a/docusaurus/tsconfig.json b/docusaurus/tsconfig.json new file mode 100644 index 000000000..9076e4fe4 --- /dev/null +++ b/docusaurus/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + }, + "include": ["./**/*.tsx*", "../typings/**/*.d.ts"] +} \ No newline at end of file diff --git a/docusaurus/utils.js b/docusaurus/utils.ts similarity index 56% rename from docusaurus/utils.js rename to docusaurus/utils.ts index 72832df21..6f0b35229 100644 --- a/docusaurus/utils.js +++ b/docusaurus/utils.ts @@ -1,3 +1,5 @@ +import {type CSSProperties} from 'react' + export const colors = [ 'Black', 'Red', @@ -15,7 +17,7 @@ export const colors = [ 'Skyblue', ] -export const menuStyles = { +export const menuStyles: CSSProperties = { listStyle: 'none', width: '100%', padding: '0', @@ -24,7 +26,7 @@ export const menuStyles = { overflowY: 'scroll', } -export const containerStyles = { +export const containerStyles: CSSProperties = { display: 'flex', flexDirection: 'column', width: 'fit-content', @@ -33,7 +35,7 @@ export const containerStyles = { alignSelf: 'center', } -export const selectedItemsContainerSyles = { +export const tagGroupSyles: CSSProperties = { display: 'inline-flex', gap: '8px', alignItems: 'center', @@ -41,9 +43,15 @@ export const selectedItemsContainerSyles = { padding: '6px', } -export const selectedItemStyles = { - backgroundColor: 'lightgray', - paddingLeft: '4px', - paddingRight: '4px', - borderRadius: '6px', +export const tagStyles: CSSProperties = { + backgroundColor: 'green', + padding: '0 6px', + margin: '0 2px', + borderRadius: '10px', + cursor: 'auto', +} + +export const removeTagStyles: CSSProperties = { + padding: '4px', + cursor: 'pointer', } diff --git a/package.json b/package.json index 3d3d431c3..35ddd3761 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "react-native": "dist/downshift.native.cjs.js", "module": "dist/downshift.esm.js", "typings": "typings/index.d.ts", + "types": "typings/index.d.ts", "sideEffects": false, "browserslist": [], "scripts": { @@ -97,7 +98,6 @@ "@mdx-js/react": "^3.0.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.7", - "@rollup/plugin-typescript": "^11.1.6", "@testing-library/cypress": "^10.0.1", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.4.5", @@ -137,7 +137,11 @@ "eslintConfig": { "parserOptions": { "ecmaVersion": 2023, - "project": "./tsconfig.json", + "project": [ + "./tsconfig.json", + "./docusaurus/tsconfig.json", + "./test/tsconfig.json" + ], "sourceType": "module" }, "settings": { diff --git a/rollup.config.js b/rollup.config.js index e7c82b011..07f749474 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,31 +1,30 @@ const commonjs = require('@rollup/plugin-commonjs') const {babel} = require('@rollup/plugin-babel') -const typescript = require('@rollup/plugin-typescript') const config = require('kcd-scripts/dist/config/rollup.config') -const babelPluginIndex = config.plugins.findIndex( - plugin => plugin.name === 'babel', -) -const typescriptPluginIndex = config.plugins.findIndex( - plugin => plugin.name === 'typescript', -) -const cjsPluginIndex = config.plugins.findIndex( - plugin => plugin.name === 'commonjs', -) -config.plugins[babelPluginIndex] = babel({ +const babelPlugin = babel({ babelHelpers: 'runtime', + extensions: ['.js', '.jsx', '.ts', '.tsx'], exclude: '**/node_modules/**', }) -config.plugins[cjsPluginIndex] = commonjs({ - include: 'node_modules/**', -}) +const cjsPlugin = commonjs({include: 'node_modules/**'}) + +config.plugins = [ + babelPlugin, + cjsPlugin, + ...config.plugins.filter( + p => !['babel', 'typescript', 'commonjs'].includes(p.name), + ), +] -if (typescriptPluginIndex === -1) { - config.plugins.push(typescript({tsconfig: 'tsconfig.json'})) -} else { - config.plugins[typescriptPluginIndex] = typescript({ - tsconfig: 'tsconfig.json', - }) +const prevExternal = config.external +config.external = id => { + if (id.includes('productionEnum.macro') || id.includes('is.macro')) { + return true + } + if (typeof prevExternal === 'function') return prevExternal(id) + if (Array.isArray(prevExternal)) return prevExternal.includes(id) + return false } module.exports = config diff --git a/src/__tests__/downshift.aria.js b/src/__tests__/downshift.aria.js index 19d6451d9..412554857 100644 --- a/src/__tests__/downshift.aria.js +++ b/src/__tests__/downshift.aria.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '../' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' beforeEach(() => { if (!('useId' in React)) resetIdCounter() diff --git a/src/__tests__/downshift.get-item-props.js b/src/__tests__/downshift.get-item-props.js index fbd028ef0..cb3058eb4 100644 --- a/src/__tests__/downshift.get-item-props.js +++ b/src/__tests__/downshift.get-item-props.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, fireEvent, screen} from '@testing-library/react' import Downshift from '../' -import {setIdCounter} from '../utils' +import {setIdCounter} from '../utils-ts' beforeEach(() => { setIdCounter(1) diff --git a/src/__tests__/downshift.lifecycle.js b/src/__tests__/downshift.lifecycle.js index dfd3782ea..95c209f88 100644 --- a/src/__tests__/downshift.lifecycle.js +++ b/src/__tests__/downshift.lifecycle.js @@ -1,21 +1,19 @@ import * as React from 'react' + import {act, fireEvent, render, screen} from '@testing-library/react' import Downshift from '../' -import {setStatus} from '../set-a11y-status' -import * as utils from '../utils' +import {setStatus, scrollIntoView} from '../utils-ts' jest.useFakeTimers() -jest.mock('../set-a11y-status') -jest.mock('../utils', () => { - const realUtils = jest.requireActual('../utils') - return { - ...realUtils, - scrollIntoView: jest.fn(), - } -}) +jest.mock('../utils-ts/scrollIntoView.ts', () => ({ + scrollIntoView: jest.fn(), +})) +jest.mock('../utils-ts/setA11yStatus.ts', () => ({ + setStatus: jest.fn(), +})) afterEach(() => { - utils.scrollIntoView.mockReset() + scrollIntoView.mockReset() }) test('do not set state after unmount', () => { @@ -248,9 +246,9 @@ test('controlled highlighted index change scrolls the item into view', () => { updateProps({highlightedIndex: 75}) expect(renderFn).toHaveBeenCalledTimes(1) - expect(utils.scrollIntoView).toHaveBeenCalledTimes(1) + expect(scrollIntoView).toHaveBeenCalledTimes(1) const menuDiv = screen.queryByTestId('menu') - expect(utils.scrollIntoView).toHaveBeenCalledWith( + expect(scrollIntoView).toHaveBeenCalledWith( screen.queryByTestId('item-75'), menuDiv, ) diff --git a/src/__tests__/downshift.misc-with-utils-mocked.js b/src/__tests__/downshift.misc-with-utils-mocked.js index b5d7dfd9e..6407476cd 100644 --- a/src/__tests__/downshift.misc-with-utils-mocked.js +++ b/src/__tests__/downshift.misc-with-utils-mocked.js @@ -4,10 +4,12 @@ import * as React from 'react' import {render, fireEvent, screen} from '@testing-library/react' import Downshift from '../' -import {scrollIntoView} from '../utils' +import {scrollIntoView} from '../utils-ts' jest.useFakeTimers() -jest.mock('../utils') +jest.mock('../utils-ts/scrollIntoView.ts', () => ({ + scrollIntoView: jest.fn(), +})) test('does not scroll from an onMouseMove event', () => { class HighlightedIndexController extends React.Component { diff --git a/src/__tests__/set-a11y-status.js b/src/__tests__/set-a11y-status.js index 1fb3b013d..c751143f3 100644 --- a/src/__tests__/set-a11y-status.js +++ b/src/__tests__/set-a11y-status.js @@ -71,7 +71,6 @@ test('creates new status div if there is none', () => { expect(statusDiv.textContent).toEqual('hello') }) - test('creates no status div if there is no document', () => { const setA11yStatus = setup() setA11yStatus('') @@ -80,5 +79,5 @@ test('creates no status div if there is no document', () => { function setup() { jest.resetModules() - return require('../set-a11y-status').setStatus + return require('../utils-ts').setStatus } diff --git a/src/__tests__/utils.reset-id-counter.js b/src/__tests__/utils.reset-id-counter.js index be8b6f8da..8daaf2fca 100644 --- a/src/__tests__/utils.reset-id-counter.js +++ b/src/__tests__/utils.reset-id-counter.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '../' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' jest.mock('react', () => { const {useId, ...react} = jest.requireActual('react') diff --git a/src/__tests__/utils.reset-id-counter.r18.js b/src/__tests__/utils.reset-id-counter.r18.js index 6f3e3aa3a..abe49db1b 100644 --- a/src/__tests__/utils.reset-id-counter.r18.js +++ b/src/__tests__/utils.reset-id-counter.r18.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen} from '@testing-library/react' import Downshift from '..' -import {resetIdCounter} from '../utils' +import {resetIdCounter} from '../utils-ts' afterAll(() => { jest.restoreAllMocks() diff --git a/src/__tests__/utils.scroll-into-view.js b/src/__tests__/utils.scroll-into-view.js index dc89dc310..13780032c 100644 --- a/src/__tests__/utils.scroll-into-view.js +++ b/src/__tests__/utils.scroll-into-view.js @@ -1,4 +1,4 @@ -import {scrollIntoView} from '../utils' +import {scrollIntoView} from '../utils-ts' test('does not throw with a null node', () => { expect(() => scrollIntoView(null)).not.toThrow() diff --git a/src/downshift.js b/src/downshift.js index 49bdda0f6..0b78b5ecf 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -4,31 +4,27 @@ import PropTypes from 'prop-types' import {Component, cloneElement} from 'react' import {isForwardRef} from 'react-is' import {isPreact, isReactNative, isReactNativeWeb} from './is.macro' -import {setStatus} from './set-a11y-status' import * as stateChangeTypes from './stateChangeTypes' import { handleRefs, callAllEventHandlers, cbToCb, debounce, - generateId, getA11yStatusMessage, getElementProps, isDOMElement, targetWithinDownshift, isPlainObject, - noop, normalizeArrowKey, pickState, requiredProp, - scrollIntoView, unwrapArray, - getState, isControlledProp, validateControlledUnchanged, getHighlightedIndex, getNonDisabledIndex, } from './utils' +import {generateId, scrollIntoView, setStatus, getState, noop} from './utils-ts' class Downshift extends Component { static propTypes = { diff --git a/src/hooks/__tests__/utils.test.js b/src/hooks/__tests__/utils.test.js index 204e2b38b..ecab211d4 100644 --- a/src/hooks/__tests__/utils.test.js +++ b/src/hooks/__tests__/utils.test.js @@ -1,13 +1,11 @@ import {renderHook} from '@testing-library/react' import { - defaultProps, - getInitialValue, - getDefaultValue, useMouseAndTouchTracker, - getItemAndIndex, isDropdownsStateEqual, useElementIds, } from '../utils' +import {getInitialValue, getDefaultValue, getItemAndIndex} from '../utils-ts' +import {dropdownDefaultProps} from '../utils.dropdown' describe('utils', () => { describe('useElementIds', () => { @@ -63,7 +61,7 @@ describe('utils', () => { describe('itemToString', () => { test('returns empty string if item is falsy', () => { - const emptyString = defaultProps.itemToString(null) + const emptyString = dropdownDefaultProps.itemToString(null) expect(emptyString).toBe('') }) }) diff --git a/src/hooks/__tests__/utils.use-element-ids.r18.test.js b/src/hooks/__tests__/utils.use-element-ids.r18.test.js index d05e435d3..2e76f231b 100644 --- a/src/hooks/__tests__/utils.use-element-ids.r18.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.r18.test.js @@ -1,5 +1,5 @@ -const {renderHook} = require('@testing-library/react') -const {useElementIds} = require('../utils') +import {renderHook} from '@testing-library/react' +import {useElementIds} from '../utils' jest.mock('react', () => { return { @@ -21,5 +21,6 @@ describe('useElementIds', () => { menuId: 'downshift-mocked-id-menu', toggleButtonId: 'downshift-mocked-id-toggle-button', }) + expect(result.current.getItemId(5)).toEqual('downshift-mocked-id-item-5') }) }) diff --git a/src/hooks/__tests__/utils.use-element-ids.test.js b/src/hooks/__tests__/utils.use-element-ids.test.js index b7a4f557f..bbc58cc9b 100644 --- a/src/hooks/__tests__/utils.use-element-ids.test.js +++ b/src/hooks/__tests__/utils.use-element-ids.test.js @@ -1,19 +1,14 @@ -const {renderHook} = require('@testing-library/react') -const {useElementIds} = require('../utils') +import {renderHook} from '@testing-library/react' +import {useElementIds} from '../utils' jest.mock('react', () => { const {useId, ...react} = jest.requireActual('react') return react }) -jest.mock('../../utils', () => { - const downshiftUtils = jest.requireActual('../../utils') - - return { - ...downshiftUtils, - generateId: () => 'test-id', - } -}) +jest.mock('../../utils-ts/generateId.ts', () => ({ + generateId: jest.fn().mockReturnValue('test-id'), +})) describe('useElementIds', () => { test('uses React.useId for React < 18', () => { @@ -26,5 +21,6 @@ describe('useElementIds', () => { menuId: 'downshift-test-id-menu', toggleButtonId: 'downshift-test-id-toggle-button', }) + expect(result.current.getItemId(5)).toEqual("downshift-test-id-item-5") }) }) diff --git a/src/hooks/index.js b/src/hooks/index.ts similarity index 76% rename from src/hooks/index.js rename to src/hooks/index.ts index a1bb13600..c72d76376 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ export {default as useSelect} from './useSelect' export {default as useCombobox} from './useCombobox' export {default as useMultipleSelection} from './useMultipleSelection' +export {default as useTagGroup} from './useTagGroup' diff --git a/src/hooks/reducer.js b/src/hooks/reducer.js index 62117c018..c1ec2f8e5 100644 --- a/src/hooks/reducer.js +++ b/src/hooks/reducer.js @@ -1,15 +1,14 @@ -import { - getHighlightedIndexOnOpen, - getDefaultValue, - getDefaultHighlightedIndex, -} from './utils' +import {getHighlightedIndexOnOpen, getDefaultHighlightedIndex} from './utils' +import {getDefaultValue} from './utils-ts' +import {dropdownDefaultStateValues} from './utils.dropdown' export default function downshiftCommonReducer( state, + props, action, stateChangeTypes, ) { - const {type, props} = action + const {type} = action let changes switch (type) { @@ -68,9 +67,17 @@ export default function downshiftCommonReducer( case stateChangeTypes.FunctionReset: changes = { highlightedIndex: getDefaultHighlightedIndex(props), - isOpen: getDefaultValue(props, 'isOpen'), - selectedItem: getDefaultValue(props, 'selectedItem'), - inputValue: getDefaultValue(props, 'inputValue'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + selectedItem: getDefaultValue( + props, + 'selectedItem', + dropdownDefaultStateValues, + ), + inputValue: getDefaultValue( + props, + 'inputValue', + dropdownDefaultStateValues, + ), } break diff --git a/src/hooks/useCombobox/__tests__/getInputProps.test.js b/src/hooks/useCombobox/__tests__/getInputProps.test.js index 50e55b0e7..187fcbf6d 100644 --- a/src/hooks/useCombobox/__tests__/getInputProps.test.js +++ b/src/hooks/useCombobox/__tests__/getInputProps.test.js @@ -1,7 +1,7 @@ import * as React from 'react' import {act, renderHook, fireEvent, createEvent} from '@testing-library/react' import * as stateChangeTypes from '../stateChangeTypes' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import { renderUseCombobox, renderCombobox, diff --git a/src/hooks/useCombobox/__tests__/getItemProps.test.js b/src/hooks/useCombobox/__tests__/getItemProps.test.js index d0472a934..e933bc63d 100644 --- a/src/hooks/useCombobox/__tests__/getItemProps.test.js +++ b/src/hooks/useCombobox/__tests__/getItemProps.test.js @@ -394,7 +394,7 @@ describe('getItemProps', () => { test('will be displayed if getInputProps is not called', () => { renderHook(() => { const {getItemProps} = useCombobox({items}) - getItemProps({disabled: true, index: 98}) + getItemProps({disabled: true, index: 1}) }) expect(console.warn.mock.calls[0][0]).toMatchInlineSnapshot( diff --git a/src/hooks/useCombobox/__tests__/getMenuProps.test.js b/src/hooks/useCombobox/__tests__/getMenuProps.test.js index 36437c7ba..d723ea383 100644 --- a/src/hooks/useCombobox/__tests__/getMenuProps.test.js +++ b/src/hooks/useCombobox/__tests__/getMenuProps.test.js @@ -1,5 +1,5 @@ import {act, renderHook} from '@testing-library/react' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import {getInput, renderCombobox, renderUseCombobox} from '../testUtils' import { defaultIds, diff --git a/src/hooks/useCombobox/__tests__/utils.test.js b/src/hooks/useCombobox/__tests__/utils.test.js index 8a27b1745..e11414adc 100644 --- a/src/hooks/useCombobox/__tests__/utils.test.js +++ b/src/hooks/useCombobox/__tests__/utils.test.js @@ -3,7 +3,7 @@ import reducer from '../reducer' describe('utils', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {type: 'super-bogus'}) + reducer({}, {}, {type: 'super-bogus'}) }).toThrowError('Reducer called without proper action type.') }) }) diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index e10c162ee..1d5370903 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -1,24 +1,27 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {isPreact, isReactNative, isReactNativeWeb} from '../../is.macro' import {handleRefs, normalizeArrowKey, callAllEventHandlers} from '../../utils' +import {useLatestRef, validatePropTypes} from '../../utils-ts' import { useMouseAndTouchTracker, useGetterPropsCalledChecker, - useLatestRef, useScrollIntoView, useControlPropsValidator, useElementIds, + isDropdownsStateEqual, +} from '../utils' +import { getItemAndIndex, getInitialValue, - isDropdownsStateEqual, useIsInitialMount, useA11yMessageStatus, -} from '../utils' +} from '../utils-ts' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import { getInitialState, defaultProps, useControlledReducer, - validatePropTypes, + propTypes, } from './utils' import downshiftUseComboboxReducer from './reducer' import * as stateChangeTypes from './stateChangeTypes' @@ -26,7 +29,7 @@ import * as stateChangeTypes from './stateChangeTypes' useCombobox.stateChangeTypes = stateChangeTypes function useCombobox(userProps = {}) { - validatePropTypes(userProps, useCombobox) + validatePropTypes(userProps, useCombobox, propTypes) // Props defaults and destructuring. const props = { ...defaultProps, @@ -84,7 +87,7 @@ function useCombobox(userProps = {}) { }) // Focus the input on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen') + const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) if (focusOnOpen && inputRef.current) { inputRef.current.focus() diff --git a/src/hooks/useCombobox/reducer.js b/src/hooks/useCombobox/reducer.js index 065df4974..3e97c3633 100644 --- a/src/hooks/useCombobox/reducer.js +++ b/src/hooks/useCombobox/reducer.js @@ -1,22 +1,23 @@ import { getHighlightedIndexOnOpen, - getDefaultValue, getChangesOnSelection, getDefaultHighlightedIndex, } from '../utils' +import {getDefaultValue} from '../utils-ts' import {getHighlightedIndex, getNonDisabledIndex} from '../../utils' import commonReducer from '../reducer' +import {dropdownDefaultStateValues} from '../utils.dropdown' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftUseComboboxReducer(state, action) { - const {type, props, altKey} = action +export default function downshiftUseComboboxReducer(state, props, action) { + const {type, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], inputValue: props.itemToString(props.items[action.index]), @@ -161,7 +162,7 @@ export default function downshiftUseComboboxReducer(state, action) { } break default: - return commonReducer(state, action, stateChangeTypes) + return commonReducer(state, props, action, stateChangeTypes) } return { diff --git a/src/hooks/useCombobox/testUtils.js b/src/hooks/useCombobox/testUtils.js index a0f09befb..3e86ac840 100644 --- a/src/hooks/useCombobox/testUtils.js +++ b/src/hooks/useCombobox/testUtils.js @@ -1,6 +1,6 @@ import * as React from 'react' import {render, screen, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import {dataTestIds, items, user} from '../testUtils' import useCombobox from '.' @@ -18,7 +18,7 @@ jest.mock('react', () => { jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, @@ -76,7 +76,7 @@ function DropdownCombobox({renderSpy, renderItem, ...props}) { getInputProps, getItemProps, } = useCombobox({items, ...props}) - const {itemToString} = props.itemToString ? props : defaultProps + const {itemToString} = props.itemToString ? props : dropdownDefaultProps renderSpy() diff --git a/src/hooks/useCombobox/utils.js b/src/hooks/useCombobox/utils.js index 290c0bd28..884e10fcf 100644 --- a/src/hooks/useCombobox/utils.js +++ b/src/hooks/useCombobox/utils.js @@ -1,13 +1,10 @@ import {useRef, useEffect} from 'react' import PropTypes from 'prop-types' -import {isControlledProp, getState, noop} from '../../utils' -import { - commonDropdownPropTypes, - defaultProps as defaultPropsCommon, - getInitialState as getInitialStateCommon, - useEnhancedReducer, - useIsInitialMount, -} from '../utils' +import {isControlledProp} from '../../utils' +import {getState} from '../../utils-ts' +import {getInitialState as getInitialStateCommon} from '../utils' +import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' +import {useIsInitialMount, useEnhancedReducer} from '../utils-ts' import {ControlledPropUpdatedSelectedItem} from './stateChangeTypes' export function getInitialState(props) { @@ -31,8 +28,8 @@ export function getInitialState(props) { } } -const propTypes = { - ...commonDropdownPropTypes, +export const propTypes = { + ...dropdownPropTypes, items: PropTypes.array.isRequired, isItemDisabled: PropTypes.func, inputValue: PropTypes.string, @@ -100,17 +97,8 @@ export function useControlledReducer( return [getState(state, props), dispatch] } -// eslint-disable-next-line import/no-mutable-exports -export let validatePropTypes = noop -/* istanbul ignore next */ -if (process.env.NODE_ENV !== 'production') { - validatePropTypes = (options, caller) => { - PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) - } -} - export const defaultProps = { - ...defaultPropsCommon, + ...dropdownDefaultProps, isItemDisabled() { return false }, diff --git a/src/hooks/useMultipleSelection/__tests__/props.test.js b/src/hooks/useMultipleSelection/__tests__/props.test.js index d9b647183..659c5c361 100644 --- a/src/hooks/useMultipleSelection/__tests__/props.test.js +++ b/src/hooks/useMultipleSelection/__tests__/props.test.js @@ -92,7 +92,9 @@ describe('props', () => { expect(getA11yStatusMessage).toHaveBeenCalledTimes(1) getA11yStatusMessage.mockClear() - rerender({multipleSelectionProps: {...multipleSelectionProps, activeIndex: 0}}) + rerender({ + multipleSelectionProps: {...multipleSelectionProps, activeIndex: 0}, + }) expect(getA11yStatusMessage).not.toHaveBeenCalled() }) @@ -587,6 +589,7 @@ describe('props', () => { onActiveIndexChange: () => { result.current.setSelectedItems([items[0]]) }, + initialSelectedItems: items, }) act(() => { diff --git a/src/hooks/useMultipleSelection/__tests__/utils.test.js b/src/hooks/useMultipleSelection/__tests__/utils.test.js index 8a27b1745..e11414adc 100644 --- a/src/hooks/useMultipleSelection/__tests__/utils.test.js +++ b/src/hooks/useMultipleSelection/__tests__/utils.test.js @@ -3,7 +3,7 @@ import reducer from '../reducer' describe('utils', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {type: 'super-bogus'}) + reducer({}, {}, {type: 'super-bogus'}) }).toThrowError('Reducer called without proper action type.') }) }) diff --git a/src/hooks/useMultipleSelection/index.js b/src/hooks/useMultipleSelection/index.js index 1bdea917b..8a668ee80 100644 --- a/src/hooks/useMultipleSelection/index.js +++ b/src/hooks/useMultipleSelection/index.js @@ -1,14 +1,13 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' import {handleRefs, callAllEventHandlers, normalizeArrowKey} from '../../utils' +import {useLatestRef} from '../../utils-ts' +import {useGetterPropsCalledChecker, useControlPropsValidator} from '../utils' import { useControlledReducer, - useGetterPropsCalledChecker, - useLatestRef, - useControlPropsValidator, - getItemAndIndex, useIsInitialMount, useA11yMessageStatus, -} from '../utils' + getItemAndIndex, +} from '../utils-ts' import { getInitialState, defaultProps, diff --git a/src/hooks/useMultipleSelection/reducer.js b/src/hooks/useMultipleSelection/reducer.js index cc8ba2116..8c22cdd04 100644 --- a/src/hooks/useMultipleSelection/reducer.js +++ b/src/hooks/useMultipleSelection/reducer.js @@ -2,8 +2,12 @@ import {getDefaultValue} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftMultipleSelectionReducer(state, action) { - const {type, index, props, selectedItem} = action +export default function downshiftMultipleSelectionReducer( + state, + props, + action, +) { + const {type, index, selectedItem} = action const {activeIndex, selectedItems} = state let changes diff --git a/src/hooks/useMultipleSelection/testUtils.js b/src/hooks/useMultipleSelection/testUtils.js index cd648603f..e9c4a9ffc 100644 --- a/src/hooks/useMultipleSelection/testUtils.js +++ b/src/hooks/useMultipleSelection/testUtils.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, screen, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import {items, user, dataTestIds} from '../testUtils' import useCombobox from '../useCombobox' import {getInput, keyDownOnInput} from '../useCombobox/testUtils' @@ -22,7 +22,7 @@ jest.mock('react', () => { jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, @@ -73,7 +73,7 @@ const DropdownMultipleCombobox = ({ items, ...comboboxProps, }) - const {itemToString} = defaultProps + const {itemToString} = dropdownDefaultProps return (
    diff --git a/src/hooks/useMultipleSelection/utils.js b/src/hooks/useMultipleSelection/utils.js index 024455772..3deb27e45 100644 --- a/src/hooks/useMultipleSelection/utils.js +++ b/src/hooks/useMultipleSelection/utils.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types' + +import {noop} from '../../utils-ts' import { getInitialValue as getInitialValueCommon, getDefaultValue as getDefaultValueCommon, - defaultProps as defaultPropsCommon, - commonPropTypes, -} from '../utils' -import {noop} from '../../utils' +} from '../utils-ts' +import {dropdownDefaultProps, dropdownPropTypes} from '../utils.dropdown' const defaultStateValues = { activeIndex: -1, @@ -98,9 +98,9 @@ function isStateEqual(prevState, newState) { } const propTypes = { - stateReducer: commonPropTypes.stateReducer, - itemToKey: commonPropTypes.itemToKey, - environment: commonPropTypes.environment, + stateReducer: dropdownPropTypes.stateReducer, + itemToKey: dropdownPropTypes.itemToKey, + environment: dropdownPropTypes.environment, selectedItems: PropTypes.array, initialSelectedItems: PropTypes.array, defaultSelectedItems: PropTypes.array, @@ -115,9 +115,9 @@ const propTypes = { } export const defaultProps = { - itemToKey: defaultPropsCommon.itemToKey, - stateReducer: defaultPropsCommon.stateReducer, - environment: defaultPropsCommon.environment, + itemToKey: dropdownDefaultProps.itemToKey, + stateReducer: dropdownDefaultProps.stateReducer, + environment: dropdownDefaultProps.environment, keyNavigationNext: 'ArrowRight', keyNavigationPrevious: 'ArrowLeft', } diff --git a/src/hooks/useSelect/__tests__/getItemProps.test.js b/src/hooks/useSelect/__tests__/getItemProps.test.js index 1231eedc9..9f743ce99 100644 --- a/src/hooks/useSelect/__tests__/getItemProps.test.js +++ b/src/hooks/useSelect/__tests__/getItemProps.test.js @@ -422,7 +422,7 @@ describe('getItemProps', () => { test('will be displayed if getInputProps is not called', () => { renderHook(() => { const {getItemProps} = useSelect({items}) - getItemProps({disabled: true, index: 99}) + getItemProps({disabled: true, index: 9}) }) expect(console.warn.mock.calls[0][0]).toMatchInlineSnapshot( diff --git a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js index ee240d152..82637c484 100644 --- a/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js +++ b/src/hooks/useSelect/__tests__/getToggleButtonProps.test.js @@ -6,7 +6,7 @@ import { screen, renderHook, } from '@testing-library/react' -import {noop} from '../../../utils' +import {noop} from '../../../utils-ts' import { renderUseSelect, renderSelect, @@ -65,7 +65,7 @@ describe('getToggleButtonProps', () => { expect(toggleButtonProps.id).toEqual(props.toggleButtonId) }) - test("assign 'listbbox' to aria-haspopup", () => { + test("assign 'listbox' to aria-haspopup", () => { const {result} = renderUseSelect() const toggleButtonProps = result.current.getToggleButtonProps() diff --git a/src/hooks/useSelect/__tests__/utils.test.ts b/src/hooks/useSelect/__tests__/utils.test.ts index 982a8bb03..c148daf1f 100644 --- a/src/hooks/useSelect/__tests__/utils.test.ts +++ b/src/hooks/useSelect/__tests__/utils.test.ts @@ -59,6 +59,6 @@ describe('getItemIndexByCharacterKey', () => { test('reducer throws error if called without proper action type', () => { expect(() => { - reducer({}, {type: 'super-bogus'}) + reducer({}, {}, {type: 'super-bogus'}) }).toThrowError('Reducer called without proper action type.') }) diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js index 5b7d37f38..be24372d4 100644 --- a/src/hooks/useSelect/index.js +++ b/src/hooks/useSelect/index.js @@ -1,34 +1,37 @@ import {useRef, useEffect, useCallback, useMemo} from 'react' +import {useLatestRef, validatePropTypes} from '../../utils-ts' +import { + callAllEventHandlers, + handleRefs, + debounce, + normalizeArrowKey, +} from '../../utils' import { isAcceptedCharacterKey, - useControlledReducer, getInitialState, useGetterPropsCalledChecker, - useLatestRef, useScrollIntoView, useControlPropsValidator, useElementIds, useMouseAndTouchTracker, - getItemAndIndex, - getInitialValue, isDropdownsStateEqual, - useA11yMessageStatus, } from '../utils' import { - callAllEventHandlers, - handleRefs, - debounce, - normalizeArrowKey, -} from '../../utils' + useControlledReducer, + getInitialValue, + getItemAndIndex, + useA11yMessageStatus, +} from '../utils-ts' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import {isReactNative, isReactNativeWeb} from '../../is.macro' import downshiftSelectReducer from './reducer' -import {validatePropTypes, defaultProps} from './utils' +import {defaultProps, propTypes} from './utils' import * as stateChangeTypes from './stateChangeTypes' useSelect.stateChangeTypes = stateChangeTypes function useSelect(userProps = {}) { - validatePropTypes(userProps, useSelect) + validatePropTypes(userProps, useSelect, propTypes) // Props defaults and destructuring. const props = { ...defaultProps, @@ -111,7 +114,7 @@ function useSelect(userProps = {}) { }) // Focus the toggle button on first render if required. useEffect(() => { - const focusOnOpen = getInitialValue(props, 'isOpen') + const focusOnOpen = getInitialValue(props, 'isOpen', defaultStateValues) if (focusOnOpen && toggleButtonRef.current) { toggleButtonRef.current.focus() diff --git a/src/hooks/useSelect/reducer.js b/src/hooks/useSelect/reducer.js index 380818bd8..8203ac4c9 100644 --- a/src/hooks/useSelect/reducer.js +++ b/src/hooks/useSelect/reducer.js @@ -1,23 +1,24 @@ import {getNonDisabledIndex, getHighlightedIndex} from '../../utils' import { getHighlightedIndexOnOpen, - getDefaultValue, getChangesOnSelection, getDefaultHighlightedIndex, } from '../utils' +import {getDefaultValue} from '../utils-ts' import commonReducer from '../reducer' +import {defaultStateValues} from '../utils.dropdown/defaultStateValues' import {getItemIndexByCharacterKey} from './utils' import * as stateChangeTypes from './stateChangeTypes' /* eslint-disable complexity */ -export default function downshiftSelectReducer(state, action) { - const {type, props, altKey} = action +export default function downshiftSelectReducer(state, props, action) { + const {type, altKey} = action let changes switch (type) { case stateChangeTypes.ItemClick: changes = { - isOpen: getDefaultValue(props, 'isOpen'), + isOpen: getDefaultValue(props, 'isOpen', defaultStateValues), highlightedIndex: getDefaultHighlightedIndex(props), selectedItem: props.items[action.index], } @@ -163,7 +164,7 @@ export default function downshiftSelectReducer(state, action) { break default: - return commonReducer(state, action, stateChangeTypes) + return commonReducer(state, props, action, stateChangeTypes) } return { diff --git a/src/hooks/useSelect/testUtils.js b/src/hooks/useSelect/testUtils.js index a473077e5..8d5a37b5a 100644 --- a/src/hooks/useSelect/testUtils.js +++ b/src/hooks/useSelect/testUtils.js @@ -1,6 +1,6 @@ import * as React from 'react' import {render, act, renderHook} from '@testing-library/react' -import {defaultProps} from '../utils' +import {dropdownDefaultProps} from '../utils.dropdown' import { clickOnItemAtIndex, clickOnToggleButton, @@ -18,7 +18,7 @@ export * from '../testUtils' jest.mock('../utils', () => { const utils = jest.requireActual('../utils') - const hooksUtils = jest.requireActual('../../utils') + const hooksUtils = jest.requireActual('../../utils-ts') return { ...utils, @@ -65,7 +65,7 @@ export function DropdownSelect({renderSpy, renderItem, ...props}) { getMenuProps, getItemProps, } = useSelect({items, ...props}) - const itemToString = props?.itemToString ?? defaultProps.itemToString + const itemToString = props?.itemToString ?? dropdownDefaultProps.itemToString renderSpy() diff --git a/src/hooks/useSelect/utils.ts b/src/hooks/useSelect/utils.ts deleted file mode 100644 index ede313748..000000000 --- a/src/hooks/useSelect/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types' -import { - commonDropdownPropTypes, - defaultProps as commonDefaultProps, -} from '../utils' -import {noop} from '../../utils' -import {GetItemIndexByCharacterKeyOptions} from './types' - -export function getItemIndexByCharacterKey({ - keysSoFar, - highlightedIndex, - items, - itemToString, - isItemDisabled, -}: GetItemIndexByCharacterKeyOptions) { - const lowerCasedKeysSoFar = keysSoFar.toLowerCase() - - for (let index = 0; index < items.length; index++) { - // if we already have a search query in progress, we also consider the current highlighted item. - const offsetIndex = - (index + highlightedIndex + (keysSoFar.length < 2 ? 1 : 0)) % items.length - - const item = items[offsetIndex] - - if ( - item !== undefined && - itemToString(item).toLowerCase().startsWith(lowerCasedKeysSoFar) && - !isItemDisabled(item, offsetIndex) - ) { - return offsetIndex - } - } - - return highlightedIndex -} - -const propTypes = { - ...commonDropdownPropTypes, - items: PropTypes.array.isRequired, - isItemDisabled: PropTypes.func, -} - -export const defaultProps = { - ...commonDefaultProps, - isItemDisabled() { - return false - }, -} - -// eslint-disable-next-line import/no-mutable-exports -export let validatePropTypes = noop as ( - options: unknown, - caller: Function, -) => void -/* istanbul ignore next */ -if (process.env.NODE_ENV !== 'production') { - validatePropTypes = (options: unknown, caller: Function): void => { - PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) - } -} diff --git a/src/hooks/useSelect/utils/defaultProps.ts b/src/hooks/useSelect/utils/defaultProps.ts new file mode 100644 index 000000000..e206fe2c9 --- /dev/null +++ b/src/hooks/useSelect/utils/defaultProps.ts @@ -0,0 +1,8 @@ +import {dropdownDefaultProps} from '../../utils.dropdown' + +export const defaultProps = { + ...dropdownDefaultProps, + isItemDisabled() { + return false + }, +} diff --git a/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts b/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts new file mode 100644 index 000000000..612c35bb7 --- /dev/null +++ b/src/hooks/useSelect/utils/getItemIndexByCharacterKey.ts @@ -0,0 +1,29 @@ +import {GetItemIndexByCharacterKeyOptions} from '../types' + +export function getItemIndexByCharacterKey({ + keysSoFar, + highlightedIndex, + items, + itemToString, + isItemDisabled, +}: GetItemIndexByCharacterKeyOptions) { + const lowerCasedKeysSoFar = keysSoFar.toLowerCase() + + for (let index = 0; index < items.length; index++) { + // if we already have a search query in progress, we also consider the current highlighted item. + const offsetIndex = + (index + highlightedIndex + (keysSoFar.length < 2 ? 1 : 0)) % items.length + + const item = items[offsetIndex] + + if ( + item !== undefined && + itemToString(item).toLowerCase().startsWith(lowerCasedKeysSoFar) && + !isItemDisabled(item, offsetIndex) + ) { + return offsetIndex + } + } + + return highlightedIndex +} diff --git a/src/hooks/useSelect/utils/index.ts b/src/hooks/useSelect/utils/index.ts new file mode 100644 index 000000000..2e35b2ab2 --- /dev/null +++ b/src/hooks/useSelect/utils/index.ts @@ -0,0 +1,3 @@ +export {propTypes} from './propTypes' +export {defaultProps} from './defaultProps' +export {getItemIndexByCharacterKey} from './getItemIndexByCharacterKey' diff --git a/src/hooks/useSelect/utils/propTypes.ts b/src/hooks/useSelect/utils/propTypes.ts new file mode 100644 index 000000000..229ed8c50 --- /dev/null +++ b/src/hooks/useSelect/utils/propTypes.ts @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types' +import {dropdownPropTypes} from '../../utils.dropdown' + +export const propTypes = { + ...dropdownPropTypes, + items: PropTypes.array.isRequired, + isItemDisabled: PropTypes.func, +} diff --git a/src/hooks/useTagGroup/README.md b/src/hooks/useTagGroup/README.md new file mode 100644 index 000000000..ba7fdc7d7 --- /dev/null +++ b/src/hooks/useTagGroup/README.md @@ -0,0 +1,721 @@ +# useTagGroup + +## The problem + +You want to build a tag group component in your app that's accessible and offers +a great user experience. There is no dedicated ARIA design pattern for this +component, but since it's widely used, we compiled the list of specifications +and implemented them through a React hook that's compliant with Downshift's +principles. + +## This solution + +`useTagGroup` is a React hook that manages all the stateful logic needed to make +the tag group functional and accessible. It returns a set of props that are +meant to be called and their results destructured on the tag group's elements: +its container, tag item and tag remove button. The props are similar to the ones +provided by vanilla `` to the children render prop. + +These props are called getter props, and their return values are destructured as +a set of ARIA attributes and event listeners. Together with the action props and +state props, they create all the stateful logic needed for the tag group to +implement the list of requirements. Every functionality needed should be +provided out-of-the-box: item removal and selection, and left/right arrow +movement between items, screen reader support etc. + +## Table of Contents + + + + +- [Usage](#usage) +- [Basic Props](#basic-props) + - [removeElementDescription](#removeelementdescription) + - [onItemsChange](#onitemschange) + - [stateReducer](#statereducer) +- [Advanced Props](#advanced-props) + - [isItemDisabled](#isitemdisabled) + - [initialItems](#initialitems) + - [initialActiveIndex](#initialactiveindex) + - [onActiveIndexChange](#onactiveindexchange) + - [onStateChange](#onstatechange) + - [activeIndex](#activeindex) + - [items](#items) + - [id](#id) + - [tagGroupId](#taggroupid) + - [getTagId](#gettagid) + - [environment](#environment) +- [stateChangeTypes](#statechangetypes) +- [Control Props](#control-props) +- [Returned props](#returned-props) + - [prop getters](#prop-getters) + - [`getTagGroupProps`](#gettaggroupprops) + - [`getTagProps`](#gettagprops) + - [`getTagRemoveProps`](#gettagremoveprops) + - [actions](#actions) + - [state](#state) +- [Event Handlers](#event-handlers) + - [Default handlers](#default-handlers) + - [Tag Group](#tag-group) + - [Tag](#tag) + - [Tag Remove Remove](#tag-remove-remove) + - [Customizing Handlers](#customizing-handlers) +- [Examples](#examples) + + + +## Usage + +> [Try it out in the browser][sandbox-example] + +```jsx +import * as React from 'react' +import {render} from 'react-dom' +import {useSelect} from 'downshift' + +const colors = [ + 'Black', + 'Red', + 'Green', + 'Blue', + 'Orange', + 'Purple', + 'Pink', + 'Orchid', + 'Aqua', + 'Lime', + 'Gray', + 'Brown', + 'Teal', + 'Skyblue', +] + +function TagGroup() { + const initialItems = colors.slice(0, 5) + const { + addItem, + getTagProps, + getTagRemoveProps, + getTagGroupProps, + items, + activeIndex, + } = useTagGroup({initialItems}) + + const itemsToAdd = colors.filter(color => !items.includes(color)) + + return ( +
    +
    + {items.map((color, index) => ( + + {color} + + + ))} +
    + +
    Add more items:
    + +
      + {itemsToAdd.map(item => ( +
    • + +
    • + ))} +
    +
    + ) +} + +render(, document.getElementById('root')) +``` + +## Basic Props + +This is the list of props that you should probably know about. There are some +[advanced props](#advanced-props) below as well. + +### removeElementDescription + +> `string` | defaults to: `"Press Delete or Backspace to remove tag."` + +An accessible description that gets added to the DOM in an invisible element and +gets picked up by screen readers when encountering tags. It should instruct +users how to remove tags with the keyboard. Useful for localized messages. + +### onItemsChange + +> `function(changes: object)` | optional, no useful default + +Called each time the items in state changed. Adding items can be done using +`addItem`, while removing items could be done with mouse and keyboard actions. + +- `changes`: These are the properties that actually have changed since the last + state change. This object is guaranteed to contain the `items` property with + the newly selected value. This also has a `type` property which you can learn + more about in the [`stateChangeTypes`](#statechangetypes) section. This + property will be part of the actions that can trigger a `items` change, for + example `useTagGroup.stateChangeTypes.FunctionAddItem`. + +### stateReducer + +> `function(state: object, actionAndChanges: object)` | optional + +**🚨 This is a really handy power feature 🚨** + +This function will be called each time `useTagGroup` sets its internal state (or +calls your `onStateChange` handler for control props). It allows you to modify +the state change that will take place which can give you fine grain control over +how the component interacts with user updates. It gives you the current state +and the state that will be set, and you return the state that you want to set. + +- `state`: The full current state of useTagGroup. +- `actionAndChanges`: Object that contains the action `type`, props needed to + return a new state based on that type and the changes suggested by the + useTagGroup default reducer. About the `type` property you can learn more + about in the [`stateChangeTypes`](#statechangetypes) section. + +```javascript +import {useTagGroup} from 'downshift' +import {items} from './utils' + +const {getTagGroupProps, getTagProps, getTagRemoveProps, ...rest} = useTagGroup( + { + initialItems: items.slice(0, 4), + stateReducer, + }, +) + +function stateReducer(state, actionAndChanges) { + const {type, changes} = actionAndChanges + // resets active item to the first when removing an item + switch (type) { + case useSelect.stateChangeTypes.TagGroupKeyDownBackspace: + case useSelect.stateChangeTypes.TagGroupKeyDownDelete: + return { + ...changes, // default tagGroup new state changes on item removal. + activeIndex: changes.items.length === 0 ? -1 : 0, + } + default: + return changes // otherwise business as usual. + } +} +``` + +> NOTE: This is only called when state actually changes. You should not attempt +> use this to handle events. If you wish to handle events, put your event +> handlers directly on the elements (make sure to use the prop getters though! +> For example ` +
    +
    +) +``` + +> NOTE: In this example we used both a getter prop `getTagGroupProps` and an +> action prop `addItem`. The properties of `useTagGroup` can be split into three +> categories as indicated below: + +### prop getters + +> See [the blog post about prop getters][blog-post-prop-getters] + +> NOTE: These prop-getters provide `aria-` attributes which are very important +> to your component being accessible. It's recommended that you utilize these +> functions and apply the props they give you to your components. + +These functions are used to apply props to the elements that you render. This +gives you maximum flexibility to render what, when, and wherever you like. You +call these on the element in question, for example on the toggle button: +` + +``` + +Required properties: + +- `index`: This is how `useTagRemoveGroup` keeps track of your item when + labelling the remove button. By default, `useTagRemoveGroup` will assume the + `index` is the order in which you're calling `getItemProps`. This is often + good enough, but if you find odd behavior, try setting this explicitly. It's + probably best to be explicit about `index` when using a windowing library like + `react-virtualized`. + +Optional properties: + +- `ref`: if you need to access the item element via a ref object, you'd call the + function like this: `getTagRemoveProps({ref: yourTagRef})`. As a result, the + tag element will receive a composed `ref` property, which guarantees that both + your code and `useSelect` use the same correct reference to the element. + +- `refKey`: if you're rendering a composite component, that component will need + to accept a prop which it forwards to the root DOM element. Commonly, folks + call this `innerRef`. So you'd call: `getTagRemoveProps({refKey: 'innerRef'})` + and your composite component would forward like: + ` + + ))} +
    + ) +} diff --git a/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts b/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts new file mode 100644 index 000000000..edaed2218 --- /dev/null +++ b/src/hooks/useTagGroup/__tests__/utils/renderUseTagGroup.ts @@ -0,0 +1,16 @@ +import {renderHook} from '@testing-library/react' + +import {UseTagGroupProps, UseTagGroupReturnValue} from '../../index.types' +import useTagGroup from '../..' +import {defaultProps} from './defaultProps' + +export function renderUseTagGroup( + initialProps: Partial> = {}, +) { + return renderHook< + UseTagGroupReturnValue, + Partial> + >((props = {}) => useTagGroup(props), { + initialProps: {...defaultProps, ...initialProps}, + }) +} diff --git a/src/hooks/useTagGroup/index.ts b/src/hooks/useTagGroup/index.ts new file mode 100644 index 000000000..4b14ee5c2 --- /dev/null +++ b/src/hooks/useTagGroup/index.ts @@ -0,0 +1,222 @@ +import {useEffect, useCallback, useRef} from 'react' + +import { + callAllEventHandlers, + handleRefs, + useLatestRef, + validatePropTypes, +} from '../../utils-ts' +import {useControlledReducer} from '../utils-ts' +import * as stateChangeTypes from './stateChangeTypes' +import { + GetTagGroupPropsOptions, + GetTagPropsOptions, + GetTagRemovePropsOptions, + UseTagGroupInterface, + UseTagGroupProps, + UseTagGroupMergedProps, + UseTagGroupReducerAction, + UseTagGroupReturnValue, + UseTagGroupState, + UseTagGroupStateChangeTypes, + GetTagRemovePropsReturnValue, + GetTagPropsReturnValue, + GetTagGroupPropsReturnValue, +} from './index.types' +import {useTagGroupReducer} from './reducer' +import { + getInitialState, + isStateEqual, + propTypes, + useElementIds, + useAccessibleDescription, + A11Y_DESCRIPTION_ELEMENT_ID, + getMergedProps, +} from './utils' + +const useTagGroup: UseTagGroupInterface = ( + userProps: UseTagGroupProps = {}, +) => { + /* State and Props */ + + validatePropTypes(userProps, useTagGroup, propTypes) + + const props = getMergedProps(userProps) + + const [state, dispatch] = useControlledReducer< + UseTagGroupState, + UseTagGroupMergedProps, + UseTagGroupStateChangeTypes, + UseTagGroupReducerAction + >(useTagGroupReducer, props, getInitialState, isStateEqual) + + const {activeIndex, items} = state + + /* Refs */ + + const latest = useLatestRef({state, props}) + const elementIds = useElementIds({ + getTagId: props.getTagId, + id: props.id, + tagGroupId: props.tagGroupId, + }) + const itemRefs = useRef>({}) + const previousActiveIndexRef = useRef(activeIndex) + const previousItemsLengthRef = useRef(items.length) + + /* Effects */ + + useAccessibleDescription( + props.environment?.document, + props.removeElementDescription, + ) + + useEffect(() => { + if ( + (activeIndex !== -1 && + previousActiveIndexRef.current !== -1 && + activeIndex !== previousActiveIndexRef.current) || + previousItemsLengthRef.current === items.length + 1 + ) { + itemRefs.current[elementIds.getTagId(activeIndex)]?.focus() + } + + previousActiveIndexRef.current = activeIndex + previousItemsLengthRef.current = items.length + }, [activeIndex, elementIds, items]) + + /* Getter functions */ + + const getTagGroupProps = useCallback( + >( + options?: GetTagGroupPropsOptions & Extra, + ) => { + const {onKeyDown, ...rest} = + options ?? ({} as GetTagGroupPropsOptions & Extra) + const handleKeyDown = (e: React.KeyboardEvent): void => { + switch (e.key) { + case 'ArrowLeft': + dispatch({ + type: stateChangeTypes.TagGroupKeyDownArrowLeft, + }) + break + case 'ArrowRight': + dispatch({ + type: stateChangeTypes.TagGroupKeyDownArrowRight, + }) + break + case 'Delete': + dispatch({ + type: stateChangeTypes.TagGroupKeyDownDelete, + }) + break + case 'Backspace': + dispatch({ + type: stateChangeTypes.TagGroupKeyDownBackspace, + }) + break + default: + } + } + + const tagGroupProps = { + id: elementIds.tagGroupId, + 'aria-live': 'polite', + 'aria-atomic': 'false', + 'aria-relevant': 'additions', + role: 'listbox', + onKeyDown: callAllEventHandlers(onKeyDown, handleKeyDown), + ...rest, + } as GetTagGroupPropsReturnValue & Extra + + return tagGroupProps + }, + [dispatch, elementIds.tagGroupId], + ) + + const getTagProps = useCallback( + >( + options: GetTagPropsOptions & Extra, + ) => { + const {index, refKey = 'ref', ref, onClick, ...rest} = options + + if (!Number.isInteger(index) || index < 0) { + throw new Error('Pass correct item index to getTagProps!') + } + + const latestState = latest.current.state + + const handleClick = () => { + dispatch({type: stateChangeTypes.TagClick, index}) + } + const tagId = elementIds.getTagId(index) + + return { + 'aria-describedby': A11Y_DESCRIPTION_ELEMENT_ID, + [refKey]: handleRefs(ref, itemNode => { + if (itemNode) { + itemRefs.current[tagId] = itemNode + } + }), + 'aria-labelledby': tagId, + role: 'option', + id: tagId, + onClick: callAllEventHandlers(onClick, handleClick), + tabIndex: latestState.activeIndex === index ? 0 : -1, + ...rest, + } as GetTagPropsReturnValue & Extra + }, + [dispatch, elementIds, latest], + ) + + const getTagRemoveProps = useCallback( + >( + options: GetTagRemovePropsOptions & Extra, + ) => { + const {index, onClick, ...rest} = options + + if (!Number.isInteger(index) || index < 0) { + throw new Error('Pass correct item index to getTagRemoveProps!') + } + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation() + dispatch({type: stateChangeTypes.TagRemoveClick, index}) + } + + const tagId = elementIds.getTagId(index) + const tagRemoveId = `${tagId}-remove` + + return { + id: tagRemoveId, + tabIndex: -1, + 'aria-labelledby': `${tagRemoveId} ${tagId}`, + onClick: callAllEventHandlers(onClick, handleClick), + ...rest, + } as GetTagRemovePropsReturnValue & Extra + }, + [elementIds, dispatch], + ) + + /* Imperative Functions */ + + const addItem = useCallback['addItem']>( + (item, index): void => { + dispatch({type: stateChangeTypes.FunctionAddItem, item, index}) + }, + [dispatch], + ) + + return { + activeIndex, + addItem, + getTagGroupProps, + getTagProps, + getTagRemoveProps, + items, + } +} + +useTagGroup.stateChangeTypes = stateChangeTypes + +export default useTagGroup diff --git a/src/hooks/useTagGroup/index.types.ts b/src/hooks/useTagGroup/index.types.ts new file mode 100644 index 000000000..b2f000cc1 --- /dev/null +++ b/src/hooks/useTagGroup/index.types.ts @@ -0,0 +1,177 @@ +import {Action, State} from '../../utils-ts' + +export interface UseTagGroupState extends State { + activeIndex: number + items: Item[] +} + +export interface Environment { + addEventListener: typeof window.addEventListener + removeEventListener: typeof window.removeEventListener + document: Document + Node: typeof window.Node +} + +export interface UseTagGroupStateChange extends Partial< + UseTagGroupState +> { + type: UseTagGroupStateChangeTypes +} + +export interface UseTagGroupActiveIndexChange< + Item, +> extends UseTagGroupStateChange { + activeIndex: number +} + +export interface UseTagGroupItemsChange< + Item, +> extends UseTagGroupStateChange { + items: Item[] +} + +export interface UseTagGroupProps extends Partial< + UseTagGroupState +> { + environment?: Environment + getTagId?: (index: number) => string + id?: string + initialActiveIndex?: number + initialItems?: Item[] + onActiveIndexChange?: (changes: UseTagGroupActiveIndexChange) => void + onItemsChange?: (changes: UseTagGroupItemsChange) => void + onStateChange?: (changes: UseTagGroupStateChange) => void + removeElementDescription?: string + stateReducer?( + state: UseTagGroupState, + actionAndChanges: Action & { + changes: Partial> + }, + ): Partial> + tagGroupId?: string +} + +export type UseTagGroupMergedProps = Required< + Pick, 'stateReducer' | 'removeElementDescription'> +> & + UseTagGroupProps + +export interface UseTagGroupInterface { + (props?: UseTagGroupProps): UseTagGroupReturnValue + stateChangeTypes: { + TagClick: UseTagGroupStateChangeTypes.TagClick + TagGroupKeyDownArrowLeft: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft + TagGroupKeyDownArrowRight: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight + TagGroupKeyDownBackspace: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace + TagGroupKeyDownDelete: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete + TagRemoveClick: UseTagGroupStateChangeTypes.TagRemoveClick + FunctionAddItem: UseTagGroupStateChangeTypes.FunctionAddItem + } +} + +export interface UseTagGroupReturnValue { + activeIndex: number + addItem: (item: Item, index?: number) => void + getTagGroupProps: GetTagGroupProps + getTagProps: GetTagProps + getTagRemoveProps: GetTagRemoveProps + items: Item[] +} + +export interface GetTagPropsOptions extends React.HTMLProps { + index: number + refKey?: string + ref?: React.MutableRefObject +} + +export interface GetTagPropsReturnValue { + 'aria-describedby': string + id: string + role: 'option' + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + tabIndex: 0 | -1 +} + +export interface GetTagRemovePropsOptions extends React.HTMLProps { + index: number +} + +export interface GetTagRemovePropsReturnValue { + id: string + 'aria-labelledby': string + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + tabIndex: number +} + +export interface GetTagGroupPropsOptions extends React.HTMLProps { + refKey?: string + ref?: React.MutableRefObject +} + +export interface GetTagGroupPropsReturnValue { + id: string + role: 'listbox' + 'aria-live': 'polite' + 'aria-atomic': 'false' + 'aria-relevant': 'additions' + onKeyDown: React.KeyboardEventHandler +} +export type GetTagGroupProps = = {}>( + options?: GetTagGroupPropsOptions & Extra, +) => GetTagGroupPropsReturnValue & Extra + +export type GetTagProps = = {}>( + options: GetTagPropsOptions & Extra, +) => GetTagPropsReturnValue & Extra + +export type GetTagRemoveProps = = {}>( + options: GetTagRemovePropsOptions & Extra, +) => GetTagRemovePropsReturnValue & Extra + +export enum UseTagGroupStateChangeTypes { + TagClick = '__tag_click__', + TagGroupKeyDownArrowLeft = '__taggroup_keydown_arrowleft__', + TagGroupKeyDownArrowRight = '__taggroup_keydown_arrowright__', + TagGroupKeyDownBackspace = '__taggroup_keydown_backspace__', + TagGroupKeyDownDelete = '__taggroup_keydown_delete__', + TagRemoveClick = '__tagremove_click__', + FunctionAddItem = '__function_add_item__', +} + +export type UseTagGroupReducerAction = + | UseTagGroupTagClickReducerAction + | UseTagGroupTagKeyDownArrowLeftAction + | UseTagGroupTagKeyDownArrowRightAction + | UseTagGroupTagKeyDownBackspaceAction + | UseTagGroupTagKeyDownDeleteAction + | UseTagGroupTagRemoveClickAction + | UseTagGroupFunctionAddItem + +export type UseTagGroupTagClickReducerAction = { + type: UseTagGroupStateChangeTypes.TagClick + index: number +} + +export type UseTagGroupTagKeyDownArrowLeftAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft +} +export type UseTagGroupTagKeyDownArrowRightAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight +} +export type UseTagGroupTagKeyDownBackspaceAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace +} +export type UseTagGroupTagKeyDownDeleteAction = { + type: UseTagGroupStateChangeTypes.TagGroupKeyDownDelete +} +export type UseTagGroupTagRemoveClickAction = { + type: UseTagGroupStateChangeTypes.TagRemoveClick + index: number +} +export type UseTagGroupFunctionAddItem = { + type: UseTagGroupStateChangeTypes.FunctionAddItem + item: Item + index?: number +} diff --git a/src/hooks/useTagGroup/reducer.ts b/src/hooks/useTagGroup/reducer.ts new file mode 100644 index 000000000..5f05201d0 --- /dev/null +++ b/src/hooks/useTagGroup/reducer.ts @@ -0,0 +1,105 @@ +import { + UseTagGroupProps, + UseTagGroupReducerAction, + UseTagGroupState, +} from './index.types' +import * as stateChangeTypes from './stateChangeTypes' + +export function useTagGroupReducer( + state: UseTagGroupState, + _props: UseTagGroupProps, + action: UseTagGroupReducerAction, +): UseTagGroupState { + const {type} = action + + let changes + + switch (type) { + case stateChangeTypes.TagClick: + changes = { + activeIndex: action.index, + } + break + case stateChangeTypes.TagGroupKeyDownArrowLeft: + changes = { + activeIndex: + state.activeIndex === 0 + ? state.items.length - 1 + : state.activeIndex - 1, + } + break + case stateChangeTypes.TagGroupKeyDownArrowRight: + changes = { + activeIndex: + state.activeIndex === state.items.length - 1 + ? 0 + : state.activeIndex + 1, + } + break + case stateChangeTypes.TagGroupKeyDownBackspace: + case stateChangeTypes.TagGroupKeyDownDelete: { + const newItems = [ + ...state.items.slice(0, state.activeIndex), + ...state.items.slice(state.activeIndex + 1), + ] + const newActiveIndex = + newItems.length === 0 + ? -1 + : newItems.length === state.activeIndex + ? state.activeIndex - 1 + : state.activeIndex + changes = { + items: [ + ...state.items.slice(0, state.activeIndex), + ...state.items.slice(state.activeIndex + 1), + ], + activeIndex: newActiveIndex, + } + break + } + case stateChangeTypes.TagRemoveClick: + { + const newItems = [ + ...state.items.slice(0, action.index), + ...state.items.slice(action.index + 1), + ] + const newActiveIndex = + newItems.length === 0 + ? -1 + : newItems.length === action.index + ? action.index - 1 + : action.index + changes = { + items: newItems, + activeIndex: newActiveIndex, + } + } + break + case stateChangeTypes.FunctionAddItem: { + let newItems: Item[] = [] + + if (action.index === undefined) { + newItems = [...state.items, action.item] + } else { + newItems = [ + ...state.items.slice(0, action.index), + action.item, + ...state.items.slice(action.index), + ] + } + + const newActiveIndex = + state.activeIndex === -1 ? newItems.length - 1 : state.activeIndex + + changes = { + items: newItems, + activeIndex: newActiveIndex, + } + break + } + default: + throw new Error('Invalid useTagGroup reducer action.') + } + + return {...state, ...changes} +} diff --git a/src/hooks/useTagGroup/stateChangeTypes.ts b/src/hooks/useTagGroup/stateChangeTypes.ts new file mode 100644 index 000000000..009780355 --- /dev/null +++ b/src/hooks/useTagGroup/stateChangeTypes.ts @@ -0,0 +1,27 @@ +import productionEnum from '../../productionEnum.macro' +import {UseTagGroupStateChangeTypes} from './index.types' + +export const TagClick = productionEnum( + '__tag_click__', +) as UseTagGroupStateChangeTypes.TagClick + +export const TagGroupKeyDownArrowLeft = productionEnum( + '__taggroup_keydown_arrowleft__', +) as UseTagGroupStateChangeTypes.TagGroupKeyDownArrowLeft +export const TagGroupKeyDownArrowRight = productionEnum( + '__taggroup_keydown_arrowright__', +) as UseTagGroupStateChangeTypes.TagGroupKeyDownArrowRight +export const TagGroupKeyDownDelete = productionEnum( + '__taggroup_keydown_delete__', +) as UseTagGroupStateChangeTypes.TagGroupKeyDownDelete +export const TagGroupKeyDownBackspace = productionEnum( + '__taggroup_keydown_backspace__', +) as UseTagGroupStateChangeTypes.TagGroupKeyDownBackspace + +export const TagRemoveClick = productionEnum( + '__tagremove_click__', +) as UseTagGroupStateChangeTypes.TagRemoveClick + +export const FunctionAddItem = productionEnum( + '__function_add_item__', +) as UseTagGroupStateChangeTypes.FunctionAddItem diff --git a/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts new file mode 100644 index 000000000..41ace534b --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/useAccessibleDescription.test.ts @@ -0,0 +1,53 @@ +import {renderHook, act} from '@testing-library/react' +import {A11Y_DESCRIPTION_ELEMENT_ID, useAccessibleDescription} from '..' + +describe('useAccessibleDescription', () => { + test('does nothing if document is undefined', () => { + const {result} = renderHook(() => + useAccessibleDescription(undefined, 'description'), + ) + + expect(result.current).toBeUndefined() + }) + + test('adds a div element to the document that serves as accessible description', () => { + const divElement = { + setAttribute: jest.fn(), + remove: jest.fn(), + style: {display: ''}, + textContent: '', + } + + const document: Document = { + createElement: jest.fn().mockReturnValue(divElement), + body: { + appendChild: jest.fn(), + }, + } as unknown as Document + const description = 'press delete to remove' + + const {unmount} = renderHook(() => + useAccessibleDescription(document, description), + ) + + expect(document.createElement).toHaveBeenCalledTimes(1) + expect(document.createElement).toHaveBeenCalledWith('div') + expect(divElement.setAttribute).toHaveBeenCalledTimes(1) + expect(divElement.setAttribute).toHaveBeenCalledWith( + 'id', + A11Y_DESCRIPTION_ELEMENT_ID, + ) + // eslint-disable-next-line jest-dom/prefer-to-have-style + expect(divElement.style.display).toEqual('none') + // eslint-disable-next-line jest-dom/prefer-to-have-text-content + expect(divElement.textContent).toEqual(description) + expect(document.body.appendChild).toHaveBeenCalledTimes(1) + expect(document.body.appendChild).toHaveBeenCalledWith(divElement) + + act(() => { + unmount() + }) + + expect(divElement.remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts new file mode 100644 index 000000000..b606c90a3 --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.r18.test.ts @@ -0,0 +1,23 @@ +import {renderHook} from '@testing-library/react' +import {useElementIds} from '..' + +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + useId() { + return 'mocked-id' + }, + } +}) + +describe('useElementIds', () => { + test('uses React.useId for React >= 18', () => { + const {result} = renderHook(() => useElementIds({})) + + expect(result.current).toEqual({ + getTagId: expect.any(Function), + tagGroupId: 'downshift-mocked-id-tag-group', + }) + expect(result.current.getTagId(10)).toEqual("downshift-mocked-id-tag-10") + }) +}) diff --git a/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts new file mode 100644 index 000000000..12ea31c4e --- /dev/null +++ b/src/hooks/useTagGroup/utils/__tests__/utils.use-element-ids.test.ts @@ -0,0 +1,23 @@ +import {renderHook} from '@testing-library/react' +import {useElementIds} from '..' + +jest.mock('react', () => { + const {useId, ...react} = jest.requireActual('react') + return react +}) + +jest.mock('../../../../utils-ts', () => ({ + generateId: jest.fn().mockReturnValue('test-id'), +})) + +describe('useElementIds', () => { + test('uses React.useId for React < 18', () => { + const {result} = renderHook(() => useElementIds({})) + + expect(result.current).toEqual({ + getTagId: expect.any(Function), + tagGroupId: 'downshift-test-id-tag-group', + }) + expect(result.current.getTagId(12)).toEqual('downshift-test-id-tag-12') + }) +}) diff --git a/src/hooks/useTagGroup/utils/getInitialState.ts b/src/hooks/useTagGroup/utils/getInitialState.ts new file mode 100644 index 000000000..603bc2af1 --- /dev/null +++ b/src/hooks/useTagGroup/utils/getInitialState.ts @@ -0,0 +1,16 @@ +import {UseTagGroupProps, UseTagGroupState} from '../index.types' + +export function getInitialState( + props: UseTagGroupProps, +): UseTagGroupState { + const items = props.items ?? props.initialItems ?? [] + const activeIndex = + props.activeIndex ?? + props.initialActiveIndex ?? + (items.length === 0 ? -1 : 0) + + return { + activeIndex, + items, + } +} diff --git a/src/hooks/useTagGroup/utils/getMergedProps.ts b/src/hooks/useTagGroup/utils/getMergedProps.ts new file mode 100644 index 000000000..811956e42 --- /dev/null +++ b/src/hooks/useTagGroup/utils/getMergedProps.ts @@ -0,0 +1,18 @@ +import {UseTagGroupProps, UseTagGroupMergedProps} from '../index.types' + +import {isReactNative} from '../../../is.macro.js' + +export function getMergedProps( + userProps: UseTagGroupProps, +): UseTagGroupMergedProps { + return { + stateReducer(_s, {changes}) { + return changes + }, + environment: + /* istanbul ignore next (ssr) */ + typeof window === 'undefined' || isReactNative ? undefined : window, + removeElementDescription: 'Press Delete or Backspace to remove tag.', + ...userProps, + } +} diff --git a/src/hooks/useTagGroup/utils/index.ts b/src/hooks/useTagGroup/utils/index.ts new file mode 100644 index 000000000..ae76f19df --- /dev/null +++ b/src/hooks/useTagGroup/utils/index.ts @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types' + +export { + type UseElementIdsProps, + type UseElementIdsReturnValue, + useElementIds, +} from './useElementIds' +export {getInitialState} from './getInitialState' +export {isStateEqual} from './isStateEqual' +export { + useAccessibleDescription, + A11Y_DESCRIPTION_ELEMENT_ID, +} from './useAccessibleDescription' +export {getMergedProps} from './getMergedProps' + +export const propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> +> = { + isItemDisabled: PropTypes.func, +} diff --git a/src/hooks/useTagGroup/utils/isStateEqual.ts b/src/hooks/useTagGroup/utils/isStateEqual.ts new file mode 100644 index 000000000..307e55e48 --- /dev/null +++ b/src/hooks/useTagGroup/utils/isStateEqual.ts @@ -0,0 +1,11 @@ +import {UseTagGroupState} from '../index.types' + +export function isStateEqual( + oldState: UseTagGroupState, + newState: UseTagGroupState, +): boolean { + return ( + oldState.activeIndex === newState.activeIndex && + oldState.items === newState.items + ) +} diff --git a/src/hooks/useTagGroup/utils/useAccessibleDescription.ts b/src/hooks/useTagGroup/utils/useAccessibleDescription.ts new file mode 100644 index 000000000..cf0744df2 --- /dev/null +++ b/src/hooks/useTagGroup/utils/useAccessibleDescription.ts @@ -0,0 +1,26 @@ +import * as React from 'react' + +export const A11Y_DESCRIPTION_ELEMENT_ID = 'tag-group-a11y-description' + +export function useAccessibleDescription( + document: Document | undefined, + description: string, +) { + React.useEffect(() => { + if (!document) { + return + } + + const accessibleDescriptionElement = document.createElement('div') + + accessibleDescriptionElement.setAttribute('id', A11Y_DESCRIPTION_ELEMENT_ID) + accessibleDescriptionElement.style.display = 'none' + accessibleDescriptionElement.textContent = description + + document.body.appendChild(accessibleDescriptionElement) + + return () => { + accessibleDescriptionElement.remove() + } + }, [description, document]) +} diff --git a/src/hooks/useTagGroup/utils/useElementIds.ts b/src/hooks/useTagGroup/utils/useElementIds.ts new file mode 100644 index 000000000..c64412219 --- /dev/null +++ b/src/hooks/useTagGroup/utils/useElementIds.ts @@ -0,0 +1,53 @@ +import * as React from 'react' + +import {generateId} from '../../../utils-ts' +import {UseTagGroupProps} from '../index.types' + +export type UseElementIdsProps = Pick< + UseTagGroupProps, + 'id' | 'getTagId' | 'tagGroupId' +> + +export type UseElementIdsReturnValue = Required< + Pick, 'getTagId' | 'tagGroupId'> +> + +// istanbul ignore next +export const useElementIds: ( + props: UseElementIdsProps, +) => UseElementIdsReturnValue = + 'useId' in React // Avoid conditional useId call + ? useElementIdsR18 + : useElementIdsLegacy + +function useElementIdsR18({ + id, + tagGroupId, + getTagId, +}: UseElementIdsProps): UseElementIdsReturnValue { + // Avoid conditional useId call + const reactId = `downshift-${React.useId()}` + if (!id) { + id = reactId + } + + const elementIdsRef = React.useRef({ + tagGroupId: tagGroupId ?? `${id}-tag-group`, + getTagId: getTagId ?? (index => `${id}-tag-${index}`), + }) + + return elementIdsRef.current +} + +function useElementIdsLegacy({ + id = `downshift-${generateId()}`, + getTagId, + tagGroupId, +}: UseElementIdsProps): UseElementIdsReturnValue { + const elementIdsRef = React.useRef({ + tagGroupId: tagGroupId ?? `${id}-tag-group`, + getTagId: getTagId ?? (index => `${id}-tag-${index}`), + }) + + return elementIdsRef.current +} diff --git a/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts new file mode 100644 index 000000000..c9341fec7 --- /dev/null +++ b/src/hooks/utils-ts/__tests__/getItemAndIndex.test.ts @@ -0,0 +1,47 @@ +import {getItemAndIndex} from '../getItemAndIndex' + +test('returns the props if both are passed', () => { + const item = {hi: 'hello'} + const index = 5 + + expect(getItemAndIndex(item, index, [item], 'bla')).toEqual([item, index]) +}) + +test('throws error when index is not passed and item is not found in the array', () => { + const item = {hi: 'hello'} + const errorMessage = 'no item found' + + expect(() => getItemAndIndex(item, undefined, [], errorMessage)).toThrowError( + errorMessage, + ) +}) + +test('returns the item and the index found', () => { + const item = {hi: 'hello'} + + expect(getItemAndIndex(item, undefined, [item], 'bla')).toEqual([item, 0]) +}) + +test('throws error when item is not passed and item is not found in the array', () => { + const item = {hi: 'hello'} + const errorMessage = 'no item found at index' + + expect(() => + getItemAndIndex(undefined, 1, [item], errorMessage), + ).toThrowError(errorMessage) +}) + +test('returns the index and the item found', () => { + const item = {hi: 'hello'} + const index = 0 + + expect(getItemAndIndex(undefined, index, [item], 'bla')).toEqual([item, 0]) +}) + +test('throws error when both index and item are not passed', () => { + const errorMessage = 'it is all wrong' + + expect(() => + getItemAndIndex(undefined, undefined, [{item: 'bla'}], errorMessage), + ).toThrowError(errorMessage) +}) diff --git a/src/hooks/utils-ts/callOnChangeProps.ts b/src/hooks/utils-ts/callOnChangeProps.ts new file mode 100644 index 000000000..f755892c3 --- /dev/null +++ b/src/hooks/utils-ts/callOnChangeProps.ts @@ -0,0 +1,45 @@ +import {Action, Props, State} from '../../utils-ts' +import {capitalizeString} from './capitalizeString' + +export function callOnChangeProps< + S extends State, + P extends Partial & Props, + T, +>(action: Action, props: P, state: S, newState: S) { + const {type} = action + const changes: Partial = {} + const keys = Object.keys(state) + + for (const key of keys) { + invokeOnChangeHandler(key, action, props, state, newState) + + if (newState[key] !== state[key]) { + changes[key] = newState[key] + } + } + + if (props.onStateChange && Object.keys(changes).length) { + props.onStateChange({type, ...changes}) + } +} + +function invokeOnChangeHandler< + S extends State, + P extends Partial & Props, + T, +>(key: string, action: Action, props: P, state: S, newState: S) { + if (newState[key] === state[key]) { + return + } + + const handlerKey = `on${capitalizeString(key)}Change` + const handler = props[handlerKey] + + if (typeof handler !== 'function') { + return + } + + const {type} = action + + handler({type, ...newState}) +} diff --git a/src/hooks/utils-ts/capitalizeString.ts b/src/hooks/utils-ts/capitalizeString.ts new file mode 100644 index 000000000..033d20981 --- /dev/null +++ b/src/hooks/utils-ts/capitalizeString.ts @@ -0,0 +1,3 @@ +export function capitalizeString(string: string): string { + return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` +} diff --git a/src/hooks/utils-ts/getDefaultValue.ts b/src/hooks/utils-ts/getDefaultValue.ts new file mode 100644 index 000000000..6aa3bd009 --- /dev/null +++ b/src/hooks/utils-ts/getDefaultValue.ts @@ -0,0 +1,16 @@ +import {State} from '../../utils-ts' +import {capitalizeString} from './capitalizeString' + +export function getDefaultValue>( + props: P, + propKey: keyof S, + defaultStateValues: S, +): S[keyof S] { + const defaultValue = props[`default${capitalizeString(propKey as string)}`] + + if (defaultValue !== undefined) { + return defaultValue as S[keyof S] + } + + return defaultStateValues[propKey] +} diff --git a/src/hooks/utils-ts/getInitialValue.ts b/src/hooks/utils-ts/getInitialValue.ts new file mode 100644 index 000000000..954e5fbb3 --- /dev/null +++ b/src/hooks/utils-ts/getInitialValue.ts @@ -0,0 +1,23 @@ +import {State} from '../../utils-ts' +import {capitalizeString} from './capitalizeString' +import {getDefaultValue} from './getDefaultValue' + +export function getInitialValue>( + props: P, + propKey: keyof S, + defaultStateValues: S, +): S[keyof S] { + const value = props[propKey] as keyof S | undefined + + if (value !== undefined) { + return value as S[keyof S] + } + + const initialValue = props[`initial${capitalizeString(propKey as string)}`] + + if (initialValue !== undefined) { + return initialValue as S[keyof S] + } + + return getDefaultValue(props, propKey, defaultStateValues) +} diff --git a/src/hooks/utils-ts/getItemAndIndex.ts b/src/hooks/utils-ts/getItemAndIndex.ts new file mode 100644 index 000000000..719afb70d --- /dev/null +++ b/src/hooks/utils-ts/getItemAndIndex.ts @@ -0,0 +1,40 @@ +/** + * Returns both the item and index when both or either is passed. + * + * @param itemProp The item which could be undefined. + * @param indexProp The index which could be undefined. + * @param items The array of items to get the item based on index. + * @param errorMessage The error to be thrown if index and item could not be returned for any reason. + * @returns An array with item and index. + */ +export function getItemAndIndex( + itemProp: Item | undefined, + indexProp: number | undefined, + items: Item[], + errorMessage: string, +): [Item, number] { + if (itemProp !== undefined && indexProp !== undefined) { + return [itemProp, indexProp] + } + + if (itemProp !== undefined) { + const index = items.indexOf(itemProp) + + if (index < 0) { + throw new Error(errorMessage) + } + + return [itemProp, items.indexOf(itemProp)] + } + + if (indexProp !== undefined) { + const item = items[indexProp] + + if (item === undefined) { + throw new Error(errorMessage) + } + return [item, indexProp] + } + + throw new Error(errorMessage) +} diff --git a/src/hooks/utils-ts/index.ts b/src/hooks/utils-ts/index.ts new file mode 100644 index 000000000..9f4c66895 --- /dev/null +++ b/src/hooks/utils-ts/index.ts @@ -0,0 +1,11 @@ +export {useControlledReducer} from './useControlledReducer' +export {useEnhancedReducer} from './useEnhancedReducer' +export {callOnChangeProps} from './callOnChangeProps' +export {getItemAndIndex} from './getItemAndIndex' +export {useIsInitialMount} from './useIsInitialMount' +export {stateReducer} from './stateReducer' +export {propTypes as commonPropTypes} from './propTypes' +export {capitalizeString} from './capitalizeString' +export {getDefaultValue} from './getDefaultValue' +export {getInitialValue} from './getInitialValue' +export {useA11yMessageStatus} from './useA11yMessageStatus' diff --git a/src/hooks/utils-ts/propTypes.ts b/src/hooks/utils-ts/propTypes.ts new file mode 100644 index 000000000..c424483b6 --- /dev/null +++ b/src/hooks/utils-ts/propTypes.ts @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types' + +// Shared between all exports. +export const propTypes = { + environment: PropTypes.shape({ + addEventListener: PropTypes.func.isRequired, + removeEventListener: PropTypes.func.isRequired, + document: PropTypes.shape({ + createElement: PropTypes.func.isRequired, + getElementById: PropTypes.func.isRequired, + activeElement: PropTypes.any.isRequired, + body: PropTypes.any.isRequired, + }).isRequired, + Node: PropTypes.func.isRequired, + }), + itemToKey: PropTypes.func, + stateReducer: PropTypes.func, +} diff --git a/src/hooks/utils-ts/stateReducer.ts b/src/hooks/utils-ts/stateReducer.ts new file mode 100644 index 000000000..1fedcb71d --- /dev/null +++ b/src/hooks/utils-ts/stateReducer.ts @@ -0,0 +1,9 @@ +import {Action, State} from '../../utils-ts' + +/** + * Default state reducer that returns the changes. + * + */ +export function stateReducer(_s: State, a: Action) { + return a.changes +} diff --git a/src/hooks/utils-ts/useA11yMessageStatus.ts b/src/hooks/utils-ts/useA11yMessageStatus.ts new file mode 100644 index 000000000..55e953070 --- /dev/null +++ b/src/hooks/utils-ts/useA11yMessageStatus.ts @@ -0,0 +1,50 @@ +import * as React from 'react' + +import {cleanupStatusDiv, debounce, setStatus} from '../../utils-ts' +import {isReactNative} from '../../is.macro.js' +import {useIsInitialMount} from './useIsInitialMount' + +/** + * Debounced call for updating the a11y message. + */ +const updateA11yStatus = debounce((status: string, document: Document) => { + setStatus(status, document) +}, 200) + +/** + * Adds an a11y aria live status message if getA11yStatusMessage is passed. + * @param getA11yStatusMessage The function that builds the status message. + * @param options The options to be passed to getA11yStatusMessage if called. + * @param dependencyArray The dependency array that triggers the status message setter via useEffect. + * @param environment The environment object containing the document. + */ +export function useA11yMessageStatus( + getA11yStatusMessage: ((options: Options) => string) | undefined, + options: Options, + dependencyArray: unknown[], + environment: {document: Document | undefined} | undefined, +) { + const document = environment?.document + const isInitialMount = useIsInitialMount() + + // Adds an a11y aria live status message if getA11yStatusMessage is passed. + React.useEffect(() => { + if (!getA11yStatusMessage || isInitialMount || isReactNative || !document) { + return + } + + const status = getA11yStatusMessage(options) + + updateA11yStatus(status, document) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencyArray) + + // Cleanup the status message container. + React.useEffect(() => { + return () => { + updateA11yStatus.cancel() + cleanupStatusDiv(document) + } + }, [document]) +} diff --git a/src/hooks/utils-ts/useControlledReducer.ts b/src/hooks/utils-ts/useControlledReducer.ts new file mode 100644 index 000000000..aa05ce1bc --- /dev/null +++ b/src/hooks/utils-ts/useControlledReducer.ts @@ -0,0 +1,33 @@ +import {getState, type Action, type State, type Props} from '../../utils-ts' +import {useEnhancedReducer} from './useEnhancedReducer' + +/** + * Wraps the useEnhancedReducer and applies the controlled prop values before + * returning the new state. + * + * @param {Function} reducer Reducer function from downshift. + * @param {Object} props The hook props, also passed to createInitialState. + * @param {Function} createInitialState Function that returns the initial state. + * @param {Function} isStateEqual Function that checks if a previous state is equal to the next. + * @returns {Array} An array with the state and an action dispatcher. + */ +export function useControlledReducer< + S extends State, + P extends Partial & Props, + T, + A extends Action, +>( + reducer: (state: S, props: P, action: A) => S, + props: P, + createInitialState: (props: P) => S, + isStateEqual: (prevState: S, newState: S) => boolean, +): [S, (action: A) => void] { + const [state, dispatch] = useEnhancedReducer( + reducer, + props, + createInitialState, + isStateEqual, + ) + + return [getState(state, props), dispatch] +} diff --git a/src/hooks/utils-ts/useEnhancedReducer.ts b/src/hooks/utils-ts/useEnhancedReducer.ts new file mode 100644 index 000000000..9b06adf74 --- /dev/null +++ b/src/hooks/utils-ts/useEnhancedReducer.ts @@ -0,0 +1,77 @@ +import * as React from 'react' + +import { + type Action, + type Props, + type State, + getState, + useLatestRef, +} from '../../utils-ts' +import {callOnChangeProps} from './callOnChangeProps' + +/** + * Computes the controlled state using a the previous state, props, + * two reducers, one from downshift and an optional one from the user. + * Also calls the onChange handlers for state values that have changed. + * + * @param {Function} reducer Reducer function from downshift. + * @param {Object} props The hook props, also passed to createInitialState. + * @param {Function} createInitialState Function that returns the initial state. + * @param {Function} isStateEqual Function that checks if a previous state is equal to the next. + * @returns {Array} An array with the state and an action dispatcher. + */ +export function useEnhancedReducer< + S extends State, + P extends Partial & Props, + T, + A extends Action, +>( + reducer: (state: S, props: P, action: A) => S, + props: P, + createInitialState: (props: P) => S, + isStateEqual: (prevState: S, newState: S) => boolean, +): [S, (action: A) => void] { + const prevStateRef = React.useRef(null) + const actionRef = React.useRef(undefined) + const propsRef = useLatestRef(props) + + const enhancedReducer = React.useCallback( + (state: S, action: A): S => { + actionRef.current = action + state = getState(state, propsRef.current) + + const changes = reducer(state, propsRef.current, action) + const newState = propsRef.current.stateReducer(state, { + ...action, + changes, + }) + + return {...state, ...newState} + }, + [propsRef, reducer], + ) + const [state, dispatch] = React.useReducer( + enhancedReducer, + props, + createInitialState, + ) + + const action = actionRef.current + + React.useEffect(() => { + const prevState = getState( + prevStateRef.current ?? ({} as S), + propsRef.current, + ) + const shouldCallOnChangeProps = + action && prevStateRef.current && !isStateEqual(prevState, state) + + if (shouldCallOnChangeProps) { + callOnChangeProps(action, propsRef.current, prevState, state) + } + + prevStateRef.current = state + }, [state, action, isStateEqual, propsRef]) + + return [state, dispatch] +} diff --git a/src/hooks/utils-ts/useIsInitialMount.ts b/src/hooks/utils-ts/useIsInitialMount.ts new file mode 100644 index 000000000..68ef5559a --- /dev/null +++ b/src/hooks/utils-ts/useIsInitialMount.ts @@ -0,0 +1,18 @@ +import * as React from 'react' + +/** + * Tracks if it's the first render. + */ +export function useIsInitialMount(): boolean { + const isInitialMountRef = React.useRef(true) + + React.useEffect(() => { + isInitialMountRef.current = false + + return () => { + isInitialMountRef.current = true + } + }, []) + + return isInitialMountRef.current +} diff --git a/src/hooks/utils.dropdown/defaultProps.ts b/src/hooks/utils.dropdown/defaultProps.ts new file mode 100644 index 000000000..390873d16 --- /dev/null +++ b/src/hooks/utils.dropdown/defaultProps.ts @@ -0,0 +1,18 @@ +import {scrollIntoView} from '../../utils-ts' +import {stateReducer} from '../utils-ts' + +import {isReactNative} from '../../is.macro.js' + +export const defaultProps = { + itemToString(item: unknown) { + return item ? String(item) : '' + }, + itemToKey(item: unknown) { + return item + }, + stateReducer, + scrollIntoView, + environment: + /* istanbul ignore next (ssr) */ + typeof window === 'undefined' || isReactNative ? undefined : window, +} diff --git a/src/hooks/utils.dropdown/defaultStateValues.ts b/src/hooks/utils.dropdown/defaultStateValues.ts new file mode 100644 index 000000000..0cd17127a --- /dev/null +++ b/src/hooks/utils.dropdown/defaultStateValues.ts @@ -0,0 +1,6 @@ +export const defaultStateValues = { + highlightedIndex: -1, + isOpen: false, + selectedItem: null as unknown, + inputValue: '', +} diff --git a/src/hooks/utils.dropdown/index.ts b/src/hooks/utils.dropdown/index.ts new file mode 100644 index 000000000..05c0ed4b1 --- /dev/null +++ b/src/hooks/utils.dropdown/index.ts @@ -0,0 +1,3 @@ +export {propTypes as dropdownPropTypes} from './propTypes' +export {defaultProps as dropdownDefaultProps} from './defaultProps' +export {defaultStateValues as dropdownDefaultStateValues} from './defaultStateValues' diff --git a/src/hooks/utils.dropdown/propTypes.ts b/src/hooks/utils.dropdown/propTypes.ts new file mode 100644 index 000000000..338748077 --- /dev/null +++ b/src/hooks/utils.dropdown/propTypes.ts @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types' + +import {commonPropTypes} from '../utils-ts' + +// Shared between useSelect, useCombobox, Downshift. +export const propTypes = { + ...commonPropTypes, + getA11yStatusMessage: PropTypes.func, + highlightedIndex: PropTypes.number, + defaultHighlightedIndex: PropTypes.number, + initialHighlightedIndex: PropTypes.number, + isOpen: PropTypes.bool, + defaultIsOpen: PropTypes.bool, + initialIsOpen: PropTypes.bool, + selectedItem: PropTypes.any, + initialSelectedItem: PropTypes.any, + defaultSelectedItem: PropTypes.any, + id: PropTypes.string, + labelId: PropTypes.string, + menuId: PropTypes.string, + getItemId: PropTypes.func, + toggleButtonId: PropTypes.string, + onSelectedItemChange: PropTypes.func, + onHighlightedIndexChange: PropTypes.func, + onStateChange: PropTypes.func, + onIsOpenChange: PropTypes.func, + scrollIntoView: PropTypes.func, +} diff --git a/src/hooks/utils.js b/src/hooks/utils.js index cbc8c6e6c..bcc599ec6 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -1,85 +1,17 @@ -import React, { - useRef, - useCallback, - useReducer, - useEffect, - useLayoutEffect, - useMemo, -} from 'react' -import PropTypes from 'prop-types' +import * as React from 'react' import {isReactNative} from '../is.macro' -import { - scrollIntoView, - getState, - generateId, - debounce, - validateControlledUnchanged, - noop, - targetWithinDownshift, -} from '../utils' -import {cleanupStatusDiv, setStatus} from '../set-a11y-status' - -const dropdownDefaultStateValues = { - highlightedIndex: -1, - isOpen: false, - selectedItem: null, - inputValue: '', -} - -function callOnChangeProps(action, state, newState) { - const {props, type} = action - const changes = {} - - Object.keys(state).forEach(key => { - invokeOnChangeHandler(key, action, state, newState) - - if (newState[key] !== state[key]) { - changes[key] = newState[key] - } - }) - - if (props.onStateChange && Object.keys(changes).length) { - props.onStateChange({type, ...changes}) - } -} - -function invokeOnChangeHandler(key, action, state, newState) { - const {props, type} = action - const handler = `on${capitalizeString(key)}Change` - if ( - props[handler] && - newState[key] !== undefined && - newState[key] !== state[key] - ) { - props[handler]({type, ...newState}) - } -} - -/** - * Default state reducer that returns the changes. - * - * @param {Object} s state. - * @param {Object} a action with changes. - * @returns {Object} changes. - */ -function stateReducer(s, a) { - return a.changes -} - -/** - * Debounced call for updating the a11y message. - */ -const updateA11yStatus = debounce((status, document) => { - setStatus(status, document) -}, 200) +import {validateControlledUnchanged, targetWithinDownshift} from '../utils' +import {generateId, noop} from '../utils-ts' +import {useIsInitialMount, getDefaultValue, getInitialValue} from './utils-ts' +import {dropdownDefaultStateValues} from './utils.dropdown' // istanbul ignore next const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' - ? useLayoutEffect - : useEffect + ? React.useLayoutEffect + : React.useEffect // istanbul ignore next const useElementIds = @@ -98,7 +30,7 @@ const useElementIds = id = reactId } - const elementIds = useMemo( + const elementIds = React.useMemo( () => ({ labelId: labelId || `${id}-label`, menuId: menuId || `${id}-menu`, @@ -119,7 +51,7 @@ const useElementIds = toggleButtonId, inputId, }) { - const elementIds = useMemo( + const elementIds = React.useMemo( () => ({ labelId: labelId || `${id}-label`, menuId: menuId || `${id}-menu`, @@ -133,176 +65,24 @@ const useElementIds = return elementIds } -function getItemAndIndex(itemProp, indexProp, items, errorMessage) { - let item, index - - if (itemProp === undefined) { - if (indexProp === undefined) { - throw new Error(errorMessage) - } - - item = items[indexProp] - index = indexProp - } else { - index = indexProp === undefined ? items.indexOf(itemProp) : indexProp - item = itemProp - } - - return [item, index] -} - function isAcceptedCharacterKey(key) { return /^\S{1}$/.test(key) } -function capitalizeString(string) { - return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` -} - -function useLatestRef(val) { - const ref = useRef(val) - // technically this is not "concurrent mode safe" because we're manipulating - // the value during render (so it's not idempotent). However, the places this - // hook is used is to support memoizing callbacks which will be called - // *during* render, so we need the latest values *during* render. - // If not for this, then we'd probably want to use useLayoutEffect instead. - ref.current = val - return ref -} - -/** - * Computes the controlled state using a the previous state, props, - * two reducers, one from downshift and an optional one from the user. - * Also calls the onChange handlers for state values that have changed. - * - * @param {Function} reducer Reducer function from downshift. - * @param {Object} props The hook props, also passed to createInitialState. - * @param {Function} createInitialState Function that returns the initial state. - * @param {Function} isStateEqual Function that checks if a previous state is equal to the next. - * @returns {Array} An array with the state and an action dispatcher. - */ -function useEnhancedReducer(reducer, props, createInitialState, isStateEqual) { - const prevStateRef = useRef() - const actionRef = useRef() - const enhancedReducer = useCallback( - (state, action) => { - actionRef.current = action - state = getState(state, action.props) - - const changes = reducer(state, action) - const newState = action.props.stateReducer(state, {...action, changes}) - - return newState - }, - [reducer], - ) - const [state, dispatch] = useReducer( - enhancedReducer, +function getInitialState(props) { + const selectedItem = getInitialValue( props, - createInitialState, - ) - const propsRef = useLatestRef(props) - const dispatchWithProps = useCallback( - action => dispatch({props: propsRef.current, ...action}), - [propsRef], + 'selectedItem', + dropdownDefaultStateValues, ) - const action = actionRef.current - - useEffect(() => { - const prevState = getState(prevStateRef.current, action?.props) - const shouldCallOnChangeProps = - action && prevStateRef.current && !isStateEqual(prevState, state) - - if (shouldCallOnChangeProps) { - callOnChangeProps(action, prevState, state) - } - - prevStateRef.current = state - }, [state, action, isStateEqual]) - - return [state, dispatchWithProps] -} - -/** - * Wraps the useEnhancedReducer and applies the controlled prop values before - * returning the new state. - * - * @param {Function} reducer Reducer function from downshift. - * @param {Object} props The hook props, also passed to createInitialState. - * @param {Function} createInitialState Function that returns the initial state. - * @param {Function} isStateEqual Function that checks if a previous state is equal to the next. - * @returns {Array} An array with the state and an action dispatcher. - */ -function useControlledReducer( - reducer, - props, - createInitialState, - isStateEqual, -) { - const [state, dispatch] = useEnhancedReducer( - reducer, + const isOpen = getInitialValue(props, 'isOpen', dropdownDefaultStateValues) + const highlightedIndex = getInitialHighlightedIndex(props) + const inputValue = getInitialValue( props, - createInitialState, - isStateEqual, + 'inputValue', + dropdownDefaultStateValues, ) - return [getState(state, props), dispatch] -} - -const defaultProps = { - itemToString(item) { - return item ? String(item) : '' - }, - itemToKey(item) { - return item - }, - stateReducer, - scrollIntoView, - environment: - /* istanbul ignore next (ssr) */ - typeof window === 'undefined' || isReactNative ? undefined : window, -} - -function getDefaultValue( - props, - propKey, - defaultStateValues = dropdownDefaultStateValues, -) { - const defaultValue = props[`default${capitalizeString(propKey)}`] - - if (defaultValue !== undefined) { - return defaultValue - } - - return defaultStateValues[propKey] -} - -function getInitialValue( - props, - propKey, - defaultStateValues = dropdownDefaultStateValues, -) { - const value = props[propKey] - - if (value !== undefined) { - return value - } - - const initialValue = props[`initial${capitalizeString(propKey)}`] - - if (initialValue !== undefined) { - return initialValue - } - - return getDefaultValue(props, propKey, defaultStateValues) -} - -function getInitialState(props) { - const selectedItem = getInitialValue(props, 'selectedItem') - const isOpen = getInitialValue(props, 'isOpen') - const highlightedIndex = getInitialHighlightedIndex(props) - const inputValue = getInitialValue(props, 'inputValue') - return { highlightedIndex: highlightedIndex < 0 && selectedItem && isOpen @@ -376,18 +156,18 @@ function useMouseAndTouchTracker( handleBlur, downshiftRefs, ) { - const mouseAndTouchTrackersRef = useRef({ + const mouseAndTouchTrackersRef = React.useRef({ isMouseDown: false, isTouchMove: false, isTouchEnd: false, }) -const getDownshiftElements = useCallback( +const getDownshiftElements = React.useCallback( () => downshiftRefs.map(ref => ref.current), [downshiftRefs], ); - useEffect(() => { + React.useEffect(() => { if (isReactNative || !environment) { return noop } @@ -400,7 +180,11 @@ const getDownshiftElements = useCallback( mouseAndTouchTrackersRef.current.isMouseDown = false if ( - !targetWithinDownshift(event.target, getDownshiftElements(), environment) + !targetWithinDownshift( + event.target, + getDownshiftElements(), + environment, + ) ) { handleBlur() } @@ -458,7 +242,7 @@ let useGetterPropsCalledChecker = () => noop /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { useGetterPropsCalledChecker = (...propKeys) => { - const getterPropsCalledRef = useRef( + const getterPropsCalledRef = React.useRef( propKeys.reduce((acc, propKey) => { acc[propKey] = {} @@ -466,7 +250,7 @@ if (process.env.NODE_ENV !== 'production') { }, {}), ) - useEffect(() => { + React.useEffect(() => { Object.keys(getterPropsCalledRef.current).forEach(propKey => { const propCallInfo = getterPropsCalledRef.current[propKey] @@ -493,7 +277,7 @@ if (process.env.NODE_ENV !== 'production') { }) }, []) - const setGetterPropCallInfo = useCallback( + const setGetterPropCallInfo = React.useCallback( (propKey, suppressRefError, refKey, elementRef) => { getterPropsCalledRef.current[propKey] = { suppressRefError, @@ -508,44 +292,6 @@ if (process.env.NODE_ENV !== 'production') { } } -/** - * 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, - environment = {}, -) { - const document = environment.document - const isInitialMount = useIsInitialMount() - - // Adds an a11y aria live status message if getA11yStatusMessage is passed. - useEffect(() => { - if (!getA11yStatusMessage || isInitialMount || isReactNative || !document) { - return - } - - const status = getA11yStatusMessage(options) - - updateA11yStatus(status, document) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencyArray) - - // Cleanup the status message container. - useEffect(() => { - return () => { - updateA11yStatus.cancel() - cleanupStatusDiv(document) - } - }, [document]) -} - function useScrollIntoView({ highlightedIndex, isOpen, @@ -555,7 +301,7 @@ function useScrollIntoView({ scrollIntoView: scrollIntoViewProp, }) { // used not to scroll on highlight by mouse. - const shouldScrollRef = useRef(true) + const shouldScrollRef = React.useRef(true) // Scroll on highlighted item if change comes from keyboard. useIsomorphicLayoutEffect(() => { if ( @@ -583,10 +329,10 @@ let useControlPropsValidator = noop if (process.env.NODE_ENV !== 'production') { useControlPropsValidator = ({props, state}) => { // used for checking when props are moving from controlled to uncontrolled. - const prevPropsRef = useRef(props) + const prevPropsRef = React.useRef(props) const isInitialMount = useIsInitialMount() - useEffect(() => { + React.useEffect(() => { if (isInitialMount) { return } @@ -613,8 +359,12 @@ function getChangesOnSelection(props, highlightedIndex, inputValue = true) { highlightedIndex: -1, ...(shouldSelect && { selectedItem: props.items[highlightedIndex], - isOpen: getDefaultValue(props, 'isOpen'), - highlightedIndex: getDefaultValue(props, 'highlightedIndex'), + isOpen: getDefaultValue(props, 'isOpen', dropdownDefaultStateValues), + highlightedIndex: getDefaultValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ), ...(inputValue && { inputValue: props.itemToString(props.items[highlightedIndex]), }), @@ -639,23 +389,6 @@ function isDropdownsStateEqual(prevState, newState) { ) } -/** - * Tracks if it's the first render. - */ -function useIsInitialMount() { - const isInitialMountRef = React.useRef(true) - - React.useEffect(() => { - isInitialMountRef.current = false - - return () => { - isInitialMountRef.current = true - } - }, []) - - return isInitialMountRef.current -} - /** * Returns the new highlightedIndex based on the defaultHighlightedIndex prop, if it's not disabled. * @@ -663,7 +396,11 @@ function useIsInitialMount() { * @returns {number} The highlighted index. */ function getDefaultHighlightedIndex(props) { - const highlightedIndex = getDefaultValue(props, 'highlightedIndex') + const highlightedIndex = getDefaultValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ) if ( highlightedIndex > -1 && props.isItemDisabled(props.items[highlightedIndex], highlightedIndex) @@ -681,7 +418,11 @@ function getDefaultHighlightedIndex(props) { * @returns {number} The highlighted index. */ function getInitialHighlightedIndex(props) { - const highlightedIndex = getInitialValue(props, 'highlightedIndex') + const highlightedIndex = getInitialValue( + props, + 'highlightedIndex', + dropdownDefaultStateValues, + ) if ( highlightedIndex > -1 && @@ -693,72 +434,16 @@ function getInitialHighlightedIndex(props) { return highlightedIndex } -// Shared between all exports. -const commonPropTypes = { - environment: PropTypes.shape({ - addEventListener: PropTypes.func.isRequired, - removeEventListener: PropTypes.func.isRequired, - document: PropTypes.shape({ - createElement: PropTypes.func.isRequired, - getElementById: PropTypes.func.isRequired, - activeElement: PropTypes.any.isRequired, - body: PropTypes.any.isRequired, - }).isRequired, - Node: PropTypes.func.isRequired, - }), - itemToString: PropTypes.func, - itemToKey: PropTypes.func, - stateReducer: PropTypes.func, -} - -// Shared between useSelect, useCombobox, Downshift. -const commonDropdownPropTypes = { - ...commonPropTypes, - getA11yStatusMessage: PropTypes.func, - highlightedIndex: PropTypes.number, - defaultHighlightedIndex: PropTypes.number, - initialHighlightedIndex: PropTypes.number, - isOpen: PropTypes.bool, - defaultIsOpen: PropTypes.bool, - initialIsOpen: PropTypes.bool, - selectedItem: PropTypes.any, - initialSelectedItem: PropTypes.any, - defaultSelectedItem: PropTypes.any, - id: PropTypes.string, - labelId: PropTypes.string, - menuId: PropTypes.string, - getItemId: PropTypes.func, - toggleButtonId: PropTypes.string, - onSelectedItemChange: PropTypes.func, - onHighlightedIndexChange: PropTypes.func, - onStateChange: PropTypes.func, - onIsOpenChange: PropTypes.func, - scrollIntoView: PropTypes.func, -} - export { useControlPropsValidator, useScrollIntoView, - updateA11yStatus, useGetterPropsCalledChecker, useMouseAndTouchTracker, getHighlightedIndexOnOpen, - getInitialState, - getInitialValue, - getDefaultValue, - defaultProps, - useControlledReducer, - useEnhancedReducer, - useLatestRef, - capitalizeString, isAcceptedCharacterKey, - getItemAndIndex, useElementIds, getChangesOnSelection, isDropdownsStateEqual, - commonDropdownPropTypes, - commonPropTypes, - useIsInitialMount, - useA11yMessageStatus, getDefaultHighlightedIndex, + getInitialState, } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index ef3656b33..000000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export {default} from './downshift' -export {resetIdCounter} from './utils' -export {useSelect, useCombobox, useMultipleSelection} from './hooks' diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..357106112 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export {default} from './downshift' +export {resetIdCounter} from './utils-ts' +export {useSelect, useCombobox, useMultipleSelection, useTagGroup} from './hooks' diff --git a/src/is.macro.d.ts b/src/is.macro.d.ts new file mode 100644 index 000000000..a094c6a93 --- /dev/null +++ b/src/is.macro.d.ts @@ -0,0 +1,3 @@ +export declare const isPreact: boolean, + isReactNative: boolean, + isReactNativeWeb: boolean diff --git a/src/productionEnum.macro.d.ts b/src/productionEnum.macro.d.ts new file mode 100644 index 000000000..7b429744b --- /dev/null +++ b/src/productionEnum.macro.d.ts @@ -0,0 +1,3 @@ +declare function productionEnum(value: T): T; + +export default productionEnum; \ No newline at end of file diff --git a/src/set-a11y-status.js b/src/set-a11y-status.js deleted file mode 100644 index 3beecf35b..000000000 --- a/src/set-a11y-status.js +++ /dev/null @@ -1,62 +0,0 @@ -import {debounce} from './utils' - -const cleanupStatus = debounce(documentProp => { - getStatusDiv(documentProp).textContent = '' -}, 500) - -/** - * Get the status node or create it if it does not already exist. - * @param {Object} documentProp document passed by the user. - * @return {HTMLElement} the status node. - */ -function getStatusDiv(documentProp) { - let statusDiv = documentProp.getElementById('a11y-status-message') - if (statusDiv) { - return statusDiv - } - - statusDiv = documentProp.createElement('div') - statusDiv.setAttribute('id', 'a11y-status-message') - statusDiv.setAttribute('role', 'status') - statusDiv.setAttribute('aria-live', 'polite') - statusDiv.setAttribute('aria-relevant', 'additions text') - Object.assign(statusDiv.style, { - border: '0', - clip: 'rect(0 0 0 0)', - height: '1px', - margin: '-1px', - overflow: 'hidden', - padding: '0', - position: 'absolute', - width: '1px', - }) - documentProp.body.appendChild(statusDiv) - return statusDiv -} - -/** - * @param {String} status the status message - * @param {Object} documentProp document passed by the user. - */ -export function setStatus(status, documentProp) { - if (!status || !documentProp) { - return - } - - const div = getStatusDiv(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/src/types.ts b/src/types.ts deleted file mode 100644 index 5ca882bdf..000000000 --- a/src/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* External Types */ - -export interface A11yStatusMessageOptions { - highlightedIndex: number | null - inputValue: string - isOpen: boolean - itemToString: (item: Item | null) => string - previousResultCount: number - resultCount: number - highlightedItem: Item - selectedItem: Item | null -} diff --git a/src/utils-ts/__tests__/getState.test.ts b/src/utils-ts/__tests__/getState.test.ts new file mode 100644 index 000000000..4feaac8af --- /dev/null +++ b/src/utils-ts/__tests__/getState.test.ts @@ -0,0 +1,14 @@ +import {getState, Props} from '../getState' + +test('returns state if no props are passed', () => { + const state = {a: 'b'} + + expect(getState(state, undefined)).toEqual(state) +}) + +test('merges state with props', () => { + const state = {a: 'b', c: 'd'} + const props = {b: 'e', c: 'f'} as unknown as Props + + expect(getState(state, props)).toEqual({a: 'b', c: 'f'}) +}) diff --git a/src/utils-ts/__tests__/handleRefs.test.ts b/src/utils-ts/__tests__/handleRefs.test.ts new file mode 100644 index 000000000..78437df1f --- /dev/null +++ b/src/utils-ts/__tests__/handleRefs.test.ts @@ -0,0 +1,17 @@ +import {handleRefs} from '../handleRefs' + +test('handleRefs handles both ref functions and objects', () => { + const refFunction = jest.fn() as unknown as React.RefCallback + const refObject = { + current: null, + } as unknown as React.MutableRefObject + const refs = [refFunction, refObject] + const node = {} as unknown as HTMLElement + const ref = handleRefs(...refs) + + ref(node) + + expect(refFunction).toHaveBeenCalledTimes(1) + expect(refFunction).toHaveBeenCalledWith(node) + expect(refObject.current).toEqual(node) +}) diff --git a/src/utils-ts/callAllEventHandlers.ts b/src/utils-ts/callAllEventHandlers.ts new file mode 100644 index 000000000..4e4116a78 --- /dev/null +++ b/src/utils-ts/callAllEventHandlers.ts @@ -0,0 +1,26 @@ +/** + * This is intended to be used to compose event handlers. + * They are executed in order until one of them sets + * `event.preventDownshiftDefault = true`. + * @param fns the event handler functions + * @return the event handler to add to an element + */ +export function callAllEventHandlers(...fns: (Function | undefined)[]) { + return ( + event: React.SyntheticEvent & { + preventDownshiftDefault?: boolean + nativeEvent: {preventDownshiftDefault?: boolean} + }, + ...args: unknown[] + ) => + fns.some(fn => { + if (fn) { + fn(event, ...args) + } + return ( + event.preventDownshiftDefault || + (event.hasOwnProperty('nativeEvent') && + event.nativeEvent.preventDownshiftDefault) + ) + }) +} diff --git a/src/utils-ts/debounce.ts b/src/utils-ts/debounce.ts new file mode 100644 index 000000000..6f2fccf55 --- /dev/null +++ b/src/utils-ts/debounce.ts @@ -0,0 +1,29 @@ +/** + * Simple debounce implementation. Will call the given + * function once after the time given has passed since + * it was last called. + */ +export function debounce( + fn: Function, + time: number, +): Function & {cancel: Function} { + let timeoutId: NodeJS.Timeout | undefined | null + + function cancel() { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + + function wrapper(...args: unknown[]) { + cancel() + timeoutId = setTimeout(() => { + timeoutId = null + fn(...args) + }, time) + } + + wrapper.cancel = cancel + + return wrapper +} diff --git a/src/utils-ts/generateId.ts b/src/utils-ts/generateId.ts new file mode 100644 index 000000000..9d4a94024 --- /dev/null +++ b/src/utils-ts/generateId.ts @@ -0,0 +1,35 @@ +import * as React from 'react' + +let idCounter = 0 + +/** + * This generates a unique ID for an instance of Downshift + * @return {string} the unique ID + */ +export function generateId(): string { + return String(idCounter++) +} + +/** + * This is only used in tests + * @param {number} num the number to set the idCounter to + */ +export function setIdCounter(num: number): void { + idCounter = num +} + +/** + * Resets idCounter to 0. Used for SSR. + */ +export function resetIdCounter() { + // istanbul ignore next + if ('useId' in React) { + console.warn( + `It is not necessary to call resetIdCounter when using React 18+`, + ) + + return + } + + idCounter = 0 +} diff --git a/src/utils-ts/getState.ts b/src/utils-ts/getState.ts new file mode 100644 index 000000000..f49720a5d --- /dev/null +++ b/src/utils-ts/getState.ts @@ -0,0 +1,46 @@ +export interface Action extends Record { + type: T +} + +export type State = Record + +export interface Props { + onStateChange?(typeAndChanges: unknown): void + stateReducer( + state: S, + actionAndChanges: Action & {changes: Partial}, + ): Partial +} + +/** + * This will perform a shallow merge of the given state object + * with the state coming from props + * (for the controlled component scenario) + * This is used in state updater functions so they're referencing + * the right state regardless of where it comes from. + * + * @param state The state of the component/hook. + * @param props The props that may contain controlled values. + * @returns The merged controlled state. + */ +export function getState< + S extends State, + P extends Partial & Props, + T, +>(state: S, props?: P): S { + if (!props) { + return state + } + + const keys = Object.keys(state) as (keyof S)[] + + return keys.reduce( + (newState, key) => { + if (props[key] !== undefined) { + newState[key] = (props as Partial)[key] as S[typeof key] + } + return newState + }, + {...state}, + ) +} diff --git a/src/utils-ts/handleRefs.ts b/src/utils-ts/handleRefs.ts new file mode 100644 index 000000000..0463b02ac --- /dev/null +++ b/src/utils-ts/handleRefs.ts @@ -0,0 +1,19 @@ +import * as React from 'react' + +export function handleRefs( + ...refs: ( + | React.MutableRefObject + | React.RefCallback + | undefined + )[] +) { + return (node: HTMLElement) => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }) + } +} diff --git a/src/utils-ts/index.ts b/src/utils-ts/index.ts new file mode 100644 index 000000000..afd68dce9 --- /dev/null +++ b/src/utils-ts/index.ts @@ -0,0 +1,11 @@ +export {generateId, setIdCounter, resetIdCounter} from './generateId' +export {useLatestRef} from './useLatestRef' +export {handleRefs} from './handleRefs' +export {callAllEventHandlers} from './callAllEventHandlers' +export {debounce} from './debounce' +export {setStatus, cleanupStatusDiv} from './setA11yStatus' +export {noop} from './noop' +export {validatePropTypes} from './validatePropTypes' +export {getState} from './getState' +export type {Action, Props, State} from './getState' +export {scrollIntoView} from './scrollIntoView' diff --git a/src/utils-ts/noop.ts b/src/utils-ts/noop.ts new file mode 100644 index 000000000..177804c7a --- /dev/null +++ b/src/utils-ts/noop.ts @@ -0,0 +1 @@ +export function noop() {} diff --git a/src/utils-ts/scrollIntoView.ts b/src/utils-ts/scrollIntoView.ts new file mode 100644 index 000000000..f61c2a41f --- /dev/null +++ b/src/utils-ts/scrollIntoView.ts @@ -0,0 +1,25 @@ +import {compute} from 'compute-scroll-into-view' + +/** + * Scroll node into view if necessary + * @param {HTMLElement} node the element that should scroll into view + * @param {HTMLElement} menuNode the menu element of the component + */ +export function scrollIntoView( + node: HTMLElement | undefined, + menuNode: HTMLElement | undefined, +) { + if (!node) { + return + } + + const actions = compute(node, { + boundary: menuNode, + block: 'nearest', + scrollMode: 'if-needed', + }) + actions.forEach(({el, top, left}) => { + el.scrollTop = top + el.scrollLeft = left + }) +} diff --git a/src/utils-ts/setA11yStatus.ts b/src/utils-ts/setA11yStatus.ts new file mode 100644 index 000000000..a0ec4b888 --- /dev/null +++ b/src/utils-ts/setA11yStatus.ts @@ -0,0 +1,59 @@ +import {debounce} from './debounce' + +const cleanupStatus = debounce((document: Document) => { + getStatusDiv(document).textContent = '' +}, 500) + +/** + * Get the status node or create it if it does not already exist. + */ +function getStatusDiv(document: Document) { + let statusDiv = document.getElementById('a11y-status-message') + if (statusDiv) { + return statusDiv + } + + statusDiv = document.createElement('div') + statusDiv.setAttribute('id', 'a11y-status-message') + statusDiv.setAttribute('role', 'status') + statusDiv.setAttribute('aria-live', 'polite') + statusDiv.setAttribute('aria-relevant', 'additions text') + Object.assign(statusDiv.style, { + border: '0', + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: '0', + position: 'absolute', + width: '1px', + }) + + document.body.appendChild(statusDiv) + return statusDiv +} + +/** + * Sets aria live status to a div element that's visually hidden. + */ +export function setStatus(status: string, document: Document | undefined) { + if (!status || !document) { + return + } + + const div = getStatusDiv(document) + + div.textContent = status + cleanupStatus(document) +} + +/** + * Removes the status element from the DOM + */ +export function cleanupStatusDiv(document: Document | undefined) { + const statusDiv = document?.getElementById('a11y-status-message') + + if (statusDiv) { + statusDiv.remove() + } +} diff --git a/src/utils-ts/useLatestRef.ts b/src/utils-ts/useLatestRef.ts new file mode 100644 index 000000000..cd23e096a --- /dev/null +++ b/src/utils-ts/useLatestRef.ts @@ -0,0 +1,12 @@ +import * as React from 'react' + +export function useLatestRef(val: T): React.MutableRefObject { + const ref = React.useRef(val) + // technically this is not "concurrent mode safe" because we're manipulating + // the value during render (so it's not idempotent). However, the places this + // hook is used is to support memoizing callbacks which will be called + // *during* render, so we need the latest values *during* render. + // If not for this, then we'd probably want to use useLayoutEffect instead. + ref.current = val + return ref +} diff --git a/src/utils-ts/validatePropTypes.ts b/src/utils-ts/validatePropTypes.ts new file mode 100644 index 000000000..a57f3fdb5 --- /dev/null +++ b/src/utils-ts/validatePropTypes.ts @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types' +import {noop} from './noop' + +// eslint-disable-next-line import/no-mutable-exports +export let validatePropTypes = noop as ( + options: unknown, + caller: Function, + propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> + >, +) => void +/* istanbul ignore next */ +if (process.env.NODE_ENV !== 'production') { + validatePropTypes = ( + options: unknown, + caller: Function, + propTypes: Record< + string, + PropTypes.Requireable<(...args: unknown[]) => unknown> + >, + ): void => { + PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name) + } +} diff --git a/src/utils.js b/src/utils.js index 23b992236..aa168abe5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,8 +1,5 @@ -import {compute} from 'compute-scroll-into-view' -import React from 'react' import {isPreact} from './is.macro' - -let idCounter = 0 +import { noop } from './utils-ts' /** * Accepts a parameter and returns it if it's a function @@ -16,29 +13,6 @@ function cbToCb(cb) { return typeof cb === 'function' ? cb : noop } -function noop() {} - -/** - * Scroll node into view if necessary - * @param {HTMLElement} node the element that should scroll into view - * @param {HTMLElement} menuNode the menu element of the component - */ -function scrollIntoView(node, menuNode) { - if (!node) { - return - } - - const actions = compute(node, { - boundary: menuNode, - block: 'nearest', - scrollMode: 'if-needed', - }) - actions.forEach(({el, top, left}) => { - el.scrollTop = top - el.scrollLeft = left - }) -} - /** * @param {HTMLElement} parent the parent node * @param {HTMLElement} child the child node @@ -117,38 +91,6 @@ function handleRefs(...refs) { } } -/** - * This generates a unique ID for an instance of Downshift - * @return {String} the unique ID - */ -function generateId() { - return String(idCounter++) -} - -/** - * This is only used in tests - * @param {Number} num the number to set the idCounter to - */ -function setIdCounter(num) { - idCounter = num -} - -/** - * Resets idCounter to 0. Used for SSR. - */ -function resetIdCounter() { - // istanbul ignore next - if ('useId' in React) { - console.warn( - `It is not necessary to call resetIdCounter when using React 18+`, - ) - - return - } - - idCounter = 0 -} - /** * Default implementation for status message. Only added when menu is open. * Will specify if there are results in the list, and if so, how many, @@ -255,29 +197,6 @@ function pickState(state = {}) { return result } -/** - * This will perform a shallow merge of the given state object - * with the state coming from props - * (for the controlled component scenario) - * This is used in state updater functions so they're referencing - * the right state regardless of where it comes from. - * - * @param {Object} state The state of the component/hook. - * @param {Object} props The props that may contain controlled values. - * @returns {Object} The merged controlled state. - */ -function getState(state, props) { - if (!state || !props) { - return state - } - - return Object.keys(state).reduce((prevState, key) => { - prevState[key] = isControlledProp(props, key) ? props[key] : state[key] - - return prevState - }, {}) -} - /** * This determines whether a prop is a "controlled prop" meaning it is * state which is controlled by the outside of this component rather @@ -476,21 +395,15 @@ export { callAllEventHandlers, handleRefs, debounce, - scrollIntoView, - generateId, getA11yStatusMessage, unwrapArray, isDOMElement, getElementProps, - noop, requiredProp, - setIdCounter, - resetIdCounter, pickState, isPlainObject, normalizeArrowKey, targetWithinDownshift, - getState, isControlledProp, validateControlledUnchanged, getHighlightedIndex, diff --git a/test/basic.test.tsx b/test/basic.test.tsx index b5f9b7cd5..4270e67d0 100644 --- a/test/basic.test.tsx +++ b/test/basic.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import Downshift, {StateChangeOptions} from '../' +import Downshift, {StateChangeOptions} from '..' type Item = string diff --git a/test/custom.test.tsx b/test/custom.test.tsx index 218e50a2a..90f8aaa0f 100644 --- a/test/custom.test.tsx +++ b/test/custom.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import Downshift, {ControllerStateAndHelpers} from '../' +import Downshift, { ControllerStateAndHelpers } from '..' type Item = string diff --git a/test/downshift.test.tsx b/test/downshift.test.tsx index 0b9c00e1d..32dc27587 100644 --- a/test/downshift.test.tsx +++ b/test/downshift.test.tsx @@ -1,5 +1,4 @@ import * as React from 'react' - import Downshift from '..' export const colors = [ diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 000000000..e78e4d116 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + }, + "include": ["../typings/index.d.ts"], + "exclude": [] +} \ No newline at end of file diff --git a/test/useCombobox.test.tsx b/test/useCombobox.test.tsx index 08cd85286..d911770ed 100644 --- a/test/useCombobox.test.tsx +++ b/test/useCombobox.test.tsx @@ -1,5 +1,4 @@ import * as React from 'react' - import {useCombobox} from '..' export const colors = [ diff --git a/tsconfig.json b/tsconfig.json index 67a6c4dcd..baeaf112d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ - { +{ "compilerOptions": { - "noEmit": true, "allowJs": true, "esModuleInterop": true, "jsx": "react", @@ -10,13 +9,21 @@ "resolveJsonModule": true, "noUncheckedIndexedAccess": true, "module": "ESNext", - "typeRoots": ["./typings", "./node_modules/@types"], + "typeRoots": [ + "./typings", + "./node_modules/@types", + "./node_modules/@testing-library" + ], "strictNullChecks": true, "outDir": "dist", + "declaration": true, + "declarationDir": "dist", + "emitDeclarationOnly": true, + "baseUrl": ".", + "paths": { + "*": ["*"] + } }, - "include": [ - "typings/**/*.d.ts", - "**/*.ts", - "**/*.tsx", - ], -} \ No newline at end of file + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["typings"] +} diff --git a/typings/index.d.ts b/typings/index.d.ts index 342f4c7dd..a53512ba2 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,906 +1,23 @@ -import * as React from 'react' - -type Callback = () => void - -type Overwrite = Pick> & U - -export interface DownshiftState { - highlightedIndex: number | null - inputValue: string | null - isOpen: boolean - selectedItem: Item | null -} - -export enum StateChangeTypes { - unknown = '__autocomplete_unknown__', - mouseUp = '__autocomplete_mouseup__', - itemMouseEnter = '__autocomplete_item_mouseenter__', - keyDownArrowUp = '__autocomplete_keydown_arrow_up__', - keyDownArrowDown = '__autocomplete_keydown_arrow_down__', - keyDownEscape = '__autocomplete_keydown_escape__', - keyDownEnter = '__autocomplete_keydown_enter__', - clickItem = '__autocomplete_click_item__', - blurInput = '__autocomplete_blur_input__', - changeInput = '__autocomplete_change_input__', - keyDownSpaceButton = '__autocomplete_keydown_space_button__', - clickButton = '__autocomplete_click_button__', - blurButton = '__autocomplete_blur_button__', - controlledPropUpdatedSelectedItem = '__autocomplete_controlled_prop_updated_selected_item__', - touchEnd = '__autocomplete_touchend__', -} - -export interface DownshiftProps { - initialSelectedItem?: Item - initialInputValue?: string - initialHighlightedIndex?: number | null - initialIsOpen?: boolean - defaultHighlightedIndex?: number | null - defaultIsOpen?: boolean - itemToString?: (item: Item | null) => string - selectedItemChanged?: (prevItem: Item, item: Item) => boolean - getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string - onChange?: ( - selectedItem: Item | null, - stateAndHelpers: ControllerStateAndHelpers, - ) => void - onSelect?: ( - selectedItem: Item | null, - stateAndHelpers: ControllerStateAndHelpers, - ) => void - onStateChange?: ( - options: StateChangeOptions, - stateAndHelpers: ControllerStateAndHelpers, - ) => void - onInputValueChange?: ( - inputValue: string, - stateAndHelpers: ControllerStateAndHelpers, - ) => void - stateReducer?: ( - state: DownshiftState, - changes: StateChangeOptions, - ) => Partial> - itemCount?: number - highlightedIndex?: number | null - inputValue?: string | null - isOpen?: boolean - selectedItem?: Item | null - children?: ChildrenFunction - id?: string - inputId?: string - labelId?: string - menuId?: string - getItemId?: (index?: number) => string - environment?: Environment - onOuterClick?: (stateAndHelpers: ControllerStateAndHelpers) => void - scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void - onUserAction?: ( - options: StateChangeOptions, - stateAndHelpers: ControllerStateAndHelpers, - ) => void - suppressRefError?: boolean -} - -export interface Environment { - addEventListener: typeof window.addEventListener - removeEventListener: typeof window.removeEventListener - document: Document - Node: typeof window.Node -} - -export interface A11yStatusMessageOptions { - highlightedIndex: number | null - inputValue: string - isOpen: boolean - itemToString: (item: Item | null) => string - previousResultCount: number - resultCount: number - highlightedItem: Item - selectedItem: Item | null -} - -export interface StateChangeOptions - extends Partial> { - type: StateChangeTypes -} - -type StateChangeFunction = ( - state: DownshiftState, -) => Partial> - -export interface GetRootPropsOptions { - refKey?: string - ref?: React.RefObject -} - -export interface GetRootPropsReturnValue { - 'aria-expanded': boolean - 'aria-haspopup': 'listbox' - 'aria-labelledby': string - 'aria-owns': string | undefined - ref?: React.RefObject - role: 'combobox' -} - -export interface GetInputPropsOptions - extends React.HTMLProps { - disabled?: boolean -} - -export interface GetInputPropsReturnValue { - 'aria-autocomplete': 'list' - 'aria-activedescendant': string | undefined - 'aria-controls': string | undefined - 'aria-labelledby': string | undefined - autoComplete: 'off' - id: string - onChange?: React.ChangeEventHandler - onChangeText?: React.ChangeEventHandler - onInput?: React.FormEventHandler - onKeyDown?: React.KeyboardEventHandler - onBlur?: React.FocusEventHandler - value: string -} - -export interface GetLabelPropsOptions - extends React.HTMLProps {} - -export interface GetLabelPropsReturnValue { - htmlFor: string - id: string -} - -export interface GetToggleButtonPropsOptions - extends React.HTMLProps { - disabled?: boolean - onPress?: (event: React.BaseSyntheticEvent) => void -} - -interface GetToggleButtonPropsReturnValue { - 'aria-label': 'close menu' | 'open menu' - 'aria-haspopup': true - 'data-toggle': true - onPress?: (event: React.BaseSyntheticEvent) => void - onClick?: React.MouseEventHandler - onKeyDown?: React.KeyboardEventHandler - onKeyUp?: React.KeyboardEventHandler - onBlur?: React.FocusEventHandler - role: 'button' - type: 'button' -} -export interface GetMenuPropsOptions - extends React.HTMLProps, - GetPropsWithRefKey { - ['aria-label']?: string -} - -export interface GetMenuPropsReturnValue { - 'aria-labelledby': string | undefined - ref?: React.RefObject - role: 'listbox' - id: string -} - -export interface GetPropsCommonOptions { - suppressRefError?: boolean -} - -export interface GetPropsWithRefKey { - refKey?: string -} - -export interface GetItemPropsOptions - extends React.HTMLProps { - index?: number - item: Item - isSelected?: boolean - disabled?: boolean -} - -export interface GetItemPropsReturnValue { - 'aria-selected': boolean - id: string - onClick?: React.MouseEventHandler - onMouseDown?: React.MouseEventHandler - onMouseMove?: React.MouseEventHandler - onPress?: React.MouseEventHandler - role: 'option' -} - -export interface PropGetters { - getRootProps: ( - options?: GetRootPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite - getToggleButtonProps: ( - options?: GetToggleButtonPropsOptions & Options, - ) => Overwrite - getLabelProps: ( - options?: GetLabelPropsOptions & Options, - ) => Overwrite - getMenuProps: ( - options?: GetMenuPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite - getInputProps: ( - options?: GetInputPropsOptions & Options, - ) => Overwrite - getItemProps: ( - options: GetItemPropsOptions & Options, - ) => Omit, 'index' | 'item'> -} - -export interface Actions { - reset: ( - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - openMenu: (cb?: Callback) => void - closeMenu: (cb?: Callback) => void - toggleMenu: ( - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - selectItem: ( - item: Item | null, - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - selectItemAtIndex: ( - index: number, - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - selectHighlightedItem: ( - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - setHighlightedIndex: ( - index: number, - otherStateToSet?: Partial>, - cb?: Callback, - ) => void - clearSelection: (cb?: Callback) => void - clearItems: () => void - setItemCount: (count: number) => void - unsetItemCount: () => void - setState: ( - stateToSet: Partial> | StateChangeFunction, - cb?: Callback, - ) => void - // props - itemToString: (item: Item | null) => string -} - -export type ControllerStateAndHelpers = DownshiftState & - PropGetters & - Actions - -export type ChildrenFunction = ( - options: ControllerStateAndHelpers, -) => React.ReactNode - -export default class Downshift extends React.Component< - DownshiftProps -> { - static stateChangeTypes: { - unknown: StateChangeTypes.unknown - mouseUp: StateChangeTypes.mouseUp - itemMouseEnter: StateChangeTypes.itemMouseEnter - keyDownArrowUp: StateChangeTypes.keyDownArrowUp - keyDownArrowDown: StateChangeTypes.keyDownArrowDown - keyDownEscape: StateChangeTypes.keyDownEscape - keyDownEnter: StateChangeTypes.keyDownEnter - clickItem: StateChangeTypes.clickItem - blurInput: StateChangeTypes.blurInput - changeInput: StateChangeTypes.changeInput - keyDownSpaceButton: StateChangeTypes.keyDownSpaceButton - clickButton: StateChangeTypes.clickButton - blurButton: StateChangeTypes.blurButton - controlledPropUpdatedSelectedItem: StateChangeTypes.controlledPropUpdatedSelectedItem - touchEnd: StateChangeTypes.touchEnd - } -} - -export function resetIdCounter(): void - -/* useSelect Types */ - -export interface UseSelectState { - highlightedIndex: number - selectedItem: Item | null - isOpen: boolean - inputValue: string -} - -export enum UseSelectStateChangeTypes { - ToggleButtonClick = '__togglebutton_click__', - ToggleButtonKeyDownArrowDown = '__togglebutton_keydown_arrow_down__', - ToggleButtonKeyDownArrowUp = '__togglebutton_keydown_arrow_up__', - ToggleButtonKeyDownCharacter = '__togglebutton_keydown_character__', - ToggleButtonKeyDownEscape = '__togglebutton_keydown_escape__', - ToggleButtonKeyDownHome = '__togglebutton_keydown_home__', - ToggleButtonKeyDownEnd = '__togglebutton_keydown_end__', - ToggleButtonKeyDownEnter = '__togglebutton_keydown_enter__', - ToggleButtonKeyDownSpaceButton = '__togglebutton_keydown_space_button__', - ToggleButtonKeyDownPageUp = '__togglebutton_keydown_page_up__', - ToggleButtonKeyDownPageDown = '__togglebutton_keydown_page_down__', - ToggleButtonBlur = '__togglebutton_blur__', - MenuMouseLeave = '__menu_mouse_leave__', - ItemMouseMove = '__item_mouse_move__', - ItemClick = '__item_click__', - FunctionToggleMenu = '__function_toggle_menu__', - FunctionOpenMenu = '__function_open_menu__', - FunctionCloseMenu = '__function_close_menu__', - FunctionSetHighlightedIndex = '__function_set_highlighted_index__', - FunctionSelectItem = '__function_select_item__', - FunctionSetInputValue = '__function_set_input_value__', - FunctionReset = '__function_reset__', -} - -export interface UseSelectProps { - items: Item[] - isItemDisabled?(item: Item, index: number): boolean - itemToString?: (item: Item | null) => string - itemToKey?: (item: Item | null) => any - getA11yStatusMessage?: (options: UseSelectState) => string - highlightedIndex?: number - initialHighlightedIndex?: number - defaultHighlightedIndex?: number - isOpen?: boolean - initialIsOpen?: boolean - defaultIsOpen?: boolean - selectedItem?: Item | null - initialSelectedItem?: Item | null - defaultSelectedItem?: Item | null - id?: string - labelId?: string - menuId?: string - toggleButtonId?: string - getItemId?: (index: number) => string - scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void - stateReducer?: ( - state: UseSelectState, - actionAndChanges: UseSelectStateChangeOptions, - ) => Partial> - onSelectedItemChange?: (changes: UseSelectSelectedItemChange) => void - onIsOpenChange?: (changes: UseSelectIsOpenChange) => void - onHighlightedIndexChange?: ( - changes: UseSelectHighlightedIndexChange, - ) => void - onStateChange?: (changes: UseSelectStateChange) => void - environment?: Environment -} - -export interface UseSelectStateChangeOptions - extends UseSelectDispatchAction { - changes: Partial> -} - -export interface UseSelectDispatchAction { - type: UseSelectStateChangeTypes - altKey?: boolean - key?: string - index?: number - highlightedIndex?: number - selectedItem?: Item | null - inputValue?: string -} - -export interface UseSelectStateChange - extends Partial> { - type: UseSelectStateChangeTypes -} - -export interface UseSelectSelectedItemChange - extends UseSelectStateChange { - selectedItem: Item | null -} - -export interface UseSelectHighlightedIndexChange - extends UseSelectStateChange { - highlightedIndex: number -} - -export interface UseSelectIsOpenChange - extends UseSelectStateChange { - isOpen: boolean -} - -export interface UseSelectGetMenuPropsOptions - extends GetPropsWithRefKey, - GetMenuPropsOptions {} - -export interface UseSelectGetMenuReturnValue extends GetMenuPropsReturnValue { - onMouseLeave: React.MouseEventHandler -} - -export interface UseSelectGetToggleButtonPropsOptions - extends GetPropsWithRefKey, - React.HTMLProps { - onPress?: (event: React.BaseSyntheticEvent) => void -} - -export interface UseSelectGetToggleButtonReturnValue - extends Pick< - GetToggleButtonPropsReturnValue, - 'onBlur' | 'onClick' | 'onPress' | 'onKeyDown' - > { - 'aria-activedescendant': string - 'aria-controls': string - 'aria-expanded': boolean - 'aria-haspopup': 'listbox' - 'aria-labelledby': string | undefined - id: string - ref?: React.RefObject - role: 'combobox' - tabIndex: 0 -} - -export interface UseSelectGetLabelPropsOptions extends GetLabelPropsOptions {} -export interface UseSelectGetLabelPropsReturnValue - extends GetLabelPropsReturnValue { - onClick: React.MouseEventHandler -} - -export interface UseSelectGetItemPropsOptions - extends Omit, 'disabled'>, - GetPropsWithRefKey {} - -export interface UseSelectGetItemPropsReturnValue - extends GetItemPropsReturnValue { - 'aria-disabled': boolean - ref?: React.RefObject -} - -export interface UseSelectPropGetters { - getToggleButtonProps: ( - options?: UseSelectGetToggleButtonPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite - getLabelProps: ( - options?: UseSelectGetLabelPropsOptions & Options, - ) => Overwrite - getMenuProps: ( - options?: UseSelectGetMenuPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite - getItemProps: ( - options: UseSelectGetItemPropsOptions & Options, - ) => Omit< - Overwrite, - 'index' | 'item' - > -} - -export interface UseSelectActions { - reset: () => void - openMenu: () => void - closeMenu: () => void - toggleMenu: () => void - selectItem: (item: Item | null) => void - setHighlightedIndex: (index: number) => void -} - -export type UseSelectReturnValue = UseSelectState & - UseSelectPropGetters & - UseSelectActions - -export interface UseSelectInterface { - (props: UseSelectProps): UseSelectReturnValue - stateChangeTypes: { - ToggleButtonClick: UseSelectStateChangeTypes.ToggleButtonClick - ToggleButtonKeyDownArrowDown: UseSelectStateChangeTypes.ToggleButtonKeyDownArrowDown - ToggleButtonKeyDownArrowUp: UseSelectStateChangeTypes.ToggleButtonKeyDownArrowUp - ToggleButtonKeyDownCharacter: UseSelectStateChangeTypes.ToggleButtonKeyDownCharacter - ToggleButtonKeyDownEscape: UseSelectStateChangeTypes.ToggleButtonKeyDownEscape - ToggleButtonKeyDownHome: UseSelectStateChangeTypes.ToggleButtonKeyDownHome - ToggleButtonKeyDownEnd: UseSelectStateChangeTypes.ToggleButtonKeyDownEnd - ToggleButtonKeyDownEnter: UseSelectStateChangeTypes.ToggleButtonKeyDownEnter - ToggleButtonKeyDownSpaceButton: UseSelectStateChangeTypes.ToggleButtonKeyDownSpaceButton - ToggleButtonKeyDownPageUp: UseSelectStateChangeTypes.ToggleButtonKeyDownPageUp - ToggleButtonKeyDownPageDown: UseSelectStateChangeTypes.ToggleButtonKeyDownPageDown - ToggleButtonBlur: UseSelectStateChangeTypes.ToggleButtonBlur - MenuMouseLeave: UseSelectStateChangeTypes.MenuMouseLeave - ItemMouseMove: UseSelectStateChangeTypes.ItemMouseMove - ItemClick: UseSelectStateChangeTypes.ItemClick - FunctionToggleMenu: UseSelectStateChangeTypes.FunctionToggleMenu - FunctionOpenMenu: UseSelectStateChangeTypes.FunctionOpenMenu - FunctionCloseMenu: UseSelectStateChangeTypes.FunctionCloseMenu - FunctionSetHighlightedIndex: UseSelectStateChangeTypes.FunctionSetHighlightedIndex - FunctionSelectItem: UseSelectStateChangeTypes.FunctionSelectItem - FunctionSetInputValue: UseSelectStateChangeTypes.FunctionSetInputValue - FunctionReset: UseSelectStateChangeTypes.FunctionReset - } -} - -export const useSelect: UseSelectInterface - -/* useCombobox Types */ - -export interface UseComboboxState { - highlightedIndex: number - selectedItem: Item | null - isOpen: boolean - inputValue: string -} - -export enum UseComboboxStateChangeTypes { - InputKeyDownArrowDown = '__input_keydown_arrow_down__', - InputKeyDownArrowUp = '__input_keydown_arrow_up__', - InputKeyDownEscape = '__input_keydown_escape__', - InputKeyDownHome = '__input_keydown_home__', - InputKeyDownEnd = '__input_keydown_end__', - InputKeyDownPageUp = '__input_keydown_page_up__', - InputKeyDownPageDown = '__input_keydown_page_down__', - InputKeyDownEnter = '__input_keydown_enter__', - InputChange = '__input_change__', - InputBlur = '__input_blur__', - InputClick = '__input_click__', - MenuMouseLeave = '__menu_mouse_leave__', - ItemMouseMove = '__item_mouse_move__', - ItemClick = '__item_click__', - ToggleButtonClick = '__togglebutton_click__', - FunctionToggleMenu = '__function_toggle_menu__', - FunctionOpenMenu = '__function_open_menu__', - FunctionCloseMenu = '__function_close_menu__', - FunctionSetHighlightedIndex = '__function_set_highlighted_index__', - FunctionSelectItem = '__function_select_item__', - FunctionSetInputValue = '__function_set_input_value__', - FunctionReset = '__function_reset__', - ControlledPropUpdatedSelectedItem = '__controlled_prop_updated_selected_item__', -} - -export interface UseComboboxProps { - items: Item[] - isItemDisabled?(item: Item, index: number): boolean - itemToString?: (item: Item | null) => string - itemToKey?: (item: Item | null) => any - getA11yStatusMessage?: (options: UseComboboxState) => string - highlightedIndex?: number - initialHighlightedIndex?: number - defaultHighlightedIndex?: number - isOpen?: boolean - initialIsOpen?: boolean - defaultIsOpen?: boolean - selectedItem?: Item | null - initialSelectedItem?: Item | null - defaultSelectedItem?: Item | null - inputValue?: string - initialInputValue?: string - defaultInputValue?: string - id?: string - labelId?: string - menuId?: string - toggleButtonId?: string - inputId?: string - getItemId?: (index: number) => string - scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void - stateReducer?: ( - state: UseComboboxState, - actionAndChanges: UseComboboxStateChangeOptions, - ) => Partial> - onSelectedItemChange?: (changes: UseComboboxSelectedItemChange) => void - onIsOpenChange?: (changes: UseComboboxIsOpenChange) => void - onHighlightedIndexChange?: ( - changes: UseComboboxHighlightedIndexChange, - ) => void - onStateChange?: (changes: UseComboboxStateChange) => void - onInputValueChange?: (changes: UseComboboxInputValueChange) => void - environment?: Environment -} - -export interface UseComboboxStateChangeOptions - extends UseComboboxDispatchAction { - changes: Partial> -} - -export interface UseComboboxDispatchAction { - type: UseComboboxStateChangeTypes - altKey?: boolean - inputValue?: string - index?: number - highlightedIndex?: number - selectedItem?: Item | null - selectItem?: boolean -} - -export interface UseComboboxStateChange - extends Partial> { - type: UseComboboxStateChangeTypes -} - -export interface UseComboboxSelectedItemChange - extends UseComboboxStateChange { - selectedItem: Item | null -} -export interface UseComboboxHighlightedIndexChange - extends UseComboboxStateChange { - highlightedIndex: number -} - -export interface UseComboboxIsOpenChange - extends UseComboboxStateChange { - isOpen: boolean -} - -export interface UseComboboxInputValueChange - extends UseComboboxStateChange { - inputValue: string -} - -export interface UseComboboxGetMenuPropsOptions - extends GetPropsWithRefKey, - GetMenuPropsOptions {} - -export interface UseComboboxGetMenuPropsReturnValue - extends UseSelectGetMenuReturnValue {} - -export interface UseComboboxGetToggleButtonPropsOptions - extends GetPropsWithRefKey, - GetToggleButtonPropsOptions {} - -export interface UseComboboxGetToggleButtonPropsReturnValue { - 'aria-controls': string - 'aria-expanded': boolean - id: string - onPress?: (event: React.BaseSyntheticEvent) => void - onClick?: React.MouseEventHandler - ref?: React.RefObject - tabIndex: -1 -} - -export interface UseComboboxGetLabelPropsOptions extends GetLabelPropsOptions {} - -export interface UseComboboxGetLabelPropsReturnValue - extends GetLabelPropsReturnValue {} - -export interface UseComboboxGetItemPropsOptions - extends Omit, 'disabled'>, - GetPropsWithRefKey {} - -export interface UseComboboxGetItemPropsReturnValue - extends GetItemPropsReturnValue { - 'aria-disabled': boolean - ref?: React.RefObject -} - -export interface UseComboboxGetInputPropsOptions - extends GetInputPropsOptions, - GetPropsWithRefKey {} - -export interface UseComboboxGetInputPropsReturnValue - extends GetInputPropsReturnValue { - 'aria-activedescendant': string - 'aria-controls': string - 'aria-expanded': boolean - role: 'combobox' - onClick: React.MouseEventHandler -} -export interface UseComboboxPropGetters { - getToggleButtonProps: ( - options?: UseComboboxGetToggleButtonPropsOptions & Options, - ) => Overwrite - getLabelProps: ( - options?: UseComboboxGetLabelPropsOptions & Options, - ) => Overwrite - getMenuProps: ( - options?: UseComboboxGetMenuPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite - getItemProps: ( - options: UseComboboxGetItemPropsOptions & Options, - ) => Omit< - Overwrite, - 'index' | 'item' - > - getInputProps: ( - options?: UseComboboxGetInputPropsOptions & Options, - otherOptions?: GetPropsCommonOptions, - ) => Overwrite -} - -export interface UseComboboxActions { - reset: () => void - openMenu: () => void - closeMenu: () => void - toggleMenu: () => void - selectItem: (item: Item | null) => void - setHighlightedIndex: (index: number) => void - setInputValue: (inputValue: string) => void -} - -export type UseComboboxReturnValue = UseComboboxState & - UseComboboxPropGetters & - UseComboboxActions - -export interface UseComboboxInterface { - (props: UseComboboxProps): UseComboboxReturnValue - stateChangeTypes: { - InputKeyDownArrowDown: UseComboboxStateChangeTypes.InputKeyDownArrowDown - InputKeyDownArrowUp: UseComboboxStateChangeTypes.InputKeyDownArrowUp - InputKeyDownEscape: UseComboboxStateChangeTypes.InputKeyDownEscape - InputKeyDownHome: UseComboboxStateChangeTypes.InputKeyDownHome - InputKeyDownEnd: UseComboboxStateChangeTypes.InputKeyDownEnd - InputKeyDownPageDown: UseComboboxStateChangeTypes.InputKeyDownPageDown - InputKeyDownPageUp: UseComboboxStateChangeTypes.InputKeyDownPageUp - InputKeyDownEnter: UseComboboxStateChangeTypes.InputKeyDownEnter - InputChange: UseComboboxStateChangeTypes.InputChange - InputBlur: UseComboboxStateChangeTypes.InputBlur - InputClick: UseComboboxStateChangeTypes.InputClick - MenuMouseLeave: UseComboboxStateChangeTypes.MenuMouseLeave - ItemMouseMove: UseComboboxStateChangeTypes.ItemMouseMove - ItemClick: UseComboboxStateChangeTypes.ItemClick - ToggleButtonClick: UseComboboxStateChangeTypes.ToggleButtonClick - FunctionToggleMenu: UseComboboxStateChangeTypes.FunctionToggleMenu - FunctionOpenMenu: UseComboboxStateChangeTypes.FunctionOpenMenu - FunctionCloseMenu: UseComboboxStateChangeTypes.FunctionCloseMenu - FunctionSetHighlightedIndex: UseComboboxStateChangeTypes.FunctionSetHighlightedIndex - FunctionSelectItem: UseComboboxStateChangeTypes.FunctionSelectItem - FunctionSetInputValue: UseComboboxStateChangeTypes.FunctionSetInputValue - FunctionReset: UseComboboxStateChangeTypes.FunctionReset - ControlledPropUpdatedSelectedItem: UseComboboxStateChangeTypes.ControlledPropUpdatedSelectedItem - } -} - -export const useCombobox: UseComboboxInterface - -// useMultipleSelection types. - -export interface UseMultipleSelectionState { - selectedItems: Item[] - activeIndex: number -} - -export enum UseMultipleSelectionStateChangeTypes { - SelectedItemClick = '__selected_item_click__', - SelectedItemKeyDownDelete = '__selected_item_keydown_delete__', - SelectedItemKeyDownBackspace = '__selected_item_keydown_backspace__', - SelectedItemKeyDownNavigationNext = '__selected_item_keydown_navigation_next__', - SelectedItemKeyDownNavigationPrevious = '__selected_item_keydown_navigation_previous__', - DropdownKeyDownNavigationPrevious = '__dropdown_keydown_navigation_previous__', - DropdownKeyDownBackspace = '__dropdown_keydown_backspace__', - DropdownClick = '__dropdown_click__', - FunctionAddSelectedItem = '__function_add_selected_item__', - FunctionRemoveSelectedItem = '__function_remove_selected_item__', - FunctionSetSelectedItems = '__function_set_selected_items__', - FunctionSetActiveIndex = '__function_set_active_index__', - FunctionReset = '__function_reset__', -} - -export interface UseMultipleSelectionProps { - selectedItems?: Item[] - initialSelectedItems?: Item[] - defaultSelectedItems?: Item[] - itemToKey?: (item: Item | null) => any - getA11yStatusMessage?: (options: UseMultipleSelectionState) => string - stateReducer?: ( - state: UseMultipleSelectionState, - actionAndChanges: UseMultipleSelectionStateChangeOptions, - ) => Partial> - activeIndex?: number - initialActiveIndex?: number - defaultActiveIndex?: number - onActiveIndexChange?: ( - changes: UseMultipleSelectionActiveIndexChange, - ) => void - onSelectedItemsChange?: ( - changes: UseMultipleSelectionSelectedItemsChange, - ) => void - onStateChange?: (changes: UseMultipleSelectionStateChange) => void - keyNavigationNext?: string - keyNavigationPrevious?: string - environment?: Environment -} - -export interface UseMultipleSelectionStateChangeOptions - extends UseMultipleSelectionDispatchAction { - changes: Partial> -} - -export interface UseMultipleSelectionDispatchAction { - type: UseMultipleSelectionStateChangeTypes - index?: number - selectedItem?: Item | null - selectedItems?: Item[] - activeIndex?: number -} - -export interface UseMultipleSelectionStateChange - extends Partial> { - 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 - activeSelectedItem: Item - removedSelectedItem: Item - activeIndex: number -} - -export interface UseMultipleSelectionGetSelectedItemPropsOptions - extends React.HTMLProps, - GetPropsWithRefKey { - index?: number - selectedItem: Item -} - -export interface UseMultipleSelectionGetSelectedItemReturnValue { - ref?: React.RefObject - tabIndex: 0 | -1 - onClick: React.MouseEventHandler - onKeyDown: React.KeyboardEventHandler -} - -export interface UseMultipleSelectionGetDropdownPropsOptions - extends React.HTMLProps { - preventKeyAction?: boolean -} - -export interface UseMultipleSelectionGetDropdownReturnValue { - ref?: React.RefObject - onClick?: React.MouseEventHandler - onKeyDown?: React.KeyboardEventHandler -} - -export interface UseMultipleSelectionPropGetters { - getDropdownProps: ( - options?: UseMultipleSelectionGetDropdownPropsOptions & Options, - extraOptions?: GetPropsCommonOptions, - ) => Omit< - Overwrite, - 'preventKeyAction' - > - getSelectedItemProps: ( - options: UseMultipleSelectionGetSelectedItemPropsOptions & Options, - ) => Omit< - Overwrite, - 'index' | 'selectedItem' - > -} - -export interface UseMultipleSelectionActions { - reset: () => void - addSelectedItem: (item: Item) => void - removeSelectedItem: (item: Item) => void - setSelectedItems: (items: Item[]) => void - setActiveIndex: (index: number) => void -} - -export type UseMultipleSelectionReturnValue = - UseMultipleSelectionState & - UseMultipleSelectionPropGetters & - UseMultipleSelectionActions - -export interface UseMultipleSelectionInterface { - ( - props?: UseMultipleSelectionProps, - ): UseMultipleSelectionReturnValue - stateChangeTypes: { - SelectedItemClick: UseMultipleSelectionStateChangeTypes.SelectedItemClick - SelectedItemKeyDownDelete: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownDelete - SelectedItemKeyDownBackspace: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownBackspace - SelectedItemKeyDownNavigationNext: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownNavigationNext - SelectedItemKeyDownNavigationPrevious: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownNavigationPrevious - DropdownKeyDownNavigationPrevious: UseMultipleSelectionStateChangeTypes.DropdownKeyDownNavigationPrevious - DropdownKeyDownBackspace: UseMultipleSelectionStateChangeTypes.DropdownKeyDownBackspace - DropdownClick: UseMultipleSelectionStateChangeTypes.DropdownClick - FunctionAddSelectedItem: UseMultipleSelectionStateChangeTypes.FunctionAddSelectedItem - FunctionRemoveSelectedItem: UseMultipleSelectionStateChangeTypes.FunctionRemoveSelectedItem - FunctionSetSelectedItems: UseMultipleSelectionStateChangeTypes.FunctionSetSelectedItems - FunctionSetActiveIndex: UseMultipleSelectionStateChangeTypes.FunctionSetActiveIndex - FunctionReset: UseMultipleSelectionStateChangeTypes.FunctionReset - } -} - -export const useMultipleSelection: UseMultipleSelectionInterface +export * from './index.legacy' +import Downshift from './index.legacy' +export default Downshift + +export { + UseTagGroupState, + UseTagGroupProps, + UseTagGroupReturnValue, + GetTagGroupProps, + GetTagGroupPropsOptions, + GetTagGroupPropsReturnValue, + GetTagProps, + GetTagPropsOptions, + GetTagPropsReturnValue, + GetTagRemoveProps, + GetTagRemovePropsOptions, + GetTagRemovePropsReturnValue, + UseTagGroupStateChangeTypes, +} from '../dist/hooks/useTagGroup/index.types' + +import {UseTagGroupInterface} from '../dist/hooks/useTagGroup/index.types' +export const useTagGroup: UseTagGroupInterface +export {UseTagGroupInterface} diff --git a/typings/index.legacy.d.ts b/typings/index.legacy.d.ts new file mode 100644 index 000000000..7d543581d --- /dev/null +++ b/typings/index.legacy.d.ts @@ -0,0 +1,888 @@ +import React from "react" + +import {Environment} from '../dist/hooks/useTagGroup/index.types' + +export {Environment} + +export type Callback = () => void + +export type Overwrite = Pick> & U + +export interface DownshiftState { + highlightedIndex: number | null + inputValue: string | null + isOpen: boolean + selectedItem: Item | null +} + +export enum StateChangeTypes { + unknown = '__autocomplete_unknown__', + mouseUp = '__autocomplete_mouseup__', + itemMouseEnter = '__autocomplete_item_mouseenter__', + keyDownArrowUp = '__autocomplete_keydown_arrow_up__', + keyDownArrowDown = '__autocomplete_keydown_arrow_down__', + keyDownEscape = '__autocomplete_keydown_escape__', + keyDownEnter = '__autocomplete_keydown_enter__', + clickItem = '__autocomplete_click_item__', + blurInput = '__autocomplete_blur_input__', + changeInput = '__autocomplete_change_input__', + keyDownSpaceButton = '__autocomplete_keydown_space_button__', + clickButton = '__autocomplete_click_button__', + blurButton = '__autocomplete_blur_button__', + controlledPropUpdatedSelectedItem = '__autocomplete_controlled_prop_updated_selected_item__', + touchEnd = '__autocomplete_touchend__', +} + +export interface DownshiftProps { + initialSelectedItem?: Item + initialInputValue?: string + initialHighlightedIndex?: number | null + initialIsOpen?: boolean + defaultHighlightedIndex?: number | null + defaultIsOpen?: boolean + itemToString?: (item: Item | null) => string + selectedItemChanged?: (prevItem: Item, item: Item) => boolean + getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string + onChange?: ( + selectedItem: Item | null, + stateAndHelpers: ControllerStateAndHelpers, + ) => void + onSelect?: ( + selectedItem: Item | null, + stateAndHelpers: ControllerStateAndHelpers, + ) => void + onStateChange?: ( + options: StateChangeOptions, + stateAndHelpers: ControllerStateAndHelpers, + ) => void + onInputValueChange?: ( + inputValue: string, + stateAndHelpers: ControllerStateAndHelpers, + ) => void + stateReducer?: ( + state: DownshiftState, + changes: StateChangeOptions, + ) => Partial> + itemCount?: number + highlightedIndex?: number | null + inputValue?: string | null + isOpen?: boolean + selectedItem?: Item | null + children?: ChildrenFunction + id?: string + inputId?: string + labelId?: string + menuId?: string + getItemId?: (index?: number) => string + environment?: Environment + onOuterClick?: (stateAndHelpers: ControllerStateAndHelpers) => void + scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void + onUserAction?: ( + options: StateChangeOptions, + stateAndHelpers: ControllerStateAndHelpers, + ) => void + suppressRefError?: boolean +} + +export interface A11yStatusMessageOptions { + highlightedIndex: number | null + inputValue: string + isOpen: boolean + itemToString: (item: Item | null) => string + previousResultCount: number + resultCount: number + highlightedItem: Item + selectedItem: Item | null +} + +export interface StateChangeOptions extends Partial> { + type: StateChangeTypes +} + +export type StateChangeFunction = ( + state: DownshiftState, +) => Partial> + +export interface GetRootPropsOptions { + refKey?: string + ref?: React.RefObject +} + +export interface GetRootPropsReturnValue { + 'aria-expanded': boolean + 'aria-haspopup': 'listbox' + 'aria-labelledby': string + 'aria-owns': string | undefined + ref?: React.RefObject + role: 'combobox' +} + +export interface GetInputPropsOptions extends React.HTMLProps { + disabled?: boolean +} + +export interface GetInputPropsReturnValue { + 'aria-autocomplete': 'list' + 'aria-activedescendant': string | undefined + 'aria-controls': string | undefined + 'aria-labelledby': string | undefined + autoComplete: 'off' + id: string + onChange?: React.ChangeEventHandler + onChangeText?: React.ChangeEventHandler + onInput?: React.FormEventHandler + onKeyDown?: React.KeyboardEventHandler + onBlur?: React.FocusEventHandler + value: string +} + +export interface GetLabelPropsOptions extends React.HTMLProps {} + +export interface GetLabelPropsReturnValue { + htmlFor: string + id: string +} + +export interface GetToggleButtonPropsOptions extends React.HTMLProps { + disabled?: boolean + onPress?: (event: React.BaseSyntheticEvent) => void +} + +export interface GetToggleButtonPropsReturnValue { + 'aria-label': 'close menu' | 'open menu' + 'aria-haspopup': true + 'data-toggle': true + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + onKeyDown?: React.KeyboardEventHandler + onKeyUp?: React.KeyboardEventHandler + onBlur?: React.FocusEventHandler + role: 'button' + type: 'button' +} + +export interface GetMenuPropsOptions + extends React.HTMLProps, GetPropsWithRefKey { + ['aria-label']?: string +} + +export interface GetMenuPropsReturnValue { + 'aria-labelledby': string | undefined + ref?: React.RefObject + role: 'listbox' + id: string +} + +export interface GetPropsCommonOptions { + suppressRefError?: boolean +} + +export interface GetPropsWithRefKey { + refKey?: string +} + +export interface GetItemPropsOptions extends React.HTMLProps { + index?: number + item: Item + isSelected?: boolean + disabled?: boolean +} + +export interface GetItemPropsReturnValue { + 'aria-selected': boolean + id: string + onClick?: React.MouseEventHandler + onMouseDown?: React.MouseEventHandler + onMouseMove?: React.MouseEventHandler + onPress?: React.MouseEventHandler + role: 'option' +} + +export interface PropGetters { + getRootProps: ( + options?: GetRootPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite + getToggleButtonProps: ( + options?: GetToggleButtonPropsOptions & Options, + ) => Overwrite + getLabelProps: ( + options?: GetLabelPropsOptions & Options, + ) => Overwrite + getMenuProps: ( + options?: GetMenuPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite + getInputProps: ( + options?: GetInputPropsOptions & Options, + ) => Overwrite + getItemProps: ( + options: GetItemPropsOptions & Options, + ) => Omit, 'index' | 'item'> +} + +export interface Actions { + reset: ( + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + openMenu: (cb?: Callback) => void + closeMenu: (cb?: Callback) => void + toggleMenu: ( + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + selectItem: ( + item: Item | null, + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + selectItemAtIndex: ( + index: number, + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + selectHighlightedItem: ( + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + setHighlightedIndex: ( + index: number, + otherStateToSet?: Partial>, + cb?: Callback, + ) => void + clearSelection: (cb?: Callback) => void + clearItems: () => void + setItemCount: (count: number) => void + unsetItemCount: () => void + setState: ( + stateToSet: Partial> | StateChangeFunction, + cb?: Callback, + ) => void + itemToString: (item: Item | null) => string +} + +export type ControllerStateAndHelpers = DownshiftState & + PropGetters & + Actions + +export type ChildrenFunction = ( + options: ControllerStateAndHelpers, +) => React.ReactNode + +export default class Downshift extends React.Component< + DownshiftProps +> { + static stateChangeTypes: { + unknown: StateChangeTypes.unknown + mouseUp: StateChangeTypes.mouseUp + itemMouseEnter: StateChangeTypes.itemMouseEnter + keyDownArrowUp: StateChangeTypes.keyDownArrowUp + keyDownArrowDown: StateChangeTypes.keyDownArrowDown + keyDownEscape: StateChangeTypes.keyDownEscape + keyDownEnter: StateChangeTypes.keyDownEnter + clickItem: StateChangeTypes.clickItem + blurInput: StateChangeTypes.blurInput + changeInput: StateChangeTypes.changeInput + keyDownSpaceButton: StateChangeTypes.keyDownSpaceButton + clickButton: StateChangeTypes.clickButton + blurButton: StateChangeTypes.blurButton + controlledPropUpdatedSelectedItem: StateChangeTypes.controlledPropUpdatedSelectedItem + touchEnd: StateChangeTypes.touchEnd + } +} + +export function resetIdCounter(): void + +/* useSelect Types */ + +export interface UseSelectState { + highlightedIndex: number + selectedItem: Item | null + isOpen: boolean + inputValue: string +} + +export enum UseSelectStateChangeTypes { + ToggleButtonClick = '__togglebutton_click__', + ToggleButtonKeyDownArrowDown = '__togglebutton_keydown_arrow_down__', + ToggleButtonKeyDownArrowUp = '__togglebutton_keydown_arrow_up__', + ToggleButtonKeyDownCharacter = '__togglebutton_keydown_character__', + ToggleButtonKeyDownEscape = '__togglebutton_keydown_escape__', + ToggleButtonKeyDownHome = '__togglebutton_keydown_home__', + ToggleButtonKeyDownEnd = '__togglebutton_keydown_end__', + ToggleButtonKeyDownEnter = '__togglebutton_keydown_enter__', + ToggleButtonKeyDownSpaceButton = '__togglebutton_keydown_space_button__', + ToggleButtonKeyDownPageUp = '__togglebutton_keydown_page_up__', + ToggleButtonKeyDownPageDown = '__togglebutton_keydown_page_down__', + ToggleButtonBlur = '__togglebutton_blur__', + MenuMouseLeave = '__menu_mouse_leave__', + ItemMouseMove = '__item_mouse_move__', + ItemClick = '__item_click__', + FunctionToggleMenu = '__function_toggle_menu__', + FunctionOpenMenu = '__function_open_menu__', + FunctionCloseMenu = '__function_close_menu__', + FunctionSetHighlightedIndex = '__function_set_highlighted_index__', + FunctionSelectItem = '__function_select_item__', + FunctionSetInputValue = '__function_set_input_value__', + FunctionReset = '__function_reset__', +} + +export interface UseSelectProps { + items: Item[] + isItemDisabled?(item: Item, index: number): boolean + itemToString?: (item: Item | null) => string + itemToKey?: (item: Item | null) => any + getA11yStatusMessage?: (options: UseSelectState) => string + highlightedIndex?: number + initialHighlightedIndex?: number + defaultHighlightedIndex?: number + isOpen?: boolean + initialIsOpen?: boolean + defaultIsOpen?: boolean + selectedItem?: Item | null + initialSelectedItem?: Item | null + defaultSelectedItem?: Item | null + id?: string + labelId?: string + menuId?: string + toggleButtonId?: string + getItemId?: (index: number) => string + scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void + stateReducer?: ( + state: UseSelectState, + actionAndChanges: UseSelectStateChangeOptions, + ) => Partial> + onSelectedItemChange?: (changes: UseSelectSelectedItemChange) => void + onIsOpenChange?: (changes: UseSelectIsOpenChange) => void + onHighlightedIndexChange?: ( + changes: UseSelectHighlightedIndexChange, + ) => void + onStateChange?: (changes: UseSelectStateChange) => void + environment?: Environment +} + +export interface UseSelectStateChangeOptions< + Item, +> extends UseSelectDispatchAction { + changes: Partial> +} + +export interface UseSelectDispatchAction { + type: UseSelectStateChangeTypes + altKey?: boolean + key?: string + index?: number + highlightedIndex?: number + selectedItem?: Item | null + inputValue?: string +} + +export interface UseSelectStateChange extends Partial> { + type: UseSelectStateChangeTypes +} + +export interface UseSelectSelectedItemChange extends UseSelectStateChange { + selectedItem: Item | null +} + +export interface UseSelectHighlightedIndexChange< + Item, +> extends UseSelectStateChange { + highlightedIndex: number +} + +export interface UseSelectIsOpenChange extends UseSelectStateChange { + isOpen: boolean +} + +export interface UseSelectGetMenuPropsOptions + extends GetPropsWithRefKey, GetMenuPropsOptions {} + +export interface UseSelectGetMenuReturnValue extends GetMenuPropsReturnValue { + onMouseLeave: React.MouseEventHandler +} + +export interface UseSelectGetToggleButtonPropsOptions + extends GetPropsWithRefKey, React.HTMLProps { + onPress?: (event: React.BaseSyntheticEvent) => void +} + +export interface UseSelectGetToggleButtonReturnValue extends Pick< + GetToggleButtonPropsReturnValue, + 'onBlur' | 'onClick' | 'onPress' | 'onKeyDown' +> { + 'aria-activedescendant': string + 'aria-controls': string + 'aria-expanded': boolean + 'aria-haspopup': 'listbox' + 'aria-labelledby': string | undefined + id: string + ref?: React.RefObject + role: 'combobox' + tabIndex: 0 +} + +export interface UseSelectGetLabelPropsOptions extends GetLabelPropsOptions {} + +export interface UseSelectGetLabelPropsReturnValue extends GetLabelPropsReturnValue { + onClick: React.MouseEventHandler +} + +export interface UseSelectGetItemPropsOptions + extends Omit, 'disabled'>, GetPropsWithRefKey {} + +export interface UseSelectGetItemPropsReturnValue extends GetItemPropsReturnValue { + 'aria-disabled': boolean + ref?: React.RefObject +} + +export interface UseSelectPropGetters { + getToggleButtonProps: ( + options?: UseSelectGetToggleButtonPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite + getLabelProps: ( + options?: UseSelectGetLabelPropsOptions & Options, + ) => Overwrite + getMenuProps: ( + options?: UseSelectGetMenuPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite + getItemProps: ( + options: UseSelectGetItemPropsOptions & Options, + ) => Omit< + Overwrite, + 'index' | 'item' + > +} + +export interface UseSelectActions { + reset: () => void + openMenu: () => void + closeMenu: () => void + toggleMenu: () => void + selectItem: (item: Item | null) => void + setHighlightedIndex: (index: number) => void +} + +export type UseSelectReturnValue = UseSelectState & + UseSelectPropGetters & + UseSelectActions + +export interface UseSelectInterface { + (props: UseSelectProps): UseSelectReturnValue + stateChangeTypes: { + ToggleButtonClick: UseSelectStateChangeTypes.ToggleButtonClick + ToggleButtonKeyDownArrowDown: UseSelectStateChangeTypes.ToggleButtonKeyDownArrowDown + ToggleButtonKeyDownArrowUp: UseSelectStateChangeTypes.ToggleButtonKeyDownArrowUp + ToggleButtonKeyDownCharacter: UseSelectStateChangeTypes.ToggleButtonKeyDownCharacter + ToggleButtonKeyDownEscape: UseSelectStateChangeTypes.ToggleButtonKeyDownEscape + ToggleButtonKeyDownHome: UseSelectStateChangeTypes.ToggleButtonKeyDownHome + ToggleButtonKeyDownEnd: UseSelectStateChangeTypes.ToggleButtonKeyDownEnd + ToggleButtonKeyDownEnter: UseSelectStateChangeTypes.ToggleButtonKeyDownEnter + ToggleButtonKeyDownSpaceButton: UseSelectStateChangeTypes.ToggleButtonKeyDownSpaceButton + ToggleButtonKeyDownPageUp: UseSelectStateChangeTypes.ToggleButtonKeyDownPageUp + ToggleButtonKeyDownPageDown: UseSelectStateChangeTypes.ToggleButtonKeyDownPageDown + ToggleButtonBlur: UseSelectStateChangeTypes.ToggleButtonBlur + MenuMouseLeave: UseSelectStateChangeTypes.MenuMouseLeave + ItemMouseMove: UseSelectStateChangeTypes.ItemMouseMove + ItemClick: UseSelectStateChangeTypes.ItemClick + FunctionToggleMenu: UseSelectStateChangeTypes.FunctionToggleMenu + FunctionOpenMenu: UseSelectStateChangeTypes.FunctionOpenMenu + FunctionCloseMenu: UseSelectStateChangeTypes.FunctionCloseMenu + FunctionSetHighlightedIndex: UseSelectStateChangeTypes.FunctionSetHighlightedIndex + FunctionSelectItem: UseSelectStateChangeTypes.FunctionSelectItem + FunctionSetInputValue: UseSelectStateChangeTypes.FunctionSetInputValue + FunctionReset: UseSelectStateChangeTypes.FunctionReset + } +} + +export const useSelect: UseSelectInterface + +/* useCombobox Types */ + +export interface UseComboboxState { + highlightedIndex: number + selectedItem: Item | null + isOpen: boolean + inputValue: string +} + +export enum UseComboboxStateChangeTypes { + InputKeyDownArrowDown = '__input_keydown_arrow_down__', + InputKeyDownArrowUp = '__input_keydown_arrow_up__', + InputKeyDownEscape = '__input_keydown_escape__', + InputKeyDownHome = '__input_keydown_home__', + InputKeyDownEnd = '__input_keydown_end__', + InputKeyDownPageUp = '__input_keydown_page_up__', + InputKeyDownPageDown = '__input_keydown_page_down__', + InputKeyDownEnter = '__input_keydown_enter__', + InputChange = '__input_change__', + InputBlur = '__input_blur__', + InputClick = '__input_click__', + MenuMouseLeave = '__menu_mouse_leave__', + ItemMouseMove = '__item_mouse_move__', + ItemClick = '__item_click__', + ToggleButtonClick = '__togglebutton_click__', + FunctionToggleMenu = '__function_toggle_menu__', + FunctionOpenMenu = '__function_open_menu__', + FunctionCloseMenu = '__function_close_menu__', + FunctionSetHighlightedIndex = '__function_set_highlighted_index__', + FunctionSelectItem = '__function_select_item__', + FunctionSetInputValue = '__function_set_input_value__', + FunctionReset = '__function_reset__', + ControlledPropUpdatedSelectedItem = '__controlled_prop_updated_selected_item__', +} + +export interface UseComboboxProps { + items: Item[] + isItemDisabled?(item: Item, index: number): boolean + itemToString?: (item: Item | null) => string + itemToKey?: (item: Item | null) => any + getA11yStatusMessage?: (options: UseComboboxState) => string + highlightedIndex?: number + initialHighlightedIndex?: number + defaultHighlightedIndex?: number + isOpen?: boolean + initialIsOpen?: boolean + defaultIsOpen?: boolean + selectedItem?: Item | null + initialSelectedItem?: Item | null + defaultSelectedItem?: Item | null + inputValue?: string + initialInputValue?: string + defaultInputValue?: string + id?: string + labelId?: string + menuId?: string + toggleButtonId?: string + inputId?: string + getItemId?: (index: number) => string + scrollIntoView?: (node: HTMLElement, menuNode: HTMLElement) => void + stateReducer?: ( + state: UseComboboxState, + actionAndChanges: UseComboboxStateChangeOptions, + ) => Partial> + onSelectedItemChange?: (changes: UseComboboxSelectedItemChange) => void + onIsOpenChange?: (changes: UseComboboxIsOpenChange) => void + onHighlightedIndexChange?: ( + changes: UseComboboxHighlightedIndexChange, + ) => void + onStateChange?: (changes: UseComboboxStateChange) => void + onInputValueChange?: (changes: UseComboboxInputValueChange) => void + environment?: Environment +} + +export interface UseComboboxStateChangeOptions< + Item, +> extends UseComboboxDispatchAction { + changes: Partial> +} + +export interface UseComboboxDispatchAction { + type: UseComboboxStateChangeTypes + altKey?: boolean + inputValue?: string + index?: number + highlightedIndex?: number + selectedItem?: Item | null + selectItem?: boolean +} + +export interface UseComboboxStateChange extends Partial> { + type: UseComboboxStateChangeTypes +} + +export interface UseComboboxSelectedItemChange< + Item, +> extends UseComboboxStateChange { + selectedItem: Item | null +} + +export interface UseComboboxHighlightedIndexChange< + Item, +> extends UseComboboxStateChange { + highlightedIndex: number +} + +export interface UseComboboxIsOpenChange extends UseComboboxStateChange { + isOpen: boolean +} + +export interface UseComboboxInputValueChange< + Item, +> extends UseComboboxStateChange { + inputValue: string +} + +export interface UseComboboxGetMenuPropsOptions + extends GetPropsWithRefKey, GetMenuPropsOptions {} + +export interface UseComboboxGetMenuPropsReturnValue extends UseSelectGetMenuReturnValue {} + +export interface UseComboboxGetToggleButtonPropsOptions + extends GetPropsWithRefKey, GetToggleButtonPropsOptions {} + +export interface UseComboboxGetToggleButtonPropsReturnValue { + 'aria-controls': string + 'aria-expanded': boolean + id: string + onPress?: (event: React.BaseSyntheticEvent) => void + onClick?: React.MouseEventHandler + ref?: React.RefObject + tabIndex: -1 +} + +export interface UseComboboxGetLabelPropsOptions extends GetLabelPropsOptions {} + +export interface UseComboboxGetLabelPropsReturnValue extends GetLabelPropsReturnValue {} + +export interface UseComboboxGetItemPropsOptions + extends Omit, 'disabled'>, GetPropsWithRefKey {} + +export interface UseComboboxGetItemPropsReturnValue extends GetItemPropsReturnValue { + 'aria-disabled': boolean + ref?: React.RefObject +} + +export interface UseComboboxGetInputPropsOptions + extends GetInputPropsOptions, GetPropsWithRefKey {} + +export interface UseComboboxGetInputPropsReturnValue extends GetInputPropsReturnValue { + 'aria-activedescendant': string + 'aria-controls': string + 'aria-expanded': boolean + role: 'combobox' + onClick: React.MouseEventHandler +} + +export interface UseComboboxPropGetters { + getToggleButtonProps: ( + options?: UseComboboxGetToggleButtonPropsOptions & Options, + ) => Overwrite + getLabelProps: ( + options?: UseComboboxGetLabelPropsOptions & Options, + ) => Overwrite + getMenuProps: ( + options?: UseComboboxGetMenuPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite + getItemProps: ( + options: UseComboboxGetItemPropsOptions & Options, + ) => Omit< + Overwrite, + 'index' | 'item' + > + getInputProps: ( + options?: UseComboboxGetInputPropsOptions & Options, + otherOptions?: GetPropsCommonOptions, + ) => Overwrite +} + +export interface UseComboboxActions { + reset: () => void + openMenu: () => void + closeMenu: () => void + toggleMenu: () => void + selectItem: (item: Item | null) => void + setHighlightedIndex: (index: number) => void + setInputValue: (inputValue: string) => void +} + +export type UseComboboxReturnValue = UseComboboxState & + UseComboboxPropGetters & + UseComboboxActions + +export interface UseComboboxInterface { + (props: UseComboboxProps): UseComboboxReturnValue + stateChangeTypes: { + InputKeyDownArrowDown: UseComboboxStateChangeTypes.InputKeyDownArrowDown + InputKeyDownArrowUp: UseComboboxStateChangeTypes.InputKeyDownArrowUp + InputKeyDownEscape: UseComboboxStateChangeTypes.InputKeyDownEscape + InputKeyDownHome: UseComboboxStateChangeTypes.InputKeyDownHome + InputKeyDownEnd: UseComboboxStateChangeTypes.InputKeyDownEnd + InputKeyDownPageDown: UseComboboxStateChangeTypes.InputKeyDownPageDown + InputKeyDownPageUp: UseComboboxStateChangeTypes.InputKeyDownPageUp + InputKeyDownEnter: UseComboboxStateChangeTypes.InputKeyDownEnter + InputChange: UseComboboxStateChangeTypes.InputChange + InputBlur: UseComboboxStateChangeTypes.InputBlur + InputClick: UseComboboxStateChangeTypes.InputClick + MenuMouseLeave: UseComboboxStateChangeTypes.MenuMouseLeave + ItemMouseMove: UseComboboxStateChangeTypes.ItemMouseMove + ItemClick: UseComboboxStateChangeTypes.ItemClick + ToggleButtonClick: UseComboboxStateChangeTypes.ToggleButtonClick + FunctionToggleMenu: UseComboboxStateChangeTypes.FunctionToggleMenu + FunctionOpenMenu: UseComboboxStateChangeTypes.FunctionOpenMenu + FunctionCloseMenu: UseComboboxStateChangeTypes.FunctionCloseMenu + FunctionSetHighlightedIndex: UseComboboxStateChangeTypes.FunctionSetHighlightedIndex + FunctionSelectItem: UseComboboxStateChangeTypes.FunctionSelectItem + FunctionSetInputValue: UseComboboxStateChangeTypes.FunctionSetInputValue + FunctionReset: UseComboboxStateChangeTypes.FunctionReset + ControlledPropUpdatedSelectedItem: UseComboboxStateChangeTypes.ControlledPropUpdatedSelectedItem + } +} + +export const useCombobox: UseComboboxInterface + +// useMultipleSelection types. + +export interface UseMultipleSelectionState { + selectedItems: Item[] + activeIndex: number +} + +export enum UseMultipleSelectionStateChangeTypes { + SelectedItemClick = '__selected_item_click__', + SelectedItemKeyDownDelete = '__selected_item_keydown_delete__', + SelectedItemKeyDownBackspace = '__selected_item_keydown_backspace__', + SelectedItemKeyDownNavigationNext = '__selected_item_keydown_navigation_next__', + SelectedItemKeyDownNavigationPrevious = '__selected_item_keydown_navigation_previous__', + DropdownKeyDownNavigationPrevious = '__dropdown_keydown_navigation_previous__', + DropdownKeyDownBackspace = '__dropdown_keydown_backspace__', + DropdownClick = '__dropdown_click__', + FunctionAddSelectedItem = '__function_add_selected_item__', + FunctionRemoveSelectedItem = '__function_remove_selected_item__', + FunctionSetSelectedItems = '__function_set_selected_items__', + FunctionSetActiveIndex = '__function_set_active_index__', + FunctionReset = '__function_reset__', +} + +export interface UseMultipleSelectionProps { + selectedItems?: Item[] + initialSelectedItems?: Item[] + defaultSelectedItems?: Item[] + itemToKey?: (item: Item | null) => any + getA11yStatusMessage?: (options: UseMultipleSelectionState) => string + stateReducer?: ( + state: UseMultipleSelectionState, + actionAndChanges: UseMultipleSelectionStateChangeOptions, + ) => Partial> + activeIndex?: number + initialActiveIndex?: number + defaultActiveIndex?: number + onActiveIndexChange?: ( + changes: UseMultipleSelectionActiveIndexChange, + ) => void + onSelectedItemsChange?: ( + changes: UseMultipleSelectionSelectedItemsChange, + ) => void + onStateChange?: (changes: UseMultipleSelectionStateChange) => void + keyNavigationNext?: string + keyNavigationPrevious?: string + environment?: Environment +} + +export interface UseMultipleSelectionStateChangeOptions< + Item, +> extends UseMultipleSelectionDispatchAction { + changes: Partial> +} + +export interface UseMultipleSelectionDispatchAction { + type: UseMultipleSelectionStateChangeTypes + index?: number + selectedItem?: Item | null + selectedItems?: Item[] + activeIndex?: number +} + +export interface UseMultipleSelectionStateChange extends Partial< + UseMultipleSelectionState +> { + type: UseMultipleSelectionStateChangeTypes +} + +export interface UseMultipleSelectionActiveIndexChange< + Item, +> extends UseMultipleSelectionStateChange { + activeIndex: number +} + +export interface UseMultipleSelectionSelectedItemsChange< + Item, +> extends UseMultipleSelectionStateChange { + selectedItems: Item[] +} + +export interface A11yRemovalMessage { + itemToString: (item: Item) => string + resultCount: number + activeSelectedItem: Item + removedSelectedItem: Item + activeIndex: number +} + +export interface UseMultipleSelectionGetSelectedItemPropsOptions + extends React.HTMLProps, GetPropsWithRefKey { + index?: number + selectedItem: Item +} + +export interface UseMultipleSelectionGetSelectedItemReturnValue { + ref?: React.RefObject + tabIndex: 0 | -1 + onClick: React.MouseEventHandler + onKeyDown: React.KeyboardEventHandler +} + +export interface UseMultipleSelectionGetDropdownPropsOptions extends React.HTMLProps { + preventKeyAction?: boolean +} + +export interface UseMultipleSelectionGetDropdownReturnValue { + ref?: React.RefObject + onClick?: React.MouseEventHandler + onKeyDown?: React.KeyboardEventHandler +} + +export interface UseMultipleSelectionPropGetters { + getDropdownProps: ( + options?: UseMultipleSelectionGetDropdownPropsOptions & Options, + extraOptions?: GetPropsCommonOptions, + ) => Omit< + Overwrite, + 'preventKeyAction' + > + getSelectedItemProps: ( + options: UseMultipleSelectionGetSelectedItemPropsOptions & Options, + ) => Omit< + Overwrite, + 'index' | 'selectedItem' + > +} + +export interface UseMultipleSelectionActions { + reset: () => void + addSelectedItem: (item: Item) => void + removeSelectedItem: (item: Item) => void + setSelectedItems: (items: Item[]) => void + setActiveIndex: (index: number) => void +} + +export type UseMultipleSelectionReturnValue = UseMultipleSelectionState & + UseMultipleSelectionPropGetters & + UseMultipleSelectionActions + +export interface UseMultipleSelectionInterface { + ( + props?: UseMultipleSelectionProps, + ): UseMultipleSelectionReturnValue + stateChangeTypes: { + SelectedItemClick: UseMultipleSelectionStateChangeTypes.SelectedItemClick + SelectedItemKeyDownDelete: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownDelete + SelectedItemKeyDownBackspace: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownBackspace + SelectedItemKeyDownNavigationNext: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownNavigationNext + SelectedItemKeyDownNavigationPrevious: UseMultipleSelectionStateChangeTypes.SelectedItemKeyDownNavigationPrevious + DropdownKeyDownNavigationPrevious: UseMultipleSelectionStateChangeTypes.DropdownKeyDownNavigationPrevious + DropdownKeyDownBackspace: UseMultipleSelectionStateChangeTypes.DropdownKeyDownBackspace + DropdownClick: UseMultipleSelectionStateChangeTypes.DropdownClick + FunctionAddSelectedItem: UseMultipleSelectionStateChangeTypes.FunctionAddSelectedItem + FunctionRemoveSelectedItem: UseMultipleSelectionStateChangeTypes.FunctionRemoveSelectedItem + FunctionSetSelectedItems: UseMultipleSelectionStateChangeTypes.FunctionSetSelectedItems + FunctionSetActiveIndex: UseMultipleSelectionStateChangeTypes.FunctionSetActiveIndex + FunctionReset: UseMultipleSelectionStateChangeTypes.FunctionReset + } +} + +export const useMultipleSelection: UseMultipleSelectionInterface