├── .nvmrc ├── cypress ├── fixtures │ └── example.json ├── support │ └── index.js ├── .eslintrc ├── plugins │ └── index.js └── integration │ └── combobox.js ├── .gitattributes ├── src ├── __mocks__ │ ├── set-a11y-status.js │ └── utils.js ├── hooks │ ├── index.js │ ├── useSelect │ │ ├── __tests__ │ │ │ ├── getLabelProps.test.js │ │ │ ├── utils.test.js │ │ │ ├── returnProps.test.js │ │ │ └── getItemProps.test.js │ │ ├── stateChangeTypes.js │ │ ├── testUtils.js │ │ ├── utils.js │ │ └── reducer.js │ ├── README.md │ └── utils.js ├── index.js ├── __tests__ │ ├── .eslintrc │ ├── __snapshots__ │ │ ├── downshift.get-menu-props.js.snap │ │ ├── downshift.get-root-props.js.snap │ │ ├── downshift.get-item-props.js.snap │ │ ├── downshift.misc.js.snap │ │ ├── set-a11y-status.js.snap │ │ └── downshift.aria.js.snap │ ├── utils.pick-state.js │ ├── utils.call-all-event-handlers.js │ ├── set-a11y-status.js │ ├── downshift.focus-restoration.js │ ├── downshift.misc-with-utils-mocked.js │ ├── downshift.get-label-props.js │ ├── utils.get-a11y-status-message.js │ ├── utils.reset-id-counter.js │ ├── downshift.aria.js │ ├── portal-support.js │ ├── downshift.get-menu-props.js │ ├── utils.scroll-into-view.js │ ├── downshift.get-button-props.js │ ├── downshift.get-root-props.js │ ├── downshift.props.js │ ├── downshift.misc.js │ ├── downshift.get-item-props.js │ └── downshift.lifecycle.js ├── is.macro.js ├── productionEnum.macro.js ├── set-a11y-status.js ├── stateChangeTypes.js └── utils.js ├── .npmrc ├── other ├── react-native │ ├── .babelrc │ ├── jest.config.js │ └── __tests__ │ │ ├── __snapshots__ │ │ └── render-tests.js.snap │ │ ├── onBlur-tests.js │ │ ├── render-tests.js │ │ └── onChange-tests.js ├── ssr │ ├── jest.config.js │ └── __tests__ │ │ └── index.js ├── TYPESCRIPT_USAGE.md ├── misc-tests │ ├── jest.config.js │ └── __tests__ │ │ ├── build.js │ │ └── preact.js ├── USERS.md ├── manual-releases.md ├── public │ └── logo │ │ └── downshift.svg └── MAINTAINING.md ├── cypress.json ├── .prettierignore ├── prettier.config.js ├── docs ├── tests │ ├── index.mdx │ ├── combobox.mdx │ └── combobox.js ├── index.mdx └── useSelect │ ├── introduction.mdx │ ├── html.js │ ├── html.mdx │ └── uiLibraries.mdx ├── .flowconfig ├── CHANGELOG.md ├── doczrc.js ├── .gitignore ├── jest.config.js ├── tsconfig.json ├── rollup.config.js ├── .travis.yml ├── LICENSE ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .size-snapshot.json ├── test ├── custom.test.tsx ├── basic.test.tsx ├── custom.test.js └── basic.test.js ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── package.json └── flow-typed └── npm └── downshift_v2.x.x.js.flow /.nvmrc: -------------------------------------------------------------------------------- 1 | v8 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /src/__mocks__/set-a11y-status.js: -------------------------------------------------------------------------------- 1 | module.exports = jest.fn() 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /other/react-native/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } 4 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | export {default as useSelect} from './useSelect' 2 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:6006", 3 | "video": false 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | preact/ 5 | package-lock.json 6 | package.json 7 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import '@testing-library/cypress/add-commands' 3 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // this is really only here for editor integrations 2 | module.exports = require('kcd-scripts/prettier') 3 | -------------------------------------------------------------------------------- /cypress/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "cypress" 4 | ], 5 | "env": { 6 | "cypress/globals": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './downshift' 2 | export {resetIdCounter} from './utils' 3 | export {useSelect} from './hooks' 4 | -------------------------------------------------------------------------------- /docs/tests/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | route: /tests 4 | menu: Tests 5 | --- 6 | 7 | This is where we render components for the Cypress tests. 8 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/ 3 | 4 | [include] 5 | ./test 6 | 7 | [libs] 8 | 9 | [lints] 10 | all=error 11 | 12 | [options] 13 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (_on, _config) => { 2 | // `on` is used to hook into various events Cypress emits 3 | // `config` is the resolved Cypress config 4 | } 5 | -------------------------------------------------------------------------------- /docs/tests/combobox.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Combobox 3 | route: /tests/combobox 4 | menu: Tests 5 | --- 6 | 7 | import Combobox from './combobox' 8 | 9 | # Combobox 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/__mocks__/utils.js: -------------------------------------------------------------------------------- 1 | const actualUtils = require.requireActual('../utils') 2 | module.exports = Object.assign(actualUtils, { 3 | scrollIntoView: jest.fn(), // hard to write tests for this thing... 4 | }) 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the [releases page](../../releases). 6 | -------------------------------------------------------------------------------- /doczrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | port: 6006, 3 | files: './docs/**/*.{md,markdown,mdx}', 4 | menu: [ 5 | 'Home', 6 | {name: 'useSelect', menu: ['Introduction', 'Usage', 'UI Libraries']}, 7 | 'Tests', 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /src/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "jsx-a11y/label-has-for": "off", 4 | "jsx-a11y/click-events-have-key-events": "off", 5 | "react/prop-types": "off", 6 | "react/display-name": "off", 7 | "react/no-deprecated": "off", 8 | "no-console": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .opt-in 5 | .opt-out 6 | .DS_Store 7 | .next 8 | .eslintcache 9 | .docz 10 | storybook-static 11 | preact/ 12 | 13 | cypress/videos 14 | cypress/screenshots 15 | 16 | # these cause more harm than good 17 | # when working with contributors 18 | package-lock.json 19 | yarn.lock 20 | flow-coverage/ 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('kcd-scripts/jest') 2 | 3 | module.exports = Object.assign(jestConfig, { 4 | coveragePathIgnorePatterns: [ 5 | ...jestConfig.coveragePathIgnorePatterns, 6 | '.macro.js$', 7 | '/src/stateChangeTypes.js', 8 | ], 9 | setupFilesAfterEnv: ['@testing-library/react/cleanup-after-each'], 10 | }) 11 | -------------------------------------------------------------------------------- /other/react-native/jest.config.js: -------------------------------------------------------------------------------- 1 | // const jestConfig = require('kcd-scripts/config').jest 2 | 3 | module.exports = { 4 | preset: 'react-native', 5 | rootDir: '../../', 6 | roots: ['.'], 7 | transform: { 8 | '^.+\\.js$': '/node_modules/react-native/jest/preprocessor.js', 9 | }, 10 | testMatch: ['/other/react-native/__tests__/**/*.js?(x)'], 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "noUnusedLocals": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noUnusedParameters": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noEmitOnError": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "lib": ["es2016", "dom"] 12 | }, 13 | "include": ["test/**/*.tsx"] 14 | } 15 | -------------------------------------------------------------------------------- /other/ssr/jest.config.js: -------------------------------------------------------------------------------- 1 | // This is separate because the test environment is set via the config 2 | // and we want most of our tests to run with jsdom, but we still want 3 | // to make sure that the server rendering use case continues to work. 4 | const jestConfig = require('kcd-scripts/config').jest 5 | 6 | module.exports = Object.assign(jestConfig, { 7 | roots: ['.'], 8 | testEnvironment: 'node', 9 | }) 10 | -------------------------------------------------------------------------------- /docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Home 3 | route: / 4 | order: 1 5 | --- 6 | 7 | # Downshift 8 | 9 | This might be the future of the docs. But for now we're just using this for 10 | Cypress tests. 11 | 12 | For docs see the repo: [github.com/downshift-js/downshift](https://github.com/downshift-js/downshift) 13 | 14 | For examples see: [github.com/kentcdodds/downshift-examples](https://github.com/kentcdodds/downshift-examples) 15 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const commonjs = require('rollup-plugin-commonjs') 2 | const config = require('kcd-scripts/dist/config/rollup.config.js') 3 | 4 | const cjsPluginIndex = config.plugins.findIndex( 5 | plugin => plugin.name === 'commonjs', 6 | ) 7 | config.plugins[cjsPluginIndex] = commonjs({ 8 | include: 'node_modules/**', 9 | namedExports: { 10 | 'react-is': ['isForwardRef'], 11 | }, 12 | }) 13 | 14 | module.exports = config 15 | -------------------------------------------------------------------------------- /other/TYPESCRIPT_USAGE.md: -------------------------------------------------------------------------------- 1 | # Typescript Usage 2 | 3 | The current bundled Typescript definitions are incomplete and based around the 4 | needs of the developers who contributed them. 5 | 6 | Pull requests to improve them are welcome and appreciated. If you've never 7 | contributed to open source before, then you may find 8 | [this free video course](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) 9 | helpful. 10 | -------------------------------------------------------------------------------- /other/misc-tests/jest.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('kcd-scripts/config').jest 2 | const babelHelpersList = require('@babel/helpers').list 3 | 4 | module.exports = Object.assign(jestConfig, { 5 | roots: ['.'], 6 | testEnvironment: 'jsdom', 7 | moduleNameMapper: babelHelpersList.reduce((aliasMap, helper) => { 8 | aliasMap[ 9 | `@babel/runtime/helpers/esm/${helper}` 10 | ] = `@babel/runtime/helpers/${helper}` 11 | return aliasMap 12 | }, {}), 13 | }) 14 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/downshift.get-menu-props.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`not applying the ref prop results in an error 1`] = `"downshift: The ref prop \\"ref\\" from getMenuProps was not applied correctly on your menu element."`; 4 | 5 | exports[`using a composite component and calling getMenuProps without a refKey results in an error 1`] = `"downshift: The ref prop \\"ref\\" from getMenuProps was not applied correctly on your menu element."`; 6 | -------------------------------------------------------------------------------- /src/__tests__/utils.pick-state.js: -------------------------------------------------------------------------------- 1 | import {pickState} from '../utils' 2 | 3 | test('pickState only picks state that downshift cares about', () => { 4 | const otherStateToSet = { 5 | isOpen: true, 6 | foo: 0, 7 | } 8 | const result = pickState(otherStateToSet) 9 | const expected = {isOpen: true} 10 | const resultKeys = Object.keys(result) 11 | const expectedKeys = Object.keys(expected) 12 | resultKeys.sort() 13 | expectedKeys.sort() 14 | expect(result).toEqual(expected) 15 | expect(resultKeys).toEqual(expectedKeys) 16 | }) 17 | -------------------------------------------------------------------------------- /other/USERS.md: -------------------------------------------------------------------------------- 1 | # Users 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | If you or your company uses this project, add your name to this list! Eventually 10 | we may have a website to showcase these (wanna build it!?) 11 | 12 | > No users have been added yet! 13 | 14 | 19 | -------------------------------------------------------------------------------- /docs/useSelect/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Introduction 3 | menu: useSelect 4 | route: /hooks/use-select 5 | --- 6 | 7 | # useSelect 8 | 9 | The `useSelect` hook provides functionality and accessibility to a dropdown that should act as a HTML `` custom dropdown [click here][select-readme]. 11 | 12 | ## Roadmap and contributions 13 | 14 | Next steps: 15 | 16 | - complete testing for the `useSelect` hook and polishing API. 17 | - create types (TS and Flow) for it or re-write it directly in Typescript. 18 | - create `useAutocomplete` hook (the old Downshift default component) for the combobox design pattern. 19 | - create `multiple` versions for `useSelect` and `useAutocomplete`. 20 | 21 | [hooks-issue]: https://github.com/downshift-js/downshift/issues/683 22 | [select-readme]: https://github.com/downshift-js/downshift/tree/master/src/hooks/useSelect 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 PayPal 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | - `downshift` version: 15 | - `node` version: 16 | - `npm` (or `yarn`) version: 17 | 18 | **Relevant code or config** 19 | 20 | ```javascript 21 | ``` 22 | 23 | **What you did**: 24 | 25 | **What happened**: 26 | 27 | 28 | 29 | **Reproduction repository**: 30 | 31 | 35 | 36 | **Problem description**: 37 | 38 | **Suggested solution**: 39 | -------------------------------------------------------------------------------- /src/__tests__/set-a11y-status.js: -------------------------------------------------------------------------------- 1 | jest.useFakeTimers() 2 | 3 | beforeEach(() => { 4 | document.body.innerHTML = '' 5 | }) 6 | 7 | test('sets the status', () => { 8 | const setA11yStatus = setup() 9 | setA11yStatus('hello') 10 | expect(document.body.firstChild).toMatchSnapshot() 11 | }) 12 | 13 | test('replaces the status with a different one', () => { 14 | const setA11yStatus = setup() 15 | setA11yStatus('hello') 16 | setA11yStatus('goodbye') 17 | expect(document.body.firstChild).toMatchSnapshot() 18 | }) 19 | 20 | test('does add anything for an empty string', () => { 21 | const setA11yStatus = setup() 22 | setA11yStatus('') 23 | expect(document.body.firstChild).toMatchSnapshot() 24 | }) 25 | 26 | test('escapes HTML', () => { 27 | const setA11yStatus = setup() 28 | setA11yStatus('') 29 | expect(document.body.firstChild).toMatchSnapshot() 30 | }) 31 | 32 | test('performs cleanup after a timeout', () => { 33 | const setA11yStatus = setup() 34 | setA11yStatus('hello') 35 | jest.runAllTimers() 36 | expect(document.body.firstChild).toMatchSnapshot() 37 | }) 38 | 39 | function setup() { 40 | jest.resetModules() 41 | return require('../set-a11y-status').default 42 | } 43 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/downshift.get-item-props.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getItemProps defaults the index when no index is given 1`] = ` 4 | 46 | `; 47 | 48 | exports[`getItemProps logs a helpful error when no object is given 1`] = `"The property \\"item\\" is required in \\"getItemProps\\""`; 49 | 50 | exports[`getItemProps logs error when no item is given 1`] = `"The property \\"item\\" is required in \\"getItemProps\\""`; 51 | -------------------------------------------------------------------------------- /other/react-native/__tests__/__snapshots__/render-tests.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders with React Native components 1`] = ` 4 | 11 | 25 | 26 | 34 | foo 35 | 36 | 44 | bar 45 | 46 | 47 | 48 | `; 49 | -------------------------------------------------------------------------------- /other/ssr/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOMServer from 'react-dom/server' 3 | import Downshift, {resetIdCounter} from '../../../src' 4 | 5 | test('does not throw an error when server rendering', () => { 6 | expect(() => { 7 | ReactDOMServer.renderToString( 8 | 9 | {({getInputProps, getLabelProps}) => ( 10 |
11 |
14 | )} 15 |
, 16 | ) 17 | }).not.toThrow() 18 | }) 19 | 20 | test('resets idCounter', () => { 21 | const getRenderedString = () => { 22 | resetIdCounter() 23 | return ReactDOMServer.renderToString( 24 | 25 | {({getInputProps, getLabelProps}) => ( 26 |
27 |
30 | )} 31 |
, 32 | ) 33 | } 34 | 35 | const firstRun = getRenderedString() 36 | const secondRun = getRenderedString() 37 | 38 | expect(firstRun).toBe(secondRun) 39 | }) 40 | 41 | /* eslint jsx-a11y/label-has-for:0 */ 42 | -------------------------------------------------------------------------------- /other/react-native/__tests__/onBlur-tests.js: -------------------------------------------------------------------------------- 1 | import {Text, TextInput, View} from 'react-native' 2 | import React from 'react' 3 | 4 | // Note: test renderer must be required after react-native. 5 | import TestRenderer from 'react-test-renderer' 6 | 7 | import Downshift from '../../../dist/downshift.native.cjs' 8 | 9 | const RootView = ({innerRef, ...rest}) => 10 | 11 | test('calls onBlur and does not crash when there is no document', () => { 12 | const Input = jest.fn(props => ) 13 | 14 | const element = ( 15 | 16 | {({getRootProps, getInputProps, getItemProps}) => ( 17 | 18 | 19 | 20 | foo 21 | bar 22 | 23 | 24 | )} 25 | 26 | ) 27 | TestRenderer.create(element) 28 | 29 | const [[firstArg]] = Input.mock.calls 30 | expect(firstArg).toMatchObject({ 31 | onBlur: expect.any(Function), 32 | }) 33 | const fakeEvent = 'blur' 34 | firstArg.onBlur(fakeEvent) 35 | }) 36 | 37 | /* 38 | eslint 39 | react/prop-types: 0, 40 | import/extensions: 0, 41 | import/no-unresolved: 0 42 | */ 43 | -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "dist/downshift.cjs.js": { 3 | "bundled": 81341, 4 | "minified": 37651, 5 | "gzipped": 9471 6 | }, 7 | "preact/dist/downshift.cjs.js": { 8 | "bundled": 79966, 9 | "minified": 36550, 10 | "gzipped": 9356 11 | }, 12 | "preact/dist/downshift.umd.min.js": { 13 | "bundled": 101318, 14 | "minified": 35173, 15 | "gzipped": 11515 16 | }, 17 | "preact/dist/downshift.umd.js": { 18 | "bundled": 115158, 19 | "minified": 41144, 20 | "gzipped": 13029 21 | }, 22 | "dist/downshift.umd.min.js": { 23 | "bundled": 106042, 24 | "minified": 36525, 25 | "gzipped": 12082 26 | }, 27 | "dist/downshift.umd.js": { 28 | "bundled": 144403, 29 | "minified": 50045, 30 | "gzipped": 15663 31 | }, 32 | "dist/downshift.esm.js": { 33 | "bundled": 80879, 34 | "minified": 37277, 35 | "gzipped": 9393, 36 | "treeshaked": { 37 | "rollup": { 38 | "code": 673, 39 | "import_statements": 347 40 | }, 41 | "webpack": { 42 | "code": 27308 43 | } 44 | } 45 | }, 46 | "preact/dist/downshift.esm.js": { 47 | "bundled": 79504, 48 | "minified": 36176, 49 | "gzipped": 9274, 50 | "treeshaked": { 51 | "rollup": { 52 | "code": 674, 53 | "import_statements": 348 54 | }, 55 | "webpack": { 56 | "code": 27317 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/set-a11y-status.js: -------------------------------------------------------------------------------- 1 | import {debounce} from './utils' 2 | 3 | let statusDiv 4 | 5 | const cleanupStatus = debounce(() => { 6 | getStatusDiv().textContent = '' 7 | }, 500) 8 | 9 | /** 10 | * @param {String} status the status message 11 | * @param {Object} documentProp document passed by the user. 12 | */ 13 | function setStatus(status, documentProp) { 14 | const div = getStatusDiv(documentProp) 15 | if (!status) { 16 | return 17 | } 18 | 19 | div.textContent = status 20 | cleanupStatus() 21 | } 22 | 23 | /** 24 | * Get the status node or create it if it does not already exist. 25 | * @param {Object} documentProp document passed by the user. 26 | * @return {HTMLElement} the status node. 27 | */ 28 | function getStatusDiv(documentProp = document) { 29 | if (statusDiv) { 30 | return statusDiv 31 | } 32 | 33 | statusDiv = documentProp.createElement('div') 34 | statusDiv.setAttribute('id', 'a11y-status-message') 35 | statusDiv.setAttribute('role', 'status') 36 | statusDiv.setAttribute('aria-live', 'polite') 37 | statusDiv.setAttribute('aria-relevant', 'additions text') 38 | Object.assign(statusDiv.style, { 39 | border: '0', 40 | clip: 'rect(0 0 0 0)', 41 | height: '1px', 42 | margin: '-1px', 43 | overflow: 'hidden', 44 | padding: '0', 45 | position: 'absolute', 46 | width: '1px', 47 | }) 48 | documentProp.body.appendChild(statusDiv) 49 | return statusDiv 50 | } 51 | 52 | export default setStatus 53 | -------------------------------------------------------------------------------- /src/__tests__/downshift.focus-restoration.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render, fireEvent} from '@testing-library/react' 3 | import Downshift from '../' 4 | 5 | test('focus restored upon item mouse click', () => { 6 | const {queryByTestId, container} = renderDownshift(['A', 'B']) 7 | const inputNode = container.querySelector(`input`) 8 | const buttonNode = container.querySelector('button') 9 | const item = queryByTestId('A') 10 | 11 | expect(document.activeElement.nodeName).toEqual('BODY') 12 | 13 | inputNode.focus() 14 | expect(document.activeElement).toBe(inputNode) 15 | 16 | fireEvent.click(item) 17 | expect(document.activeElement).toBe(inputNode) 18 | 19 | buttonNode.focus() 20 | expect(document.activeElement).toBe(buttonNode) 21 | 22 | fireEvent.click(item) 23 | expect(document.activeElement).toBe(buttonNode) 24 | }) 25 | 26 | function renderDownshift(items) { 27 | const id = 'languages[0].name' 28 | 29 | return render( 30 | 31 | {({getInputProps, getItemProps, getToggleButtonProps}) => ( 32 |
33 | 34 |
43 | )} 44 |
, 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/stateChangeTypes.js: -------------------------------------------------------------------------------- 1 | import productionEnum from './productionEnum.macro' 2 | 3 | export const unknown = productionEnum('__autocomplete_unknown__') 4 | export const mouseUp = productionEnum('__autocomplete_mouseup__') 5 | export const itemMouseEnter = productionEnum('__autocomplete_item_mouseenter__') 6 | export const keyDownArrowUp = productionEnum( 7 | '__autocomplete_keydown_arrow_up__', 8 | ) 9 | export const keyDownArrowDown = productionEnum( 10 | '__autocomplete_keydown_arrow_down__', 11 | ) 12 | export const keyDownEscape = productionEnum('__autocomplete_keydown_escape__') 13 | export const keyDownEnter = productionEnum('__autocomplete_keydown_enter__') 14 | export const keyDownHome = productionEnum('__autocomplete_keydown_home__') 15 | export const keyDownEnd = productionEnum('__autocomplete_keydown_end__') 16 | export const clickItem = productionEnum('__autocomplete_click_item__') 17 | export const blurInput = productionEnum('__autocomplete_blur_input__') 18 | export const changeInput = productionEnum('__autocomplete_change_input__') 19 | export const keyDownSpaceButton = productionEnum( 20 | '__autocomplete_keydown_space_button__', 21 | ) 22 | export const clickButton = productionEnum('__autocomplete_click_button__') 23 | export const blurButton = productionEnum('__autocomplete_blur_button__') 24 | export const controlledPropUpdatedSelectedItem = productionEnum( 25 | '__autocomplete_controlled_prop_updated_selected_item__', 26 | ) 27 | export const touchEnd = productionEnum('__autocomplete_touchend__') 28 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/downshift.misc.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`expect console.warn to fire—depending on process.env.NODE_ENV value it should warn exactly one time when value !== production 1`] = ` 4 | Array [ 5 | "downshift: An object was passed to the default implementation of \`itemToString\`. You should probably provide your own \`itemToString\` implementation. Please refer to the \`itemToString\` API documentation.", 6 | "The object that was passed:", 7 | Object { 8 | "label": "test", 9 | "value": "any", 10 | }, 11 | ] 12 | `; 13 | 14 | exports[`warns when controlled component becomes uncontrolled 1`] = ` 15 | Array [ 16 | Array [ 17 | "downshift: A component has changed the controlled prop \\"selectedItem\\" to be uncontrolled. This prop should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled Downshift element for the lifetime of the component. More info: https://github.com/downshift-js/downshift#control-props", 18 | ], 19 | ] 20 | `; 21 | 22 | exports[`warns when uncontrolled component becomes controlled 1`] = ` 23 | Array [ 24 | Array [ 25 | "downshift: A component has changed the uncontrolled prop \\"selectedItem\\" to be controlled. This prop should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled Downshift element for the lifetime of the component. More info: https://github.com/downshift-js/downshift#control-props", 26 | ], 27 | ] 28 | `; 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | 37 | - [ ] Documentation 38 | - [ ] Tests 39 | - [ ] Ready to be merged 40 | 41 | - [ ] Added myself to contributors table 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/set-a11y-status.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`does add anything for an empty string 1`] = ` 4 |
11 | `; 12 | 13 | exports[`escapes HTML 1`] = ` 14 |
21 | <script>alert("!!!")</script> 22 |
23 | `; 24 | 25 | exports[`performs cleanup after a timeout 1`] = ` 26 |
33 | `; 34 | 35 | exports[`replaces the status with a different one 1`] = ` 36 |
43 | goodbye 44 |
45 | `; 46 | 47 | exports[`sets the status 1`] = ` 48 |
55 | hello 56 |
57 | `; 58 | -------------------------------------------------------------------------------- /other/manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | This project has an automated release set up. So things are only released when 10 | there are useful changes in the code that justify a release. But sometimes 11 | things get messed up one way or another and we need to trigger the release 12 | ourselves. When this happens, simply bump the number below and commit that with 13 | the following commit message based on your needs: 14 | 15 | **Major** 16 | 17 | ``` 18 | fix(release): manually release a major version 19 | 20 | There was an issue with a major release, so this manual-releases.md 21 | change is to release a new major version. 22 | 23 | Reference: # 24 | 25 | BREAKING CHANGE: 26 | ``` 27 | 28 | **Minor** 29 | 30 | ``` 31 | feat(release): manually release a minor version 32 | 33 | There was an issue with a minor release, so this manual-releases.md 34 | change is to release a new minor version. 35 | 36 | Reference: # 37 | ``` 38 | 39 | **Patch** 40 | 41 | ``` 42 | fix(release): manually release a patch version 43 | 44 | There was an issue with a patch release, so this manual-releases.md 45 | change is to release a new patch version. 46 | 47 | Reference: # 48 | ``` 49 | 50 | The number of times we've had to do a manual release is: 4 51 | -------------------------------------------------------------------------------- /src/__tests__/downshift.misc-with-utils-mocked.js: -------------------------------------------------------------------------------- 1 | // this is stuff that I couldn't think fit anywhere else 2 | // but we still want to have tested. 3 | 4 | import React from 'react' 5 | import {render, fireEvent} from '@testing-library/react' 6 | import Downshift from '../' 7 | import {scrollIntoView} from '../utils' 8 | 9 | jest.useFakeTimers() 10 | jest.mock('../utils') 11 | 12 | test('does not scroll from an onMouseMove event', () => { 13 | class HighlightedIndexController extends React.Component { 14 | state = {highlightedIndex: 10} 15 | handleStateChange = changes => { 16 | if (changes.hasOwnProperty('highlightedIndex')) { 17 | this.setState({highlightedIndex: changes.highlightedIndex}) 18 | } 19 | } 20 | render() { 21 | return ( 22 | 26 | {({getInputProps, getItemProps}) => ( 27 |
28 | 29 |
30 |
31 |
32 | )} 33 | 34 | ) 35 | } 36 | } 37 | const {queryByTestId} = render() 38 | const input = queryByTestId('input') 39 | const item = queryByTestId('item-2') 40 | fireEvent.mouseMove(item) 41 | jest.runAllTimers() 42 | expect(scrollIntoView).not.toHaveBeenCalled() 43 | // now let's make sure that we can still scroll items into view 44 | // ↓ 45 | fireEvent.keyDown(input, {key: 'ArrowDown'}) 46 | expect(scrollIntoView).toHaveBeenCalledWith(item, undefined) 47 | }) 48 | -------------------------------------------------------------------------------- /src/__tests__/downshift.get-label-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import Downshift from '../' 4 | 5 | beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {})) 6 | afterEach(() => console.error.mockRestore()) 7 | 8 | test('label "for" attribute is set to the input "id" attribute', () => { 9 | const {label, input} = renderDownshift() 10 | expect(label.getAttribute('for')).toBeDefined() 11 | expect(label.getAttribute('for')).toBe(input.getAttribute('id')) 12 | }) 13 | 14 | test('when the inputId prop is set, the label for is set to it', () => { 15 | const id = 'foo' 16 | const {label, input} = renderDownshift({ 17 | props: {inputId: id}, 18 | }) 19 | expect(label.getAttribute('for')).toBe(id) 20 | expect(label.getAttribute('for')).toBe(input.getAttribute('id')) 21 | }) 22 | 23 | function renderDownshift({props} = {}) { 24 | const utils = render() 25 | return { 26 | ...utils, 27 | input: utils.queryByTestId('input'), 28 | label: utils.queryByTestId('label'), 29 | } 30 | } 31 | 32 | function BasicDownshift({ 33 | inputProps, 34 | labelProps, 35 | getLabelPropsFirst = false, 36 | ...rest 37 | }) { 38 | return ( 39 | 40 | {({getInputProps, getLabelProps}) => { 41 | if (getLabelPropsFirst) { 42 | labelProps = getLabelProps(labelProps) 43 | inputProps = getInputProps(inputProps) 44 | } else { 45 | inputProps = getInputProps(inputProps) 46 | labelProps = getLabelProps(labelProps) 47 | } 48 | return ( 49 |
50 | 51 |
53 | ) 54 | }} 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/useSelect/stateChangeTypes.js: -------------------------------------------------------------------------------- 1 | import productionEnum from '../../productionEnum.macro' 2 | 3 | export const MenuKeyDownArrowDown = productionEnum( 4 | '__menu_keydown_arrow_down__', 5 | ) 6 | export const MenuKeyDownArrowUp = productionEnum('__menu_keydown_arrow_up__') 7 | export const MenuKeyDownEscape = productionEnum('__menu_keydown_escape__') 8 | export const MenuKeyDownHome = productionEnum('__menu_keydown_home__') 9 | export const MenuKeyDownEnd = productionEnum('__menu_keydown_end__') 10 | export const MenuKeyDownEnter = productionEnum('__menu_keydown_enter__') 11 | export const MenuKeyDownCharacter = productionEnum('__menu_keydown_character__') 12 | export const MenuBlur = productionEnum('__menu_blur__') 13 | export const ItemMouseMove = productionEnum('__item_mouse_move__') 14 | export const ItemClick = productionEnum('__item_click__') 15 | export const ToggleButtonKeyDownCharacter = productionEnum( 16 | '__togglebutton_keydown_character__', 17 | ) 18 | export const ToggleButtonKeyDownArrowDown = productionEnum( 19 | '__togglebutton_keydown_arrow_down__', 20 | ) 21 | export const ToggleButtonKeyDownArrowUp = productionEnum( 22 | '__togglebutton_keydown_arrow_up__', 23 | ) 24 | export const ToggleButtonClick = productionEnum('__togglebutton_click__') 25 | export const FunctionToggleMenu = productionEnum('__function_toggle_menu__') 26 | export const FunctionOpenMenu = productionEnum('__function_open_menu__') 27 | export const FunctionCloseMenu = productionEnum('__function_close_menu__') 28 | export const FunctionSetHighlightedIndex = productionEnum( 29 | '__function_set_highlighted_index__', 30 | ) 31 | export const FunctionSelectItem = productionEnum('__function_select_item__') 32 | export const FunctionClearKeysSoFar = productionEnum( 33 | '__function_clear_keys_so_far__', 34 | ) 35 | export const FunctionReset = productionEnum('__function_reset__') 36 | -------------------------------------------------------------------------------- /test/custom.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Downshift, {ControllerStateAndHelpers} from '../' 3 | 4 | type Item = string 5 | 6 | interface Props {} 7 | 8 | interface State { 9 | items: Array 10 | } 11 | 12 | const CustomList: React.StatelessComponent<{isOpen: boolean}> = ({ 13 | isOpen, 14 | children, 15 | }) =>
{children}
16 | 17 | const CustomListItem: React.StatelessComponent<{isSelected: boolean}> = ({ 18 | isSelected, 19 | children, 20 | }) =>
{children}
21 | 22 | export default class App extends React.Component { 23 | state: State = { 24 | items: ['apple', 'orange', 'carrot'], 25 | } 26 | 27 | onChange = (selectedItem: Item) => { 28 | console.log('selectedItem', selectedItem) 29 | } 30 | 31 | render() { 32 | const items = this.state.items 33 | const initialSelectedItem = this.state.items[0] 34 | 35 | return ( 36 | 37 | {({ 38 | getToggleButtonProps, 39 | getItemProps, 40 | selectedItem, 41 | isOpen, 42 | }: ControllerStateAndHelpers) => ( 43 |
44 |
{selectedItem}
45 | 46 | {items.map((item, index) => ( 47 | 55 | {item} 56 | 57 | ))} 58 | 59 |
60 | )} 61 |
62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/__tests__/utils.get-a11y-status-message.js: -------------------------------------------------------------------------------- 1 | import {getA11yStatusMessage} from '../utils' 2 | 3 | const itemToString = i => String(i) 4 | 5 | const tests = [ 6 | { 7 | input: { 8 | isOpen: false, 9 | selectedItem: null, 10 | }, 11 | output: '', 12 | }, 13 | { 14 | input: { 15 | itemToString, 16 | isOpen: false, 17 | selectedItem: 'Grapes', 18 | }, 19 | output: 'Grapes', 20 | }, 21 | { 22 | input: { 23 | isOpen: true, 24 | resultCount: 0, 25 | }, 26 | output: 'No results are available.', 27 | }, 28 | { 29 | input: { 30 | isOpen: true, 31 | resultCount: 10, 32 | }, 33 | output: 34 | '10 results are available, use up and down arrow keys to navigate. Press Enter key to select.', 35 | }, 36 | { 37 | input: { 38 | isOpen: true, 39 | resultCount: 9, 40 | previousResultCount: 12, 41 | }, 42 | output: 43 | '9 results are available, use up and down arrow keys to navigate. Press Enter key to select.', 44 | }, 45 | { 46 | input: { 47 | isOpen: true, 48 | resultCount: 8, 49 | previousResultCount: 20, 50 | highlightedItem: 'Oranges', 51 | }, 52 | output: 53 | '8 results are available, use up and down arrow keys to navigate. Press Enter key to select.', 54 | }, 55 | { 56 | input: { 57 | isOpen: true, 58 | resultCount: 1, 59 | }, 60 | output: 61 | '1 result is available, use up and down arrow keys to navigate. Press Enter key to select.', 62 | }, 63 | { 64 | input: { 65 | itemToString, 66 | isOpen: true, 67 | resultCount: 7, 68 | previousResultCount: 7, 69 | selectedItem: 'Raspberries', 70 | highlightedItem: 'Raspberries', 71 | }, 72 | output: '', 73 | }, 74 | ] 75 | 76 | tests.forEach(({input, output}) => { 77 | test(`${JSON.stringify(input)} results in ${JSON.stringify(output)}`, () => { 78 | expect(getA11yStatusMessage(input)).toBe(output) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /other/react-native/__tests__/render-tests.js: -------------------------------------------------------------------------------- 1 | import {Text, TextInput, View} from 'react-native' 2 | import React from 'react' 3 | 4 | // Note: test renderer must be required after react-native. 5 | import TestRenderer from 'react-test-renderer' 6 | 7 | import Downshift from '../../../dist/downshift.native.cjs' 8 | 9 | test('renders with React Native components', () => { 10 | const RootView = ({innerRef, ...rest}) => 11 | const childrenSpy = jest.fn(({getRootProps, getInputProps, getItemProps}) => ( 12 | 13 | 14 | 15 | foo 16 | bar 17 | 18 | 19 | )) 20 | const element = {childrenSpy} 21 | const renderer = TestRenderer.create(element) 22 | expect(childrenSpy).toHaveBeenCalledWith( 23 | expect.objectContaining({ 24 | isOpen: false, 25 | highlightedIndex: null, 26 | selectedItem: null, 27 | inputValue: '', 28 | }), 29 | ) 30 | const tree = renderer.toJSON() 31 | expect(tree).toMatchSnapshot() 32 | }) 33 | 34 | test('can use children instead of render prop', () => { 35 | const RootView = ({innerRef, ...rest}) => 36 | const childrenSpy = jest.fn(({getRootProps, getInputProps, getItemProps}) => ( 37 | 38 | 39 | 40 | foo 41 | bar 42 | 43 | 44 | )) 45 | const element = {childrenSpy} 46 | TestRenderer.create(element) 47 | expect(childrenSpy).toHaveBeenCalledTimes(1) 48 | }) 49 | 50 | /* 51 | eslint 52 | react/prop-types: 0, 53 | import/extensions: 0, 54 | import/no-unresolved: 0 55 | */ 56 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/downshift.aria.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic snapshot 1`] = ` 4 | 49 | `; 50 | 51 | exports[`can override the ids 1`] = ` 52 | 97 | `; 98 | -------------------------------------------------------------------------------- /other/misc-tests/__tests__/build.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is here to validate that the built version 3 | * of the library exposes the module in the way that we 4 | * want it to. Specifically that the ES6 module import can 5 | * get the downshift function via default import. Also that 6 | * the CommonJS require returns the downshift function 7 | * (rather than an object that has the downshift as a 8 | * `default` property). 9 | * 10 | * This file is unable to validate the global export. 11 | */ 12 | import assert from 'assert' 13 | 14 | import esImport from '../../../dist/downshift.esm' 15 | 16 | import cjsImport from '../../../' // picks up the main from package.json 17 | 18 | import umdImport from '../../../dist/downshift.umd' 19 | 20 | // intentionally left out because you shouldn't ever 21 | // try to require the ES file in CommonJS 22 | // const esRequire = require('../../../dist/downshift.es') 23 | const cjsRequire = require('../../../') // picks up the main from package.json 24 | const umdRequire = require('../../../dist/downshift.umd') 25 | 26 | test('stuff is good', () => { 27 | assert( 28 | isDownshiftComponent(esImport), 29 | 'ES build has a problem with ES Modules', 30 | ) 31 | 32 | assert( 33 | isDownshiftComponent(cjsImport), 34 | 'CJS build has a problem with ES Modules', 35 | ) 36 | 37 | assert( 38 | isDownshiftComponent(cjsRequire.default), 39 | 'CJS build has a problem with CJS', 40 | ) 41 | 42 | assert( 43 | isDownshiftComponent(umdImport), 44 | 'UMD build has a problem with ES Modules', 45 | ) 46 | 47 | assert( 48 | isDownshiftComponent(umdRequire.default), 49 | 'UMD build has a problem with CJS', 50 | ) 51 | 52 | // TODO: how could we validate the global export? 53 | }) 54 | 55 | function isDownshiftComponent(thing) { 56 | if (typeof thing !== 'function') { 57 | console.error( 58 | `downshift thing should be a function. It's a ${typeof thing} with the properties of: ${Object.keys( 59 | thing, 60 | ).join(', ')}`, 61 | ) 62 | return false 63 | } 64 | return true 65 | } 66 | 67 | /* 68 | eslint 69 | no-console: 0, 70 | import/extensions: 0, 71 | import/no-unresolved: 0, 72 | import/no-duplicates: 0, 73 | no-duplicate-imports: 0, 74 | */ 75 | -------------------------------------------------------------------------------- /test/basic.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Downshift, {StateChangeOptions, DownshiftInterface} from '../' 3 | 4 | type Item = string 5 | const TypedDownShift: DownshiftInterface = Downshift 6 | 7 | interface Props {} 8 | 9 | interface State { 10 | items: Array 11 | } 12 | 13 | export default class App extends React.Component { 14 | state: State = { 15 | items: ['apple', 'orange', 'carrot'], 16 | } 17 | 18 | onChange = (selectedItem: Item | null) => { 19 | console.log('selectedItem', selectedItem) 20 | } 21 | 22 | onUserAction = (changes: StateChangeOptions) => { 23 | console.log('type', changes.type) 24 | } 25 | 26 | render() { 27 | const items = this.state.items 28 | 29 | return ( 30 | 31 | {({ 32 | getToggleButtonProps, 33 | getInputProps, 34 | getItemProps, 35 | isOpen, 36 | inputValue, 37 | selectedItem, 38 | highlightedIndex, 39 | }) => ( 40 |
41 | 46 |
71 | )} 72 |
73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/custom.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react' 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import Downshift, { 5 | type ControllerStateAndHelpers, 6 | type DownshiftType, 7 | } from 'downshift' // eslint-disable-line import/no-unresolved 8 | 9 | type Item = string 10 | const DownshiftTyped: DownshiftType = Downshift 11 | 12 | type Props = {} 13 | 14 | type State = { 15 | items: Array, 16 | } 17 | 18 | const CustomList = ({ 19 | isOpen, 20 | children, 21 | }: { 22 | isOpen: boolean, 23 | children: React.Node, 24 | }) =>
{children}
25 | 26 | const CustomListItem = ({ 27 | isSelected, 28 | children, 29 | }: { 30 | isSelected: boolean, 31 | children: React.Node, 32 | }) =>
{children}
33 | 34 | export default class App extends React.Component { 35 | state: State = { 36 | items: ['apple', 'orange', 'carrot'], 37 | } 38 | 39 | onChange = (selectedItem: Item) => { 40 | // eslint-disable-next-line no-console 41 | console.log('selectedItem', selectedItem) 42 | } 43 | 44 | render() { 45 | const items = this.state.items 46 | const defaultSelectedItem = this.state.items[0] 47 | 48 | return ( 49 | 50 | {({ 51 | getToggleButtonProps, 52 | getItemProps, 53 | selectedItem, 54 | isOpen, 55 | }: ControllerStateAndHelpers) => { 56 | return ( 57 |
58 |
{selectedItem}
59 | 60 | {items.map((item, index) => ( 61 | 69 | {item} 70 | 71 | ))} 72 | 73 |
74 | ) 75 | }} 76 |
77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/__tests__/utils.reset-id-counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import Downshift from '../' 4 | import {resetIdCounter} from '../utils' 5 | 6 | test('renders with correct and predictable auto generated id upon resetIdCounter call', () => { 7 | resetIdCounter() 8 | 9 | const renderDownshift = ({getInputProps, getLabelProps, getItemProps}) => ( 10 |
11 |
24 | ) 25 | 26 | const setup1 = setup({renderDownshift}) 27 | expect(setup1.id).toBe('downshift-0') 28 | expect(setup1.label.getAttribute('for')).toBe('downshift-0-input') 29 | expect(setup1.input.getAttribute('id')).toBe('downshift-0-input') 30 | expect(setup1.item.getAttribute('id')).toBe('downshift-0-item-0') 31 | setup1.unmount() 32 | 33 | const setup2 = setup({renderDownshift}) 34 | expect(setup2.id).toBe('downshift-1') 35 | expect(setup2.label.getAttribute('for')).toBe('downshift-1-input') 36 | expect(setup2.input.getAttribute('id')).toBe('downshift-1-input') 37 | expect(setup2.item.getAttribute('id')).toBe('downshift-1-item-0') 38 | setup2.unmount() 39 | 40 | resetIdCounter() 41 | 42 | const setup3 = setup({renderDownshift}) 43 | expect(setup3.id).toBe('downshift-0') 44 | expect(setup3.label.getAttribute('for')).toBe('downshift-0-input') 45 | expect(setup3.input.getAttribute('id')).toBe('downshift-0-input') 46 | expect(setup3.item.getAttribute('id')).toBe('downshift-0-item-0') 47 | setup3.unmount() 48 | }) 49 | 50 | function setup({renderDownshift = () =>
, ...props} = {}) { 51 | let renderArg 52 | const childrenSpy = jest.fn(controllerArg => { 53 | renderArg = controllerArg 54 | return renderDownshift(controllerArg) 55 | }) 56 | const utils = render({childrenSpy}) 57 | const input = utils.queryByTestId('input') 58 | const label = utils.queryByTestId('label') 59 | const item = utils.queryByTestId('item-0') 60 | return {...utils, input, label, item, ...renderArg} 61 | } 62 | -------------------------------------------------------------------------------- /src/__tests__/downshift.aria.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import Downshift from '../' 4 | import {resetIdCounter} from '../utils' 5 | 6 | beforeEach(() => { 7 | resetIdCounter() 8 | }) 9 | 10 | test('basic snapshot', () => { 11 | const {container} = renderDownshift({props: {selectedItem: 'item'}}) 12 | expect(container.firstChild).toMatchSnapshot() 13 | }) 14 | 15 | test('can override the ids', () => { 16 | const {container} = renderDownshift({ 17 | props: { 18 | inputId: 'custom-input-id', 19 | labelId: 'custom-label-id', 20 | menuId: 'custom-menu-id', 21 | getItemId: index => `custom-item-id-${index}`, 22 | }, 23 | }) 24 | expect(container.firstChild).toMatchSnapshot() 25 | }) 26 | 27 | test('if aria-label is provided to the menu then aria-labelledby is not applied to the label', () => { 28 | const customLabel = 'custom menu label' 29 | const {menu} = renderDownshift({ 30 | menuProps: {'aria-label': customLabel}, 31 | }) 32 | expect(menu.getAttribute('aria-labelledby')).toBeNull() 33 | expect(menu.getAttribute('aria-label')).toBe(customLabel) 34 | }) 35 | 36 | function renderDownshift({renderFn, props, menuProps} = {}) { 37 | function defaultRenderFn({ 38 | getInputProps, 39 | getToggleButtonProps, 40 | getLabelProps, 41 | getMenuProps, 42 | getItemProps, 43 | }) { 44 | return ( 45 |
46 | 49 | 50 |
57 | ) 58 | } 59 | 60 | let renderArg 61 | const childrenSpy = jest.fn(controllerArg => { 62 | renderArg = controllerArg 63 | return renderFn || defaultRenderFn(controllerArg) 64 | }) 65 | const utils = render({childrenSpy}) 66 | return { 67 | ...utils, 68 | renderArg, 69 | root: utils.queryByTestId('root'), 70 | input: utils.queryByTestId('input'), 71 | menu: utils.queryByTestId('menu'), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /other/public/logo/downshift.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /src/hooks/useSelect/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getItemIndex, 3 | getItemIndexByCharacterKey, 4 | itemToString, 5 | } from '../../utils' 6 | import {getA11yStatusMessage} from '../utils' 7 | import reducer from '../reducer' 8 | 9 | describe('utils', () => { 10 | describe('itemToString', () => { 11 | test('returns empty string if item is falsy', () => { 12 | const emptyString = itemToString(null) 13 | expect(emptyString).toBe('') 14 | }) 15 | }) 16 | 17 | describe('getItemIndex', () => { 18 | test('returns -1 if no items', () => { 19 | const index = getItemIndex(undefined, {}, []) 20 | expect(index).toBe(-1) 21 | }) 22 | 23 | test('returns index if passed', () => { 24 | const index = getItemIndex(5, {}, []) 25 | expect(index).toBe(5) 26 | }) 27 | 28 | test('returns index of item', () => { 29 | const item = {x: 2} 30 | const index = getItemIndex(undefined, item, [{x: 1}, item, {x: 2}]) 31 | expect(index).toBe(1) 32 | }) 33 | }) 34 | 35 | describe('getItemIndexByCharacterKey', () => { 36 | const items = ['a', 'b', 'aba', 'aab', 'bab'] 37 | 38 | test('returns to check from start if from highlightedIndex does not find anything', () => { 39 | const index = getItemIndexByCharacterKey('a', 3, items, item => item) 40 | expect(index).toBe(0) 41 | }) 42 | 43 | test('checks from highlightedIndex position inclusively if there is more than one key', () => { 44 | const index = getItemIndexByCharacterKey('aba', 2, items, item => item) 45 | expect(index).toBe(2) 46 | }) 47 | 48 | test('checks from highlightedIndex position exclusively if there is only one key', () => { 49 | const index = getItemIndexByCharacterKey('a', 2, items, item => item) 50 | expect(index).toBe(3) 51 | }) 52 | }) 53 | 54 | describe('getA11yStatusMessage', () => { 55 | test('returns empty if no items', () => { 56 | const message = getA11yStatusMessage({}) 57 | expect(message).toBe('') 58 | }) 59 | 60 | test('returns empty if no message can be created', () => { 61 | const message = getA11yStatusMessage({items: [], isOpen: false}) 62 | expect(message).toBe('') 63 | }) 64 | }) 65 | 66 | test('reducer throws error if called without proper action type', () => { 67 | expect(() => { 68 | reducer({}, {type: 'super-bogus'}) 69 | }).toThrowError('Reducer called without proper action type.') 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /other/react-native/__tests__/onChange-tests.js: -------------------------------------------------------------------------------- 1 | import {Text, TextInput, View} from 'react-native' 2 | import React from 'react' 3 | 4 | // Note: test renderer must be required after react-native. 5 | import TestRenderer from 'react-test-renderer' 6 | 7 | import Downshift from '../../../dist/downshift.native.cjs' 8 | 9 | const RootView = ({innerRef, ...rest}) => 10 | 11 | test('calls onChange when TextInput changes values', () => { 12 | const onChange = jest.fn() 13 | const Input = jest.fn(props => ) 14 | 15 | const element = ( 16 | 17 | {({getRootProps, getInputProps, getItemProps}) => ( 18 | 19 | 20 | 21 | foo 22 | bar 23 | 24 | 25 | )} 26 | 27 | ) 28 | TestRenderer.create(element) 29 | 30 | const [[firstArg]] = Input.mock.calls 31 | expect(firstArg).toMatchObject({ 32 | onChange: expect.any(Function), 33 | }) 34 | const fakeEvent = {nativeEvent: {text: 'foobar'}} 35 | firstArg.onChange(fakeEvent) 36 | 37 | expect(onChange).toHaveBeenCalledTimes(1) 38 | expect(onChange).toHaveBeenCalledWith(fakeEvent) 39 | }) 40 | 41 | test('calls onChangeText when TextInput changes values', () => { 42 | const onChangeText = jest.fn() 43 | const Input = jest.fn(props => ) 44 | 45 | const element = ( 46 | 47 | {({getRootProps, getInputProps, getItemProps}) => ( 48 | 49 | 50 | 51 | foo 52 | bar 53 | 54 | 55 | )} 56 | 57 | ) 58 | TestRenderer.create(element) 59 | 60 | const [[firstArg]] = Input.mock.calls 61 | expect(firstArg).toMatchObject({ 62 | onChangeText: expect.any(Function), 63 | }) 64 | const fakeEvent = 'foobar' 65 | firstArg.onChangeText(fakeEvent) 66 | 67 | expect(onChangeText).toHaveBeenCalledTimes(1) 68 | expect(onChangeText).toHaveBeenCalledWith(fakeEvent) 69 | }) 70 | 71 | /* 72 | eslint 73 | react/prop-types: 0, 74 | import/extensions: 0, 75 | import/no-unresolved: 0 76 | */ 77 | -------------------------------------------------------------------------------- /test/basic.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react' 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import Downshift, { 5 | type StateChangeOptions, 6 | type ControllerStateAndHelpers, 7 | type DownshiftType, 8 | } from 'downshift' // eslint-disable-line import/no-unresolved 9 | 10 | type Item = string 11 | const DownshiftTyped: DownshiftType = Downshift 12 | 13 | type Props = {} 14 | 15 | type State = { 16 | items: Array, 17 | } 18 | 19 | export default class App extends React.Component { 20 | state: State = { 21 | items: ['apple', 'orange', 'carrot'], 22 | } 23 | 24 | onChange = (selectedItem: Item) => { 25 | // eslint-disable-next-line no-console 26 | console.log('selectedItem', selectedItem) 27 | } 28 | 29 | onUserAction = (changes: StateChangeOptions) => { 30 | // eslint-disable-next-line no-console 31 | console.log('type', changes.type) 32 | } 33 | 34 | render() { 35 | const items = this.state.items 36 | 37 | return ( 38 | 39 | {({ 40 | getToggleButtonProps, 41 | getInputProps, 42 | getItemProps, 43 | isOpen, 44 | inputValue, 45 | selectedItem, 46 | highlightedIndex, 47 | }: ControllerStateAndHelpers) => ( 48 |
49 | 54 |
79 | )} 80 |
81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/__tests__/portal-support.js: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import {render, fireEvent} from '@testing-library/react' 5 | import Downshift from '../' 6 | 7 | test('will not reset when clicking within the menu', () => { 8 | class MyMenu extends React.Component { 9 | el = document.createElement('div') 10 | componentDidMount() { 11 | document.body.appendChild(this.el) 12 | } 13 | componentWillUnmount() { 14 | document.body.removeChild(this.el) 15 | } 16 | render() { 17 | return ReactDOM.createPortal( 18 |
19 | 20 | 28 |
, 29 | this.el, 30 | ) 31 | } 32 | } 33 | function MyPortalAutocomplete() { 34 | return ( 35 | 36 | {({ 37 | getMenuProps, 38 | getItemProps, 39 | getToggleButtonProps, 40 | isOpen, 41 | selectedItem, 42 | }) => ( 43 |
44 | {selectedItem ? ( 45 |
{selectedItem}
46 | ) : null} 47 | 50 | {isOpen ? : null} 51 |
52 | )} 53 |
54 | ) 55 | } 56 | const {getByTestId, queryByTestId} = render() 57 | expect(queryByTestId('menu')).toBeNull() 58 | getByTestId('button').click() 59 | expect(getByTestId('menu')).toBeInstanceOf(HTMLElement) 60 | 61 | const notAnItem = getByTestId('not-an-item') 62 | 63 | // Mouse events 64 | fireEvent.mouseDown(notAnItem) 65 | notAnItem.focus() // sets document.activeElement 66 | fireEvent.mouseUp(notAnItem) 67 | expect(getByTestId('menu')).toBeInstanceOf(HTMLElement) 68 | 69 | // Touch events 70 | fireEvent.touchStart(notAnItem) 71 | fireEvent.touchEnd(notAnItem) 72 | notAnItem.focus() // sets document.activeElement 73 | expect(getByTestId('menu')).toBeInstanceOf(HTMLElement) 74 | 75 | getByTestId('item').click() 76 | expect(queryByTestId('menu')).toBeNull() 77 | expect(getByTestId('selection')).toHaveTextContent('The item') 78 | }) 79 | -------------------------------------------------------------------------------- /src/hooks/useSelect/testUtils.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {useId} from '@reach/auto-id' 3 | import {render} from '@testing-library/react' 4 | import {renderHook} from '@testing-library/react-hooks' 5 | import {getElementIds, itemToString} from '../utils' 6 | import useSelect from '.' 7 | 8 | const items = [ 9 | 'Neptunium', 10 | 'Plutonium', 11 | 'Americium', 12 | 'Curium', 13 | 'Berkelium', 14 | 'Californium', 15 | 'Einsteinium', 16 | 'Fermium', 17 | 'Mendelevium', 18 | 'Nobelium', 19 | 'Lawrencium', 20 | 'Rutherfordium', 21 | 'Dubnium', 22 | 'Seaborgium', 23 | 'Bohrium', 24 | 'Hassium', 25 | 'Meitnerium', 26 | 'Darmstadtium', 27 | 'Roentgenium', 28 | 'Copernicium', 29 | 'Nihonium', 30 | 'Flerovium', 31 | 'Moscovium', 32 | 'Livermorium', 33 | 'Tennessine', 34 | 'Oganesson', 35 | ] 36 | 37 | jest.mock('@reach/auto-id', () => { 38 | return { 39 | useId: () => 'test-id', 40 | } 41 | }) 42 | 43 | const defaultIds = getElementIds(useId) 44 | 45 | const dataTestIds = { 46 | toggleButton: 'toggle-button-id', 47 | menu: 'menu-id', 48 | item: index => `item-id-${index}`, 49 | } 50 | 51 | const setupHook = props => { 52 | return renderHook(() => useSelect({items, ...props})) 53 | } 54 | 55 | const DropdownSelect = props => { 56 | const { 57 | isOpen, 58 | selectedItem, 59 | getToggleButtonProps, 60 | getLabelProps, 61 | getMenuProps, 62 | highlightedIndex, 63 | getItemProps, 64 | } = useSelect({items, ...props}) 65 | return ( 66 |
67 | 68 | 76 |
    77 | {isOpen && 78 | (props.items || items).map((item, index) => { 79 | const stringItem = 80 | item instanceof Object ? itemToString(item) : item 81 | return ( 82 |
  • 90 | {stringItem} 91 |
  • 92 | ) 93 | })} 94 |
95 |
96 | ) 97 | } 98 | 99 | const setup = props => render() 100 | 101 | export {dataTestIds, setup, items, setupHook, defaultIds} 102 | -------------------------------------------------------------------------------- /src/hooks/utils.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | function getElementIds( 4 | generateDefaultId, 5 | {id, labelId, menuId, getItemId, toggleButtonId} = {}, 6 | ) { 7 | const uniqueId = id === undefined ? `downshift-${generateDefaultId()}` : id 8 | 9 | return { 10 | labelId: labelId || `${uniqueId}-label`, 11 | menuId: menuId || `${uniqueId}-menu`, 12 | getItemId: getItemId || (index => `${uniqueId}-item-${index}`), 13 | toggleButtonId: toggleButtonId || `${uniqueId}-toggle-button`, 14 | } 15 | } 16 | 17 | function getNextWrappingIndex(moveAmount, baseIndex, itemsLength, circular) { 18 | if (baseIndex === -1) { 19 | return moveAmount > 0 ? 0 : itemsLength - 1 20 | } 21 | const nextIndex = baseIndex + moveAmount 22 | 23 | if (nextIndex < 0) { 24 | return circular ? itemsLength - 1 : 0 25 | } 26 | if (nextIndex >= itemsLength) { 27 | return circular ? 0 : itemsLength - 1 28 | } 29 | 30 | return nextIndex 31 | } 32 | 33 | function getItemIndexByCharacterKey( 34 | keysSoFar, 35 | highlightedIndex, 36 | items, 37 | itemToStringParam, 38 | ) { 39 | let newHighlightedIndex = -1 40 | const itemStrings = items.map(item => itemToStringParam(item).toLowerCase()) 41 | const startPosition = highlightedIndex + 1 42 | 43 | newHighlightedIndex = itemStrings 44 | .slice(startPosition) 45 | .findIndex(itemString => itemString.startsWith(keysSoFar)) 46 | 47 | if (newHighlightedIndex > -1) { 48 | return newHighlightedIndex + startPosition 49 | } else { 50 | return itemStrings 51 | .slice(0, startPosition) 52 | .findIndex(itemString => itemString.startsWith(keysSoFar)) 53 | } 54 | } 55 | 56 | function getState(state, props) { 57 | return Object.keys(state).reduce((prevState, key) => { 58 | // eslint-disable-next-line no-param-reassign 59 | prevState[key] = props[key] === undefined ? state[key] : props[key] 60 | return prevState 61 | }, {}) 62 | } 63 | 64 | function getItemIndex(index, item, items) { 65 | if (index !== undefined) { 66 | return index 67 | } 68 | if (items.length === 0) { 69 | return -1 70 | } 71 | return items.indexOf(item) 72 | } 73 | 74 | function itemToString(item) { 75 | return item ? String(item) : '' 76 | } 77 | 78 | function getPropTypesValidator(caller, propTypes) { 79 | // istanbul ignore next 80 | return function validate(options = {}) { 81 | Object.entries(propTypes).forEach(([key]) => { 82 | PropTypes.checkPropTypes(propTypes, options, key, caller.name) 83 | }) 84 | } 85 | } 86 | 87 | function isAcceptedCharacterKey(key) { 88 | return /^\S{1}$/.test(key) 89 | } 90 | 91 | export { 92 | getElementIds, 93 | getNextWrappingIndex, 94 | getItemIndexByCharacterKey, 95 | getState, 96 | getItemIndex, 97 | getPropTypesValidator, 98 | itemToString, 99 | isAcceptedCharacterKey, 100 | } 101 | -------------------------------------------------------------------------------- /docs/useSelect/html.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Usage 3 | menu: useSelect 4 | route: /hooks/use-select/usage 5 | --- 6 | 7 | import {Playground} from 'docz' 8 | import {useSelect} from '../../src' 9 | import {items, menuStyles} from './html' 10 | 11 | # useSelect 12 | 13 | ## HTML elements 14 | 15 | A custom `` is useful for the custom styling of the widget, since the ` 57 | 62 | 65 |
    70 | {isOpen && 71 | (inputValue 72 | ? items.filter(i => 73 | i.toLowerCase().includes(inputValue.toLowerCase()), 74 | ) 75 | : items 76 | ).map((item, index) => ( 77 |
  • 89 | {item} 90 |
  • 91 | ))} 92 |
93 |
94 | )} 95 | 96 |
97 | ) 98 | } 99 | } 100 | 101 | export default Combobox 102 | 103 | // downshift takes care of the label for us 104 | /* eslint jsx-a11y/label-has-for:0 */ 105 | -------------------------------------------------------------------------------- /other/misc-tests/__tests__/preact.js: -------------------------------------------------------------------------------- 1 | // Tell Babel to transform JSX into preact.h() calls: 2 | /** @jsx preact.h */ 3 | /* 4 | eslint-disable 5 | react/prop-types, 6 | no-console, 7 | react/display-name, 8 | import/extensions, 9 | import/no-unresolved 10 | */ 11 | 12 | /* 13 | Testing the preact version is a tiny bit complicated because 14 | we need the preact build (the one that imports 'preact' rather 15 | than 'react') otherwise things don't work very well. 16 | So there's a script `test.build` which will run the cjs build 17 | for preact before running this test. 18 | */ 19 | 20 | import preact from 'preact' 21 | import render from 'preact-render-to-string' 22 | import Downshift from '../../../preact' 23 | 24 | test('works with preact', () => { 25 | const childrenSpy = jest.fn(({getInputProps, getItemProps}) => ( 26 |
27 | 28 |
29 |
foo
30 |
bar
31 |
32 |
33 | )) 34 | const ui = {childrenSpy} 35 | render(ui) 36 | expect(childrenSpy).toHaveBeenCalledWith( 37 | expect.objectContaining({ 38 | isOpen: false, 39 | highlightedIndex: null, 40 | selectedItem: null, 41 | inputValue: '', 42 | }), 43 | ) 44 | }) 45 | 46 | test('can render a composite component', () => { 47 | const Div = ({innerRef, ...props}) =>
48 | const childrenSpy = jest.fn(({getRootProps}) => ( 49 |
50 | )) 51 | const ui = {childrenSpy} 52 | render(ui) 53 | expect(childrenSpy).toHaveBeenCalledWith( 54 | expect.objectContaining({ 55 | isOpen: false, 56 | highlightedIndex: null, 57 | selectedItem: null, 58 | inputValue: '', 59 | }), 60 | ) 61 | }) 62 | 63 | test('getInputProps composes onChange with onInput', () => { 64 | const onChange = jest.fn() 65 | const onInput = jest.fn() 66 | const Input = jest.fn(props => ) 67 | const {ui} = setup({ 68 | children({getInputProps}) { 69 | return ( 70 |
71 | 72 |
73 | ) 74 | }, 75 | }) 76 | render(ui) 77 | expect(Input).toHaveBeenCalledTimes(1) 78 | const [[firstArg]] = Input.mock.calls 79 | expect(firstArg).toMatchObject({ 80 | onInput: expect.any(Function), 81 | }) 82 | expect(firstArg.onChange).toBeUndefined() 83 | const fakeEvent = {defaultPrevented: false, target: {value: ''}} 84 | firstArg.onInput(fakeEvent) 85 | expect(onChange).toHaveBeenCalledTimes(1) 86 | expect(onChange).toHaveBeenCalledWith(fakeEvent) 87 | expect(onInput).toHaveBeenCalledTimes(1) 88 | expect(onInput).toHaveBeenCalledWith(fakeEvent) 89 | }) 90 | 91 | test('can use children instead of render prop', () => { 92 | const childrenSpy = jest.fn() 93 | render({childrenSpy}) 94 | expect(childrenSpy).toHaveBeenCalledTimes(1) 95 | }) 96 | 97 | function setup({children = () =>
, ...props} = {}) { 98 | let renderArg 99 | const childrenSpy = jest.fn(controllerArg => { 100 | renderArg = controllerArg 101 | return children(controllerArg) 102 | }) 103 | const ui = {childrenSpy} 104 | return {childrenSpy, ui, ...renderArg} 105 | } 106 | -------------------------------------------------------------------------------- /src/__tests__/downshift.get-menu-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import Downshift from '../' 4 | 5 | beforeEach(() => jest.spyOn(console, 'error').mockImplementation(() => {})) 6 | afterEach(() => console.error.mockRestore()) 7 | 8 | const Menu = ({innerRef, ...rest}) =>
9 | 10 | test('using a composite component and calling getMenuProps without a refKey results in an error', () => { 11 | const MyComponent = () => ( 12 | ( 14 |
15 | 16 |
17 | )} 18 | /> 19 | ) 20 | render() 21 | expect(console.error.mock.calls[1][0]).toMatchSnapshot() 22 | }) 23 | 24 | test('not applying the ref prop results in an error', () => { 25 | const MyComponent = () => ( 26 | { 28 | getMenuProps() 29 | return ( 30 |
31 |
    32 |
33 | ) 34 | }} 35 | /> 36 | ) 37 | render() 38 | expect(console.error.mock.calls[0][0]).toMatchSnapshot() 39 | }) 40 | 41 | test('renders fine when rendering a composite component and applying getMenuProps properly', () => { 42 | const MyComponent = () => ( 43 | ( 45 |
46 | 47 |
48 | )} 49 | /> 50 | ) 51 | render() 52 | expect(console.error.mock.calls).toHaveLength(0) 53 | }) 54 | 55 | test('using a composite component and calling getMenuProps without a refKey does not result in an error if suppressRefError is true', () => { 56 | const MyComponent = () => ( 57 | ( 59 |
60 | 61 |
62 | )} 63 | /> 64 | ) 65 | render() 66 | expect(console.error.mock.calls).toHaveLength(0) 67 | }) 68 | 69 | test('returning a DOM element and calling getMenuProps with a refKey does not result in an error if suppressRefError is true', () => { 70 | const MyComponent = () => ( 71 | ( 73 |
74 |
    75 |
76 | )} 77 | /> 78 | ) 79 | render() 80 | expect(console.error.mock.calls).toHaveLength(1) 81 | }) 82 | 83 | test('not applying the ref prop results in an error does not result in an error if suppressRefError is true', () => { 84 | const MyComponent = () => ( 85 | { 87 | getMenuProps({}, {suppressRefError: true}) 88 | return ( 89 |
90 |
    91 |
92 | ) 93 | }} 94 | /> 95 | ) 96 | render() 97 | expect(console.error.mock.calls).toHaveLength(0) 98 | }) 99 | 100 | test('renders fine when rendering a composite component and applying getMenuProps properly even if suppressRefError is true', () => { 101 | const MyComponent = () => ( 102 | ( 104 |
105 | 108 |
109 | )} 110 | /> 111 | ) 112 | render() 113 | expect(console.error.mock.calls).toHaveLength(0) 114 | }) 115 | -------------------------------------------------------------------------------- /src/__tests__/utils.scroll-into-view.js: -------------------------------------------------------------------------------- 1 | import {scrollIntoView} from '../utils' 2 | 3 | test('does not throw with a null node', () => { 4 | expect(() => scrollIntoView(null)).not.toThrow() 5 | }) 6 | 7 | test('does not throw if the given node is the root node', () => { 8 | const node = getNode() 9 | expect(() => scrollIntoView(node, node)).not.toThrow() 10 | }) 11 | 12 | test('does nothing if the node is within the scrollable area', () => { 13 | const scrollableScrollTop = 2 14 | const node = getNode({height: 10, top: 6}) 15 | const scrollableNode = getScrollableNode({ 16 | scrollTop: scrollableScrollTop, 17 | children: [node], 18 | }) 19 | scrollIntoView(node, scrollableNode) 20 | expect(scrollableNode.scrollTop).toBe(scrollableScrollTop) 21 | }) 22 | 23 | test('does nothing if parent.top is above view area and node within view', () => { 24 | const scrollableScrollTop = 1000 25 | const node = getNode({height: 40, top: 300}) 26 | // parent bounds is [-1000 + 1000, -500 + 1000] = [0, 500] 27 | // node bounds is [300, 340] => node within visible area 28 | const scrollableNode = getScrollableNode({ 29 | top: -1000, 30 | height: 500, 31 | scrollTop: scrollableScrollTop, 32 | children: [node], 33 | }) 34 | scrollIntoView(node, scrollableNode) 35 | expect(scrollableNode.scrollTop).toBe(scrollableScrollTop) 36 | }) 37 | 38 | test('aligns to top when the node is above the scrollable parent', () => { 39 | // TODO: make this test a tiny bit more readable/maintainable... 40 | const nodeTop = 2 41 | const scrollableScrollTop = 13 42 | const node = getNode({height: 10, top: nodeTop}) 43 | const scrollableNode = getScrollableNode({ 44 | top: 10, 45 | scrollTop: scrollableScrollTop, 46 | children: [node], 47 | }) 48 | scrollIntoView(node, scrollableNode) 49 | expect(scrollableNode.scrollTop).toBe(5) 50 | }) 51 | 52 | test('aligns to top of scrollable parent when the node is above view area', () => { 53 | const node = getNode({height: 40, top: -50}) 54 | const scrollableNode = getScrollableNode({ 55 | top: 50, 56 | scrollTop: 100, 57 | children: [node], 58 | }) 59 | scrollIntoView(node, scrollableNode) 60 | expect(scrollableNode.scrollTop).toBe(0) 61 | }) 62 | 63 | test('aligns to bottom when the node is below the scrollable parent', () => { 64 | const nodeTop = 115 65 | const node = getNode({height: 10, top: nodeTop}) 66 | const scrollableNode = getScrollableNode({ 67 | height: 100, 68 | children: [node], 69 | }) 70 | scrollIntoView(node, scrollableNode) 71 | expect(scrollableNode.scrollTop).toBe(25) 72 | }) 73 | 74 | function getScrollableNode(overrides = {}) { 75 | return getNode({ 76 | height: 100, 77 | top: 0, 78 | scrollTop: 0, 79 | scrollHeight: 150, 80 | ...overrides, 81 | }) 82 | } 83 | 84 | function getNode({ 85 | top = 0, 86 | height = 0, 87 | scrollTop = 0, 88 | scrollHeight = height, 89 | clientHeight = height, 90 | children = [], 91 | borderBottomWidth = 0, 92 | borderTopWidth = 0, 93 | } = {}) { 94 | const div = document.createElement('div') 95 | div.getBoundingClientRect = () => ({ 96 | width: 50, 97 | height, 98 | top, 99 | left: 0, 100 | right: 50, 101 | bottom: top + height, 102 | }) 103 | div.style.borderTopWidth = borderTopWidth 104 | div.style.borderBottomWidth = borderBottomWidth 105 | div.scrollTop = scrollTop 106 | 107 | Object.defineProperties(div, { 108 | clientHeight: {value: clientHeight}, 109 | offsetHeight: {value: clientHeight}, 110 | scrollHeight: {value: scrollHeight}, 111 | }) 112 | children.forEach(child => div.appendChild(child)) 113 | document.documentElement.appendChild(div) 114 | return div 115 | } 116 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. `npm run setup` to setup and validate your clone of the project 12 | 3. Create a branch for your PR 13 | 14 | > Tip: Keep your `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/downshift-js/downshift.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, do a regular `git pull`. 28 | 29 | ## Add yourself as a contributor 30 | 31 | This project follows the [all contributors][all-contributors] specification. To 32 | add yourself to the table of contributors on the `README.md`, please use the 33 | automated script as part of your PR: 34 | 35 | ```console 36 | npm run add-contributor 37 | ``` 38 | 39 | Follow the prompt and commit `.all-contributorsrc` and `README.md` in the PR. If 40 | you've already added yourself to the list and are making a new type of 41 | contribution, you can run it again and select the added contribution type. 42 | 43 | ## Committing and Pushing changes 44 | 45 | Please make sure to run the tests before you commit your changes. You can run 46 | `npm run test:update` which will update any snapshots that need updating. Make 47 | sure to include those changes (if they exist) in your commit. We also track the 48 | bundle sizes in a `.size-snapshot.json` file, this will likely update when you 49 | make changes to the codebase. 50 | 51 | ### Tests 52 | 53 | There are quite a few test scripts that run as part of a `validate` script in 54 | this project: 55 | 56 | - lint - ESLint stuff, pretty basic. Please fix any errors/warnings :) 57 | - build-and-test - This ensures that the built version of `downshift` is what we expect. These tests live in `other/misc-tests/__tests__`. 58 | - test:cover - This is primarily unit tests on the source code and accounts for most of the coverage. We enforce 100% code coverage on this library. These tests live in `src/__tests__` 59 | - test:ts - This runs `tsc` on the codebase to make sure the type script definitions are correct for the `tsx` files in the `test` directory. 60 | - test:ssr - This ensures that downshift works with server-side rendering (it can run and render in an environment without the DOM). These tests live in `other/ssr/__tests__` 61 | - test:cypress - This runs tests in an actual browser. It runs and tests the storybook examples. These tests live in `cypress/integration`. 62 | 63 | ### opt into git hooks 64 | 65 | There are git hooks set up with this project that are automatically installed 66 | when you install dependencies. They're really handy, but are turned off by 67 | default (so as to not hinder new contributors). You can opt into these by 68 | creating a file called `.opt-in` at the root of the project and putting this 69 | inside: 70 | 71 | ``` 72 | pre-commit 73 | ``` 74 | 75 | ## Help needed 76 | 77 | Please checkout the [the open issues][issues] 78 | 79 | Also, please watch the repo and respond to questions/bug reports/feature 80 | requests! Thanks! 81 | 82 | [egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 83 | [all-contributors]: https://github.com/kentcdodds/all-contributors 84 | [issues]: https://github.com/downshift-js/downshift/issues 85 | -------------------------------------------------------------------------------- /other/MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | 4 | 5 | 6 | 7 | **Table of Contents** 8 | 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Issues](#issues) 11 | - [Pull Requests](#pull-requests) 12 | - [Release](#release) 13 | - [Thanks!](#thanks) 14 | 15 | 16 | 17 | This is documentation for maintainers of this project. 18 | 19 | ## Code of Conduct 20 | 21 | Please review, understand, and be an example of it. Violations of the code of 22 | conduct are taken seriously, even (especially) for maintainers. 23 | 24 | ## Issues 25 | 26 | We want to support and build the community. We do that best by helping people 27 | learn to solve their own problems. We have an issue template and hopefully most 28 | folks follow it. If it's not clear what the issue is, invite them to create a 29 | minimal reproduction of what they're trying to accomplish or the bug they think 30 | they've found. 31 | 32 | Once it's determined that a code change is necessary, point people to 33 | [makeapullrequest.com](http://makeapullrequest.com) and invite them to make a 34 | pull request. If they're the one who needs the feature, they're the one who can 35 | build it. If they need some hand holding and you have time to lend a hand, 36 | please do so. It's an investment into another human being, and an investment 37 | into a potential maintainer. 38 | 39 | Remember that this is open source, so the code is not yours, it's ours. If 40 | someone needs a change in the codebase, you don't have to make it happen 41 | yourself. Commit as much time to the project as you want/need to. Nobody can ask 42 | any more of you than that. 43 | 44 | ## Pull Requests 45 | 46 | As a maintainer, you're fine to make your branches on the main repo or on your 47 | own fork. Either way is fine. 48 | 49 | When we receive a pull request, a travis build is kicked off automatically (see 50 | the `.travis.yml` for what runs in the travis build). We avoid merging anything 51 | that breaks the travis build. 52 | 53 | Please review PRs and focus on the code rather than the individual. You never 54 | know when this is someone's first ever PR and we want their experience to be as 55 | positive as possible, so be uplifting and constructive. 56 | 57 | When you merge the pull request, 99% of the time you should use the 58 | [Squash and merge](https://help.github.com/articles/merging-a-pull-request/) 59 | feature. This keeps our git history clean, but more importantly, this allows us 60 | to make any necessary changes to the commit message so we release what we want 61 | to release. See the next section on Releases for more about that. 62 | 63 | ## Release 64 | 65 | Our releases are automatic. They happen whenever code lands into `master`. A 66 | travis build gets kicked off and if it's successful, a tool called 67 | [`semantic-release`](https://github.com/semantic-release/semantic-release) is 68 | used to automatically publish a new release to npm as well as a changelog to 69 | GitHub. It is only able to determine the version and whether a release is 70 | necessary by the git commit messages. With this in mind, **please brush up on 71 | [the commit message convention][commit] which drives our releases.** 72 | 73 | > One important note about this: Please make sure that commit messages do NOT 74 | > contain the words "BREAKING CHANGE" in them unless we want to push a major 75 | > version. I've been burned by this more than once where someone will include 76 | > "BREAKING CHANGE: None" and it will end up releasing a new major version. Not 77 | > a huge deal honestly, but kind of annoying... 78 | 79 | ## Thanks! 80 | 81 | Thank you so much for helping to maintain this project! 82 | 83 | [commit]: https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md 84 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | 4 | 5 | 6 | 7 | **Table of Contents** 8 | 9 | - [Our Pledge](#our-pledge) 10 | - [Our Standards](#our-standards) 11 | - [Our Responsibilities](#our-responsibilities) 12 | - [Scope](#scope) 13 | - [Enforcement](#enforcement) 14 | - [Attribution](#attribution) 15 | 16 | 17 | 18 | ## Our Pledge 19 | 20 | In the interest of fostering an open and welcoming environment, we as 21 | contributors and maintainers pledge to making participation in our project and 22 | our community a harassment-free experience for everyone, regardless of age, body 23 | size, disability, ethnicity, gender identity and expression, level of 24 | experience, nationality, personal appearance, race, religion, or sexual identity 25 | and orientation. 26 | 27 | ## Our Standards 28 | 29 | Examples of behavior that contributes to creating a positive environment 30 | include: 31 | 32 | - Using welcoming and inclusive language 33 | - Being respectful of differing viewpoints and experiences 34 | - Gracefully accepting constructive criticism 35 | - Focusing on what is best for the community 36 | - Showing empathy towards other community members 37 | 38 | Examples of unacceptable behavior by participants include: 39 | 40 | - The use of sexualized language or imagery and unwelcome sexual attention or 41 | advances 42 | - Trolling, insulting/derogatory comments, and personal or political attacks 43 | - Public or private harassment 44 | - Publishing others' private information, such as a physical or electronic 45 | address, without explicit permission 46 | - Other conduct which could reasonably be considered inappropriate in a 47 | professional setting 48 | 49 | ## Our Responsibilities 50 | 51 | Project maintainers are responsible for clarifying the standards of acceptable 52 | behavior and are expected to take appropriate and fair corrective action in 53 | response to any instances of unacceptable behavior. 54 | 55 | Project maintainers have the right and responsibility to remove, edit, or reject 56 | comments, commits, code, wiki edits, issues, and other contributions that are 57 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 58 | contributor for other behaviors that they deem inappropriate, threatening, 59 | offensive, or harmful. 60 | 61 | ## Scope 62 | 63 | This Code of Conduct applies both within project spaces and in public spaces 64 | when an individual is representing the project or its community. Examples of 65 | representing a project or community include using an official project e-mail 66 | address, posting via an official social media account, or acting as an appointed 67 | representative at an online or offline event. Representation of a project may be 68 | further defined and clarified by project maintainers. 69 | 70 | ## Enforcement 71 | 72 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 73 | reported by contacting the project team at kent+coc@doddsfamily.us. All 74 | complaints will be reviewed and investigated and will result in a response that 75 | is deemed necessary and appropriate to the circumstances. The project team is 76 | obligated to maintain confidentiality with regard to the reporter of an 77 | incident. Further details of specific enforcement policies may be posted 78 | separately. 79 | 80 | Project maintainers who do not follow or enforce the Code of Conduct in good 81 | faith may face temporary or permanent repercussions as determined by other 82 | members of the project's leadership. 83 | 84 | ## Attribution 85 | 86 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 87 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 88 | 89 | [homepage]: http://contributor-covenant.org 90 | [version]: http://contributor-covenant.org/version/1/4/ 91 | -------------------------------------------------------------------------------- /src/hooks/useSelect/utils.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import {getNextWrappingIndex} from '../utils' 3 | 4 | const defaultStateValues = { 5 | highlightedIndex: -1, 6 | isOpen: false, 7 | selectedItem: null, 8 | } 9 | 10 | function getA11yStatusMessage({isOpen, items}) { 11 | if (!items) { 12 | return '' 13 | } 14 | const resultCount = items.length 15 | if (isOpen) { 16 | if (resultCount === 0) { 17 | return 'No results are available' 18 | } 19 | return `${resultCount} result${ 20 | resultCount === 1 ? ' is' : 's are' 21 | } available, use up and down arrow keys to navigate. Press Enter key to select.` 22 | } 23 | return '' 24 | } 25 | 26 | function getA11ySelectionMessage({selectedItem, itemToString}) { 27 | return `${itemToString(selectedItem)} has been selected.` 28 | } 29 | 30 | function getHighlightedIndexOnOpen(props, state, offset) { 31 | const {items, initialHighlightedIndex, defaultHighlightedIndex} = props 32 | const {selectedItem, highlightedIndex} = state 33 | 34 | // initialHighlightedIndex will give value to highlightedIndex on initial state only. 35 | if (initialHighlightedIndex !== undefined && highlightedIndex > -1) { 36 | return initialHighlightedIndex 37 | } 38 | if (defaultHighlightedIndex !== undefined) { 39 | return defaultHighlightedIndex 40 | } 41 | if (selectedItem) { 42 | if (offset === 0) { 43 | return items.indexOf(selectedItem) 44 | } 45 | return getNextWrappingIndex( 46 | offset, 47 | items.indexOf(selectedItem), 48 | items.length, 49 | false, 50 | ) 51 | } 52 | if (offset === 0) { 53 | return -1 54 | } 55 | return offset < 0 ? items.length - 1 : 0 56 | } 57 | 58 | function capitalizeString(string) { 59 | return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}` 60 | } 61 | 62 | function getDefaultValue(props, propKey) { 63 | const defaultPropKey = `default${capitalizeString(propKey)}` 64 | if (props[defaultPropKey] !== undefined) { 65 | return props[defaultPropKey] 66 | } 67 | return defaultStateValues[propKey] 68 | } 69 | 70 | function getInitialValue(props, propKey) { 71 | if (props[propKey] !== undefined) { 72 | return props[propKey] 73 | } 74 | const initialPropKey = `initial${capitalizeString(propKey)}` 75 | if (props[initialPropKey] !== undefined) { 76 | return props[initialPropKey] 77 | } 78 | return getDefaultValue(props, propKey) 79 | } 80 | 81 | function getInitialState(props) { 82 | return { 83 | highlightedIndex: getInitialValue(props, 'highlightedIndex'), 84 | isOpen: getInitialValue(props, 'isOpen'), 85 | selectedItem: getInitialValue(props, 'selectedItem'), 86 | keysSoFar: '', 87 | } 88 | } 89 | 90 | function invokeOnChangeHandler(propKey, props, state, changes) { 91 | const handler = `on${capitalizeString(propKey)}Change` 92 | if ( 93 | props[handler] && 94 | changes[propKey] !== undefined && 95 | changes[propKey] !== state[propKey] 96 | ) { 97 | props[handler](changes) 98 | } 99 | } 100 | 101 | function callOnChangeProps(props, state, changes) { 102 | ;['isOpen', 'highlightedIndex', 'selectedItem'].forEach(propKey => { 103 | invokeOnChangeHandler(propKey, props, state, changes) 104 | }) 105 | 106 | if (props.onStateChange && changes !== undefined) { 107 | props.onStateChange(changes) 108 | } 109 | } 110 | 111 | const propTypes = { 112 | items: PropTypes.array.isRequired, 113 | itemToString: PropTypes.func, 114 | getA11yStatusMessage: PropTypes.func, 115 | getA11ySelectionMessage: PropTypes.func, 116 | circularNavigation: PropTypes.bool, 117 | highlightedIndex: PropTypes.number, 118 | defaultHighlightedIndex: PropTypes.number, 119 | initialHighlightedIndex: PropTypes.number, 120 | isOpen: PropTypes.bool, 121 | defaultIsOpen: PropTypes.bool, 122 | initialIsOpen: PropTypes.bool, 123 | selectedItem: PropTypes.any, 124 | initialSelectedItem: PropTypes.any, 125 | defaultSelectedItem: PropTypes.any, 126 | id: PropTypes.string, 127 | labelId: PropTypes.string, 128 | menuId: PropTypes.string, 129 | getItemId: PropTypes.func, 130 | toggleButtonId: PropTypes.string, 131 | stateReducer: PropTypes.func, 132 | onSelectedItemChange: PropTypes.func, 133 | onHighlightedIndexChange: PropTypes.func, 134 | onStateChange: PropTypes.func, 135 | onIsOpenChange: PropTypes.func, 136 | environment: PropTypes.shape({ 137 | addEventListener: PropTypes.func, 138 | removeEventListener: PropTypes.func, 139 | document: PropTypes.shape({ 140 | getElementById: PropTypes.func, 141 | activeElement: PropTypes.any, 142 | body: PropTypes.any, 143 | }), 144 | }), 145 | } 146 | 147 | export { 148 | getHighlightedIndexOnOpen, 149 | getA11yStatusMessage, 150 | getA11ySelectionMessage, 151 | getInitialState, 152 | defaultStateValues, 153 | propTypes, 154 | getDefaultValue, 155 | callOnChangeProps, 156 | } 157 | -------------------------------------------------------------------------------- /cypress/integration/combobox.js: -------------------------------------------------------------------------------- 1 | // the combobox happens to be in the center of the page. 2 | // without specifying an x and y for the body events 3 | // we actually wind up firing events on the combobox. 4 | const bodyX = 100 5 | const bodyY = 300 6 | 7 | describe('combobox', () => { 8 | before(() => { 9 | cy.visit('/tests/combobox') 10 | }) 11 | 12 | beforeEach(() => { 13 | cy.getByTestId('clear-button').click() 14 | }) 15 | 16 | it('can select an item', () => { 17 | cy.getByTestId('combobox-input') 18 | .type('ee{downarrow}{enter}') 19 | .should('have.value', 'Green') 20 | }) 21 | 22 | it('can arrow up to select last item', () => { 23 | cy.getByTestId('combobox-input') 24 | .type('{uparrow}{enter}') // open menu, last option is focused 25 | .should('have.value', 'Purple') 26 | }) 27 | 28 | it('can arrow down to select first item', () => { 29 | cy.getByTestId('combobox-input') 30 | .type('{downarrow}{enter}') // open menu, first option is focused 31 | .should('have.value', 'Black') 32 | }) 33 | 34 | it('can down arrow to select an item', () => { 35 | cy.getByTestId('combobox-input') 36 | .type('{downarrow}{downarrow}{enter}') // open and select second item 37 | .should('have.value', 'Red') 38 | }) 39 | 40 | it('can use home arrow to select first item', () => { 41 | cy.getByTestId('combobox-input') 42 | .type('{downarrow}{downarrow}{home}{enter}') // open to first, go down to second, return to first by home. 43 | .should('have.value', 'Black') 44 | }) 45 | 46 | it('can use end arrow to select last item', () => { 47 | cy.getByTestId('combobox-input') 48 | .type('{downarrow}{end}{enter}') // open to first, go to last by end. 49 | .should('have.value', 'Purple') 50 | }) 51 | 52 | it('resets the item on blur', () => { 53 | cy.getByTestId('combobox-input') 54 | .type('{downarrow}{enter}') // open and select first item 55 | .should('have.value', 'Black') 56 | .get('body') 57 | .click(bodyX, bodyY) 58 | .getByTestId('combobox-input') 59 | .should('have.value', 'Black') 60 | }) 61 | 62 | it('can use the mouse to click an item', () => { 63 | cy.getByTestId('combobox-input') 64 | .type('red') 65 | .getByTestId('downshift-item-0') 66 | .click() 67 | .getByTestId('combobox-input') 68 | .should('have.value', 'Red') 69 | }) 70 | 71 | it('does not reset the input when mouseup outside while the input is focused', () => { 72 | cy.getByTestId('combobox-input') 73 | .type('red') 74 | .getByTestId('downshift-item-0') 75 | .click() 76 | .getByTestId('combobox-input') 77 | .should('have.value', 'Red') 78 | .type('{backspace}{backspace}') 79 | .should('have.value', 'R') 80 | .click() 81 | .get('body') 82 | .trigger('mouseup', bodyX, bodyY) 83 | .getByTestId('combobox-input') 84 | .should('have.value', 'R') 85 | .blur() 86 | .get('body') 87 | .trigger('click', bodyX, bodyY) 88 | .getByTestId('combobox-input') 89 | .should('have.value', 'Red') 90 | }) 91 | 92 | it('resets when bluring the input', () => { 93 | cy.getByTestId('combobox-input') 94 | .type('re') 95 | .blur() 96 | // https://github.com/kentcdodds/cypress-testing-library/issues/13 97 | .wait(1) 98 | .queryByTestId('downshift-item-0', {timeout: 10}) 99 | .should('not.be.visible') 100 | }) 101 | 102 | it('does not reset when tabbing from input to the toggle button', () => { 103 | cy.getByTestId('combobox-input') 104 | .type('pu') 105 | .getByTestId('toggle-button') 106 | .focus() 107 | .getByTestId('downshift-item-0') 108 | .click() 109 | .getByTestId('combobox-input') 110 | .should('have.value', 'Purple') 111 | }) 112 | 113 | it('does not reset when tabbing from the toggle button to the input', () => { 114 | cy.getByTestId('toggle-button') 115 | .click() 116 | .getByTestId('combobox-input') 117 | .focus() 118 | .getByTestId('downshift-item-0') 119 | .click() 120 | .getByTestId('combobox-input') 121 | .should('have.value', 'Black') 122 | }) 123 | 124 | it('resets when tapping outside on a touch screen', () => { 125 | cy.getByTestId('combobox-input') 126 | .type('re') 127 | .get('body') 128 | .trigger('touchstart', bodyX, bodyY) 129 | .trigger('touchend', bodyX, bodyY) 130 | .queryByTestId('downshift-item-0', {timeout: 10}) 131 | .should('not.be.visible') 132 | }) 133 | 134 | it('does not reset when swiping outside to scroll a touch screen', () => { 135 | cy.getByTestId('combobox-input') 136 | .type('re') 137 | .get('body') 138 | .trigger('touchstart', bodyX, bodyY) 139 | .trigger('touchmove', bodyX, bodyY + 20) 140 | .trigger('touchend', bodyX, bodyY + 20) 141 | .queryByTestId('downshift-item-0', {timeout: 10}) 142 | .should('be.visible') 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /src/__tests__/downshift.get-button-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render, fireEvent} from '@testing-library/react' 3 | import Downshift from '../' 4 | 5 | jest.useFakeTimers() 6 | 7 | test('space on button opens and closes the menu', () => { 8 | const {button, childrenSpy} = setup() 9 | fireEvent.keyDown(button, {key: ' '}) 10 | fireEvent.keyUp(button, {key: ' '}) 11 | expect(childrenSpy).toHaveBeenLastCalledWith( 12 | expect.objectContaining({isOpen: true}), 13 | ) 14 | fireEvent.keyDown(button, {key: ' '}) 15 | fireEvent.keyUp(button, {key: ' '}) 16 | expect(childrenSpy).toHaveBeenLastCalledWith( 17 | expect.objectContaining({isOpen: false}), 18 | ) 19 | }) 20 | 21 | test('clicking on the button opens and closes the menu', () => { 22 | const {button, childrenSpy} = setup() 23 | fireEvent.click(button) 24 | expect(childrenSpy).toHaveBeenLastCalledWith( 25 | expect.objectContaining({isOpen: true}), 26 | ) 27 | fireEvent.click(button) 28 | expect(childrenSpy).toHaveBeenLastCalledWith( 29 | expect.objectContaining({isOpen: false}), 30 | ) 31 | }) 32 | 33 | test('button ignores key events it does not handle', () => { 34 | const {button, childrenSpy} = setup() 35 | childrenSpy.mockClear() 36 | fireEvent.keyDown(button, {key: 's'}) 37 | expect(childrenSpy).not.toHaveBeenCalled() 38 | }) 39 | 40 | test('on button blur resets the state', () => { 41 | const {button, childrenSpy} = setup() 42 | fireEvent.blur(button) 43 | jest.runAllTimers() 44 | expect(childrenSpy).toHaveBeenLastCalledWith( 45 | expect.objectContaining({ 46 | isOpen: false, 47 | }), 48 | ) 49 | }) 50 | 51 | test('on button blur does not reset the state when the mouse is down', () => { 52 | const {button, childrenSpy} = setup() 53 | childrenSpy.mockClear() 54 | // mousedown somwhere 55 | fireEvent.mouseDown(document.body) 56 | fireEvent.blur(button) 57 | jest.runAllTimers() 58 | expect(childrenSpy).not.toHaveBeenCalled() 59 | }) 60 | 61 | test('on open it will highlight item if state has highlightedIndex', () => { 62 | const highlightedIndex = 4 63 | const {button, childrenSpy} = setup({props: {highlightedIndex}}) 64 | fireEvent.click(button) 65 | expect(childrenSpy).toHaveBeenLastCalledWith( 66 | expect.objectContaining({highlightedIndex}), 67 | ) 68 | }) 69 | 70 | test('getToggleButtonProps returns all given props', () => { 71 | const buttonProps = {'data-foo': 'bar'} 72 | const Button = jest.fn(props =>
140 | ) 141 | }) 142 | const utils = render({childrenSpy}) 143 | const button = utils.container.querySelector('button') 144 | return {...utils, button, childrenSpy, ...renderArg} 145 | } 146 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "downshift", 3 | "version": "0.0.0-semantically-released", 4 | "description": "A set of primitives to build simple, flexible, WAI-ARIA compliant React autocomplete components", 5 | "main": "dist/downshift.cjs.js", 6 | "react-native": "dist/downshift.native.cjs.js", 7 | "module": "dist/downshift.esm.js", 8 | "typings": "typings/index.d.ts", 9 | "scripts": { 10 | "add-contributor": "kcd-scripts contributors add", 11 | "build": "npm run build:web --silent && npm run build:native --silent", 12 | "build:web": "kcd-scripts build --bundle --p-react --no-clean --size-snapshot", 13 | "build:native": "cross-env BUILD_REACT_NATIVE=true BUILD_FILENAME_SUFFIX=.native kcd-scripts build --bundle cjs --no-clean", 14 | "lint": "kcd-scripts lint", 15 | "test": "kcd-scripts test", 16 | "test:cover": "kcd-scripts test --coverage", 17 | "test:ssr": "kcd-scripts test --config other/ssr/jest.config.js --no-watch", 18 | "test:update": "npm run test:cover -s -- --updateSnapshot", 19 | "test:ts": "tsc --noEmit -p ./tsconfig.json", 20 | "test:flow": "flow", 21 | "test:flow:coverage": "flow-coverage-report", 22 | "test:build": "jest --config other/misc-tests/jest.config.js", 23 | "// FIXME: test:build": "jest --projects other/misc-tests other/react-native", 24 | "test:cypress:dev": "npm-run-all --parallel --race docs:dev cy:open", 25 | "pretest:cypress": "npm run docs:build --silent", 26 | "test:cypress": "start-server-and-test docs:serve http://localhost:6006 cy:run", 27 | "cy:run": "cypress run", 28 | "cy:open": "cypress open", 29 | "build-and-test": "npm run build -s && npm run test:build -s", 30 | "docs:build": "docz build", 31 | "postdocs:build": "cpy \"**/*\" ../../.docz/dist --cwd=\"other/public\" --parents", 32 | "docs:dev": "docz dev", 33 | "docs:serve": "serve ./.docz/dist --listen 6006 --single", 34 | "setup": "npm install && npm run validate", 35 | "validate": "kcd-scripts validate lint,build-and-test,test:cover,test:ts,test:flow:coverage,test:ssr,test:cypress" 36 | }, 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "kcd-scripts pre-commit" 40 | } 41 | }, 42 | "files": [ 43 | "dist", 44 | "typings", 45 | "preact", 46 | "flow-typed" 47 | ], 48 | "keywords": [ 49 | "enhanced input", 50 | "react", 51 | "autocomplete", 52 | "autosuggest", 53 | "typeahead", 54 | "dropdown", 55 | "select", 56 | "combobox", 57 | "omnibox", 58 | "accessibility", 59 | "WAI-ARIA", 60 | "multiselect", 61 | "multiple selection" 62 | ], 63 | "author": "Kent C. Dodds (http://kentcdodds.com/)", 64 | "license": "MIT", 65 | "peerDependencies": { 66 | "react": ">=0.14.9" 67 | }, 68 | "dependencies": { 69 | "@babel/runtime": "^7.4.5", 70 | "@reach/auto-id": "^0.2.0", 71 | "compute-scroll-into-view": "^1.0.9", 72 | "keyboard-key": "1.0.4", 73 | "prop-types": "^15.7.2", 74 | "react-is": "^16.9.0" 75 | }, 76 | "devDependencies": { 77 | "@babel/helpers": "^7.4.4", 78 | "@testing-library/react-hooks": "^2.0.1", 79 | "@testing-library/cypress": "^4.1.1", 80 | "@testing-library/react": "^8.0.9", 81 | "@types/jest": "^24.0.15", 82 | "@types/react": "^16.9.2", 83 | "babel-plugin-macros": "^2.6.1", 84 | "babel-preset-react-native": "^4.0.1", 85 | "buble": "^0.19.6", 86 | "cpy-cli": "^2.0.0", 87 | "cross-env": "^5.1.4", 88 | "cypress": "^3.3.2", 89 | "docz": "^1.2.0", 90 | "docz-theme-default": "^1.2.0", 91 | "eslint-plugin-cypress": "^2.6.1", 92 | "eslint-plugin-react": "7.12.4", 93 | "flow-bin": "^0.102.0", 94 | "flow-coverage-report": "^0.6.0", 95 | "jest-dom": "^3.0.0", 96 | "kcd-scripts": "^1.7.0", 97 | "npm-run-all": "^4.1.2", 98 | "preact": "^8.5.2", 99 | "preact-render-to-string": "^4.1.0", 100 | "react": "^16.9.0", 101 | "react-dom": "^16.9.0", 102 | "react-native": "^0.60.5", 103 | "react-test-renderer": "^16.9.0", 104 | "rollup-plugin-commonjs": "^10.0.2", 105 | "serve": "^11.0.2", 106 | "start-server-and-test": "^1.10.0", 107 | "typescript": "^3.5.2" 108 | }, 109 | "eslintConfig": { 110 | "extends": "./node_modules/kcd-scripts/eslint.js", 111 | "rules": { 112 | "eqeqeq": "off", 113 | "import/no-useless-path-segments": "off", 114 | "import/no-unassigned-import": "off", 115 | "max-lines": "off", 116 | "max-lines-per-function": "off", 117 | "no-eq-null": "off", 118 | "react/jsx-indent": "off", 119 | "react/prop-types": "off", 120 | "jsx-a11y/label-has-for": "off", 121 | "jsx-a11y/label-has-associated-control": "off", 122 | "complexity": [ 123 | "error", 124 | 12 125 | ] 126 | } 127 | }, 128 | "eslintIgnore": [ 129 | "node_modules", 130 | "coverage", 131 | "dist", 132 | ".docz", 133 | "typings" 134 | ], 135 | "repository": { 136 | "type": "git", 137 | "url": "https://github.com/downshift-js/downshift.git" 138 | }, 139 | "bugs": { 140 | "url": "https://github.com/downshift-js/downshift/issues" 141 | }, 142 | "homepage": "https://github.com/downshift-js/downshift#readme", 143 | "flow-coverage-report": { 144 | "includeGlob": [ 145 | "test/**/*.js" 146 | ], 147 | "threshold": 90, 148 | "type": [ 149 | "text" 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/hooks/useSelect/reducer.js: -------------------------------------------------------------------------------- 1 | import {getNextWrappingIndex, getItemIndexByCharacterKey} from '../utils' 2 | import {getHighlightedIndexOnOpen, getDefaultValue} from './utils' 3 | import * as stateChangeTypes from './stateChangeTypes' 4 | 5 | /* eslint-disable complexity */ 6 | export default function downshiftSelectReducer(state, action) { 7 | const {type, props, shiftKey} = action 8 | let changes 9 | 10 | switch (type) { 11 | case stateChangeTypes.ItemMouseMove: 12 | changes = { 13 | highlightedIndex: action.index, 14 | } 15 | break 16 | case stateChangeTypes.ItemClick: 17 | changes = { 18 | isOpen: getDefaultValue(props, 'isOpen'), 19 | highlightedIndex: getDefaultValue(props, 'highlightedIndex'), 20 | selectedItem: props.items[action.index], 21 | } 22 | break 23 | case stateChangeTypes.MenuBlur: 24 | changes = { 25 | isOpen: false, 26 | highlightedIndex: -1, 27 | ...(state.highlightedIndex >= 0 && { 28 | selectedItem: props.items[state.highlightedIndex], 29 | }), 30 | } 31 | break 32 | case stateChangeTypes.MenuKeyDownArrowDown: 33 | changes = { 34 | highlightedIndex: getNextWrappingIndex( 35 | shiftKey ? 5 : 1, 36 | state.highlightedIndex, 37 | props.items.length, 38 | props.circularNavigation, 39 | ), 40 | } 41 | break 42 | case stateChangeTypes.MenuKeyDownArrowUp: 43 | changes = { 44 | highlightedIndex: getNextWrappingIndex( 45 | shiftKey ? -5 : -1, 46 | state.highlightedIndex, 47 | props.items.length, 48 | props.circularNavigation, 49 | ), 50 | } 51 | break 52 | case stateChangeTypes.MenuKeyDownHome: 53 | changes = { 54 | highlightedIndex: 0, 55 | } 56 | break 57 | case stateChangeTypes.MenuKeyDownEnd: 58 | changes = { 59 | highlightedIndex: props.items.length - 1, 60 | } 61 | break 62 | case stateChangeTypes.MenuKeyDownEscape: 63 | changes = { 64 | isOpen: false, 65 | highlightedIndex: -1, 66 | } 67 | break 68 | case stateChangeTypes.MenuKeyDownEnter: 69 | changes = { 70 | isOpen: getDefaultValue(props, 'isOpen'), 71 | highlightedIndex: getDefaultValue(props, 'highlightedIndex'), 72 | ...(state.highlightedIndex >= 0 && { 73 | selectedItem: props.items[state.highlightedIndex], 74 | }), 75 | } 76 | break 77 | case stateChangeTypes.MenuKeyDownCharacter: 78 | { 79 | const lowercasedKey = action.key 80 | const keysSoFar = `${state.keysSoFar}${lowercasedKey}` 81 | const highlightedIndex = getItemIndexByCharacterKey( 82 | keysSoFar, 83 | state.highlightedIndex, 84 | props.items, 85 | props.itemToString, 86 | ) 87 | changes = { 88 | keysSoFar, 89 | ...(highlightedIndex >= 0 && { 90 | highlightedIndex, 91 | }), 92 | } 93 | } 94 | break 95 | case stateChangeTypes.ToggleButtonKeyDownCharacter: 96 | { 97 | const lowercasedKey = action.key 98 | const keysSoFar = `${state.keysSoFar}${lowercasedKey}` 99 | const itemIndex = getItemIndexByCharacterKey( 100 | keysSoFar, 101 | state.selectedItem ? props.items.indexOf(state.selectedItem) : -1, 102 | props.items, 103 | props.itemToString, 104 | ) 105 | changes = { 106 | keysSoFar, 107 | ...(itemIndex >= 0 && { 108 | selectedItem: props.items[itemIndex], 109 | }), 110 | } 111 | } 112 | break 113 | case stateChangeTypes.ToggleButtonKeyDownArrowDown: { 114 | changes = { 115 | isOpen: true, 116 | highlightedIndex: getHighlightedIndexOnOpen(props, state, 1), 117 | } 118 | break 119 | } 120 | case stateChangeTypes.ToggleButtonKeyDownArrowUp: 121 | changes = { 122 | isOpen: true, 123 | highlightedIndex: getHighlightedIndexOnOpen(props, state, -1), 124 | } 125 | break 126 | case stateChangeTypes.ToggleButtonClick: 127 | case stateChangeTypes.FunctionToggleMenu: 128 | changes = { 129 | isOpen: !state.isOpen, 130 | highlightedIndex: state.isOpen 131 | ? -1 132 | : getHighlightedIndexOnOpen(props, state, 0), 133 | } 134 | break 135 | case stateChangeTypes.FunctionOpenMenu: 136 | changes = { 137 | isOpen: true, 138 | highlightedIndex: getHighlightedIndexOnOpen(props, state, 0), 139 | } 140 | break 141 | case stateChangeTypes.FunctionCloseMenu: 142 | changes = { 143 | isOpen: false, 144 | } 145 | break 146 | case stateChangeTypes.FunctionSetHighlightedIndex: 147 | changes = { 148 | highlightedIndex: action.highlightedIndex, 149 | } 150 | break 151 | case stateChangeTypes.FunctionSelectItem: 152 | changes = { 153 | selectedItem: action.selectedItem, 154 | } 155 | break 156 | case stateChangeTypes.FunctionClearKeysSoFar: 157 | changes = { 158 | keysSoFar: '', 159 | } 160 | break 161 | case stateChangeTypes.FunctionReset: 162 | changes = { 163 | highlightedIndex: getDefaultValue(props, 'highlightedIndex'), 164 | isOpen: getDefaultValue(props, 'isOpen'), 165 | selectedItem: getDefaultValue(props, 'selectedItem'), 166 | } 167 | break 168 | default: 169 | throw new Error('Reducer called without proper action type.') 170 | } 171 | 172 | return { 173 | ...state, 174 | ...changes, 175 | } 176 | } 177 | /* eslint-enable complexity */ 178 | -------------------------------------------------------------------------------- /src/__tests__/downshift.get-root-props.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import Downshift from '../' 4 | 5 | const MyDiv = ({innerRef, ...rest}) =>
6 | 7 | const MyDivWithForwardedRef = React.forwardRef((props, ref) => ( 8 |
9 | )) 10 | 11 | const oldError = console.error 12 | 13 | beforeEach(() => { 14 | console.error = jest.fn() 15 | }) 16 | 17 | afterEach(() => { 18 | console.error = oldError 19 | }) 20 | 21 | test('no children provided renders nothing', () => { 22 | const MyComponent = () => 23 | expect(render().container.firstChild).toBe(null) 24 | }) 25 | 26 | test('returning null renders nothing', () => { 27 | const MyComponent = () => null} /> 28 | expect(render().container.firstChild).toBe(null) 29 | }) 30 | 31 | test('returning a composite component without calling getRootProps results in an error', () => { 32 | const MyComponent = () => } /> 33 | expect(() => render()).toThrowErrorMatchingSnapshot() 34 | }) 35 | 36 | test('returning a composite component and calling getRootProps without a refKey results in an error', () => { 37 | const MyComponent = () => ( 38 | } /> 39 | ) 40 | render() 41 | expect(console.error.mock.calls[0][0]).toMatchSnapshot() 42 | }) 43 | 44 | test('returning a DOM element and calling getRootProps with a refKey results in an error', () => { 45 | const MyComponent = () => ( 46 |
} 48 | /> 49 | ) 50 | render() 51 | expect(console.error.mock.calls[0][0]).toMatchSnapshot() 52 | }) 53 | 54 | test('not applying the ref prop results in an error', () => { 55 | const MyComponent = () => ( 56 | { 58 | const {onClick} = getRootProps() 59 | return
60 | }} 61 | /> 62 | ) 63 | render() 64 | expect(console.error.mock.calls[0][0]).toMatchSnapshot() 65 | }) 66 | 67 | test('renders fine when rendering a composite component and applying getRootProps properly', () => { 68 | const MyComponent = () => ( 69 | ( 71 | 72 | )} 73 | /> 74 | ) 75 | render() 76 | expect(console.error.mock.calls).toHaveLength(0) 77 | }) 78 | 79 | test('returning a composite component and calling getRootProps without a refKey does not result in an error if suppressRefError is true', () => { 80 | const MyComponent = () => ( 81 | ( 83 | 84 | )} 85 | /> 86 | ) 87 | render() 88 | expect(console.error.mock.calls).toHaveLength(0) 89 | }) 90 | 91 | test('returning a DOM element and calling getRootProps with a refKey does not result in an error if suppressRefError is true', () => { 92 | const MyComponent = () => ( 93 | ( 95 |
96 | )} 97 | /> 98 | ) 99 | render() 100 | expect(console.error.mock.calls).toHaveLength(0) 101 | }) 102 | 103 | test('not applying the ref prop results in an error does not result in an error if suppressRefError is true', () => { 104 | const MyComponent = () => ( 105 | { 107 | const {onClick} = getRootProps({}, {suppressRefError: true}) 108 | return
109 | }} 110 | /> 111 | ) 112 | render() 113 | expect(console.error.mock.calls).toHaveLength(0) 114 | }) 115 | 116 | test('renders fine when rendering a composite component and applying getRootProps properly even if suppressRefError is true', () => { 117 | const MyComponent = () => ( 118 | ( 120 | 123 | )} 124 | /> 125 | ) 126 | render() 127 | expect(console.error.mock.calls).toHaveLength(0) 128 | }) 129 | 130 | test('renders fine when rendering a composite component and suppressRefError prop is true', () => { 131 | const MyComponent = () => ( 132 | } 135 | /> 136 | ) 137 | render() 138 | expect(console.error.mock.calls).toHaveLength(0) 139 | }) 140 | 141 | test('renders fine when rendering a composite component that uses refs forwarding', () => { 142 | const MyComponent = () => ( 143 | ( 145 | 146 | )} 147 | /> 148 | ) 149 | render() 150 | expect(console.error.mock.calls).toHaveLength(0) 151 | }) 152 | 153 | test('has access to element when a ref is passed to getRootProps', () => { 154 | const ref = {current: null} 155 | 156 | const MyComponent = () => ( 157 | ( 159 | { 162 | ref.current = e 163 | }, 164 | })} 165 | /> 166 | )} 167 | /> 168 | ) 169 | 170 | render() 171 | expect(ref.current).not.toBeNull() 172 | expect(ref.current).toBeInstanceOf(HTMLDivElement) 173 | }) 174 | -------------------------------------------------------------------------------- /docs/useSelect/uiLibraries.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: UI Libraries 3 | menu: useSelect 4 | route: /hooks/use-select/ui-libraries 5 | --- 6 | 7 | import {Playground} from 'docz' 8 | import {useSelect} from '../../src' 9 | import {items, menuStyles} from './html' 10 | 11 | # useSelect 12 | 13 | ## UI Libraries 14 | 15 | A custom ` 188 | {items.map((item, index) => ( 189 |
197 | 198 |
199 | ))} 200 |
201 | )) 202 | const utils = render( 203 | {}} 206 | children={childrenSpy} 207 | {...props} 208 | />, 209 | ) 210 | const input = utils.queryByTestId('input') 211 | return { 212 | ...utils, 213 | childrenSpy, 214 | input, 215 | arrowDownInput: extraEventProps => 216 | fireEvent.keyDown(input, {key: 'ArrowDown', ...extraEventProps}), 217 | arrowUpInput: extraEventProps => 218 | fireEvent.keyDown(input, {key: 'ArrowUp', ...extraEventProps}), 219 | enterOnInput: extraEventProps => 220 | fireEvent.keyDown(input, {key: 'Enter', ...extraEventProps}), 221 | changeInputValue: (value, extraEventProps) => { 222 | fireEvent.change(input, {target: {value}, ...extraEventProps}) 223 | }, 224 | } 225 | } 226 | 227 | function setupWithDownshiftController() { 228 | let renderArg 229 | render( 230 | 231 | {controllerArg => { 232 | renderArg = controllerArg 233 | return null 234 | }} 235 | , 236 | ) 237 | return renderArg 238 | } 239 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import computeScrollIntoView from 'compute-scroll-into-view' 2 | import {isPreact} from './is.macro' 3 | 4 | let idCounter = 0 5 | 6 | /** 7 | * Accepts a parameter and returns it if it's a function 8 | * or a noop function if it's not. This allows us to 9 | * accept a callback, but not worry about it if it's not 10 | * passed. 11 | * @param {Function} cb the callback 12 | * @return {Function} a function 13 | */ 14 | function cbToCb(cb) { 15 | return typeof cb === 'function' ? cb : noop 16 | } 17 | 18 | function noop() {} 19 | 20 | /** 21 | * Scroll node into view if necessary 22 | * @param {HTMLElement} node the element that should scroll into view 23 | * @param {HTMLElement} menuNode the menu element of the component 24 | */ 25 | function scrollIntoView(node, menuNode) { 26 | if (node === null) { 27 | return 28 | } 29 | 30 | const actions = computeScrollIntoView(node, { 31 | boundary: menuNode, 32 | block: 'nearest', 33 | scrollMode: 'if-needed', 34 | }) 35 | actions.forEach(({el, top, left}) => { 36 | el.scrollTop = top 37 | el.scrollLeft = left 38 | }) 39 | } 40 | 41 | /** 42 | * @param {HTMLElement} parent the parent node 43 | * @param {HTMLElement} child the child node 44 | * @return {Boolean} whether the parent is the child or the child is in the parent 45 | */ 46 | function isOrContainsNode(parent, child) { 47 | return parent === child || (parent.contains && parent.contains(child)) 48 | } 49 | 50 | /** 51 | * Simple debounce implementation. Will call the given 52 | * function once after the time given has passed since 53 | * it was last called. 54 | * @param {Function} fn the function to call after the time 55 | * @param {Number} time the time to wait 56 | * @return {Function} the debounced function 57 | */ 58 | function debounce(fn, time) { 59 | let timeoutId 60 | 61 | function cancel() { 62 | if (timeoutId) { 63 | clearTimeout(timeoutId) 64 | } 65 | } 66 | 67 | function wrapper(...args) { 68 | cancel() 69 | timeoutId = setTimeout(() => { 70 | timeoutId = null 71 | fn(...args) 72 | }, time) 73 | } 74 | 75 | wrapper.cancel = cancel 76 | 77 | return wrapper 78 | } 79 | 80 | /** 81 | * This is intended to be used to compose event handlers. 82 | * They are executed in order until one of them sets 83 | * `event.preventDownshiftDefault = true`. 84 | * @param {...Function} fns the event handler functions 85 | * @return {Function} the event handler to add to an element 86 | */ 87 | function callAllEventHandlers(...fns) { 88 | return (event, ...args) => 89 | fns.some(fn => { 90 | if (fn) { 91 | fn(event, ...args) 92 | } 93 | return ( 94 | event.preventDownshiftDefault || 95 | (event.hasOwnProperty('nativeEvent') && 96 | event.nativeEvent.preventDownshiftDefault) 97 | ) 98 | }) 99 | } 100 | 101 | /** 102 | * This return a function that will call all the given functions with 103 | * the arguments with which it's called. It does a null-check before 104 | * attempting to call the functions and can take any number of functions. 105 | * @param {...Function} fns the functions to call 106 | * @return {Function} the function that calls all the functions 107 | */ 108 | function callAll(...fns) { 109 | return (...args) => { 110 | fns.forEach(fn => { 111 | if (fn) { 112 | fn(...args) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | /** 119 | * This generates a unique ID for an instance of Downshift 120 | * @return {String} the unique ID 121 | */ 122 | function generateId() { 123 | return String(idCounter++) 124 | } 125 | 126 | /** 127 | * This is only used in tests 128 | * @param {Number} num the number to set the idCounter to 129 | */ 130 | function setIdCounter(num) { 131 | idCounter = num 132 | } 133 | 134 | /** 135 | * Resets idCounter to 0. Used for SSR. 136 | */ 137 | function resetIdCounter() { 138 | idCounter = 0 139 | } 140 | 141 | /** 142 | * @param {Object} param the downshift state and other relevant properties 143 | * @return {String} the a11y status message 144 | */ 145 | function getA11yStatusMessage({ 146 | isOpen, 147 | selectedItem, 148 | resultCount, 149 | previousResultCount, 150 | itemToString, 151 | }) { 152 | if (!isOpen) { 153 | return selectedItem ? itemToString(selectedItem) : '' 154 | } 155 | if (!resultCount) { 156 | return 'No results are available.' 157 | } 158 | if (resultCount !== previousResultCount) { 159 | return `${resultCount} result${ 160 | resultCount === 1 ? ' is' : 's are' 161 | } available, use up and down arrow keys to navigate. Press Enter key to select.` 162 | } 163 | return '' 164 | } 165 | 166 | /** 167 | * Takes an argument and if it's an array, returns the first item in the array 168 | * otherwise returns the argument 169 | * @param {*} arg the maybe-array 170 | * @param {*} defaultValue the value if arg is falsey not defined 171 | * @return {*} the arg or it's first item 172 | */ 173 | function unwrapArray(arg, defaultValue) { 174 | arg = Array.isArray(arg) ? /* istanbul ignore next (preact) */ arg[0] : arg 175 | if (!arg && defaultValue) { 176 | return defaultValue 177 | } else { 178 | return arg 179 | } 180 | } 181 | 182 | /** 183 | * @param {Object} element (P)react element 184 | * @return {Boolean} whether it's a DOM element 185 | */ 186 | function isDOMElement(element) { 187 | /* istanbul ignore if */ 188 | if (isPreact) { 189 | // then this is preact 190 | return typeof element.nodeName === 'string' 191 | } 192 | 193 | // then we assume this is react 194 | return typeof element.type === 'string' 195 | } 196 | 197 | /** 198 | * @param {Object} element (P)react element 199 | * @return {Object} the props 200 | */ 201 | function getElementProps(element) { 202 | // props for react, attributes for preact 203 | 204 | /* istanbul ignore if */ 205 | if (isPreact) { 206 | return element.attributes 207 | } 208 | 209 | return element.props 210 | } 211 | 212 | /** 213 | * Throws a helpful error message for required properties. Useful 214 | * to be used as a default in destructuring or object params. 215 | * @param {String} fnName the function name 216 | * @param {String} propName the prop name 217 | */ 218 | function requiredProp(fnName, propName) { 219 | // eslint-disable-next-line no-console 220 | console.error(`The property "${propName}" is required in "${fnName}"`) 221 | } 222 | 223 | const stateKeys = [ 224 | 'highlightedIndex', 225 | 'inputValue', 226 | 'isOpen', 227 | 'selectedItem', 228 | 'type', 229 | ] 230 | /** 231 | * @param {Object} state the state object 232 | * @return {Object} state that is relevant to downshift 233 | */ 234 | function pickState(state = {}) { 235 | const result = {} 236 | stateKeys.forEach(k => { 237 | if (state.hasOwnProperty(k)) { 238 | result[k] = state[k] 239 | } 240 | }) 241 | return result 242 | } 243 | 244 | /** 245 | * Normalizes the 'key' property of a KeyboardEvent in IE/Edge 246 | * @param {Object} event a keyboardEvent object 247 | * @return {String} keyboard key 248 | */ 249 | function normalizeArrowKey(event) { 250 | const {key, keyCode} = event 251 | /* istanbul ignore next (ie) */ 252 | if (keyCode >= 37 && keyCode <= 40 && key.indexOf('Arrow') !== 0) { 253 | return `Arrow${key}` 254 | } 255 | return key 256 | } 257 | 258 | /** 259 | * Simple check if the value passed is object literal 260 | * @param {*} obj any things 261 | * @return {Boolean} whether it's object literal 262 | */ 263 | function isPlainObject(obj) { 264 | return Object.prototype.toString.call(obj) === '[object Object]' 265 | } 266 | 267 | /** 268 | * Returns the new index in the list, in a circular way. If next value is out of bonds from the total, 269 | * it will wrap to either 0 or itemCount - 1. 270 | * 271 | * @param {number} moveAmount Number of positions to move. Negative to move backwards, positive forwards. 272 | * @param {number} baseIndex The initial position to move from. 273 | * @param {number} itemCount The total number of items. 274 | * @returns {number} The new index after the move. 275 | */ 276 | function getNextWrappingIndex(moveAmount, baseIndex, itemCount) { 277 | const itemsLastIndex = itemCount - 1 278 | 279 | if ( 280 | typeof baseIndex !== 'number' || 281 | baseIndex < 0 || 282 | baseIndex >= itemCount 283 | ) { 284 | baseIndex = moveAmount > 0 ? -1 : itemsLastIndex + 1 285 | } 286 | let newIndex = baseIndex + moveAmount 287 | if (newIndex < 0) { 288 | newIndex = itemsLastIndex 289 | } else if (newIndex > itemsLastIndex) { 290 | newIndex = 0 291 | } 292 | return newIndex 293 | } 294 | 295 | export { 296 | cbToCb, 297 | callAllEventHandlers, 298 | callAll, 299 | debounce, 300 | scrollIntoView, 301 | generateId, 302 | getA11yStatusMessage, 303 | unwrapArray, 304 | isDOMElement, 305 | getElementProps, 306 | isOrContainsNode, 307 | noop, 308 | requiredProp, 309 | setIdCounter, 310 | resetIdCounter, 311 | pickState, 312 | isPlainObject, 313 | normalizeArrowKey, 314 | getNextWrappingIndex, 315 | } 316 | -------------------------------------------------------------------------------- /src/hooks/useSelect/__tests__/getItemProps.test.js: -------------------------------------------------------------------------------- 1 | import keyboardKey from 'keyboard-key' 2 | import {fireEvent, cleanup} from '@testing-library/react' 3 | import {act} from '@testing-library/react-hooks' 4 | import {noop} from '../../../utils' 5 | import {setup, dataTestIds, items, setupHook, defaultIds} from '../testUtils' 6 | 7 | describe('getItemProps', () => { 8 | afterEach(cleanup) 9 | 10 | test('throws error if no index or item has been passed', () => { 11 | const {result} = setupHook() 12 | 13 | expect(result.current.getItemProps).toThrowError( 14 | 'Pass either item or item index in getItemProps!', 15 | ) 16 | }) 17 | 18 | describe('hook props', () => { 19 | test("assign 'option' to role", () => { 20 | const {result} = setupHook() 21 | const itemProps = result.current.getItemProps({index: 0}) 22 | 23 | expect(itemProps.role).toEqual('option') 24 | }) 25 | 26 | test('assign default value to id', () => { 27 | const {result} = setupHook() 28 | const itemProps = result.current.getItemProps({index: 0}) 29 | 30 | expect(itemProps.id).toEqual(`${defaultIds.getItemId(0)}`) 31 | }) 32 | 33 | test('assign custom value passed by user to id', () => { 34 | const getItemId = index => `my-custom-item-id-${index}` 35 | const {result} = setupHook({getItemId}) 36 | const itemProps = result.current.getItemProps({index: 0}) 37 | 38 | expect(itemProps.id).toEqual(getItemId(0)) 39 | }) 40 | 41 | test("assign 'true' to aria-selected if item is highlighted", () => { 42 | const {result} = setupHook({highlightedIndex: 2}) 43 | const itemProps = result.current.getItemProps({index: 2}) 44 | 45 | expect(itemProps['aria-selected']).toEqual(true) 46 | }) 47 | 48 | test('do not assign aria-selected if item is not highlighted', () => { 49 | const {result} = setupHook({highlightedIndex: 1}) 50 | const itemProps = result.current.getItemProps({index: 2}) 51 | 52 | expect(itemProps['aria-selected']).toBeUndefined() 53 | }) 54 | }) 55 | 56 | describe('user props', () => { 57 | test('are passed down', () => { 58 | const {result} = setupHook() 59 | 60 | expect( 61 | result.current.getItemProps({index: 0, foo: 'bar'}), 62 | ).toHaveProperty('foo', 'bar') 63 | }) 64 | 65 | test('event handler onClick is called along with downshift handler', () => { 66 | const userOnClick = jest.fn() 67 | const {result} = setupHook() 68 | 69 | act(() => { 70 | const {ref: menuRef} = result.current.getMenuProps() 71 | const {ref: itemRef, onClick} = result.current.getItemProps({ 72 | index: 0, 73 | onClick: userOnClick, 74 | }) 75 | 76 | menuRef({focus: noop}) 77 | itemRef({}) 78 | result.current.toggleMenu() 79 | onClick({}) 80 | }) 81 | 82 | expect(userOnClick).toHaveBeenCalledTimes(1) 83 | expect(result.current.isOpen).toBe(false) 84 | expect(result.current.selectedItem).not.toBeNull() 85 | }) 86 | 87 | test('event handler onMouseMove is called along with downshift handler', () => { 88 | const userOnMouseMove = jest.fn() 89 | const {result} = setupHook() 90 | 91 | act(() => { 92 | const {ref: menuRef} = result.current.getMenuProps() 93 | const {ref: itemRef, onMouseMove} = result.current.getItemProps({ 94 | index: 1, 95 | onMouseMove: userOnMouseMove, 96 | }) 97 | 98 | menuRef({focus: noop}) 99 | itemRef({}) 100 | result.current.toggleMenu() 101 | onMouseMove({}) 102 | }) 103 | 104 | expect(userOnMouseMove).toHaveBeenCalledTimes(1) 105 | expect(result.current.highlightedIndex).toBe(1) 106 | }) 107 | 108 | test("event handler onClick is called without downshift handler if 'preventDownshiftDefault' is passed in user event", () => { 109 | const userOnClick = jest.fn(event => { 110 | event.preventDownshiftDefault = true 111 | }) 112 | const {result} = setupHook() 113 | 114 | act(() => { 115 | const {ref: menuRef} = result.current.getMenuProps() 116 | const {ref: itemRef, onClick} = result.current.getItemProps({ 117 | index: 0, 118 | onClick: userOnClick, 119 | }) 120 | 121 | menuRef({focus: noop}) 122 | itemRef({}) 123 | result.current.toggleMenu() 124 | onClick({}) 125 | }) 126 | 127 | expect(userOnClick).toHaveBeenCalledTimes(1) 128 | expect(result.current.isOpen).toBe(true) 129 | expect(result.current.selectedItem).toBeNull() 130 | }) 131 | 132 | test("event handler onMouseMove is called without downshift handler if 'preventDownshiftDefault' is passed in user event", () => { 133 | const useronMouseMove = jest.fn(event => { 134 | event.preventDownshiftDefault = true 135 | }) 136 | const {result} = setupHook() 137 | 138 | act(() => { 139 | const {ref: menuRef} = result.current.getMenuProps() 140 | const {ref: itemRef, onMouseMove} = result.current.getItemProps({ 141 | index: 1, 142 | onMouseMove: useronMouseMove, 143 | }) 144 | 145 | menuRef({focus: noop}) 146 | itemRef({}) 147 | result.current.toggleMenu() 148 | onMouseMove({}) 149 | }) 150 | 151 | expect(useronMouseMove).toHaveBeenCalledTimes(1) 152 | expect(result.current.highlightedIndex).toBe(-1) 153 | }) 154 | }) 155 | 156 | describe('event handlers', () => { 157 | describe('on mouse over', () => { 158 | test('it highlights the item', () => { 159 | const index = 1 160 | const wrapper = setup({isOpen: true}) 161 | const item = wrapper.getByTestId(dataTestIds.item(index)) 162 | const menu = wrapper.getByTestId(dataTestIds.menu) 163 | 164 | fireEvent.mouseMove(item) 165 | 166 | expect(menu.getAttribute('aria-activedescendant')).toBe( 167 | defaultIds.getItemId(index), 168 | ) 169 | expect(item.getAttribute('aria-selected')).toBe('true') 170 | }) 171 | 172 | test('it removes highlight from the previously highlighted item', () => { 173 | const index = 1 174 | const previousIndex = 2 175 | const wrapper = setup({ 176 | isOpen: true, 177 | initialHighlightedIndex: previousIndex, 178 | }) 179 | const item = wrapper.getByTestId(dataTestIds.item(index)) 180 | const previousItem = wrapper.getByTestId( 181 | dataTestIds.item(previousIndex), 182 | ) 183 | const menu = wrapper.getByTestId(dataTestIds.menu) 184 | 185 | fireEvent.mouseMove(item) 186 | 187 | expect(menu.getAttribute('aria-activedescendant')).not.toBe( 188 | defaultIds.getItemId(previousIndex), 189 | ) 190 | expect(previousItem.getAttribute('aria-selected')).toBeNull() 191 | }) 192 | 193 | it('keeps highlight on multiple events', () => { 194 | const index = 1 195 | const wrapper = setup({isOpen: true}) 196 | const item = wrapper.getByTestId(dataTestIds.item(index)) 197 | const menu = wrapper.getByTestId(dataTestIds.menu) 198 | 199 | fireEvent.mouseMove(item) 200 | fireEvent.mouseMove(item) 201 | fireEvent.mouseMove(item) 202 | 203 | expect(menu.getAttribute('aria-activedescendant')).toBe( 204 | defaultIds.getItemId(index), 205 | ) 206 | expect(item.getAttribute('aria-selected')).toBe('true') 207 | }) 208 | }) 209 | 210 | describe('on click', () => { 211 | test('it selects the item', () => { 212 | const index = 1 213 | const wrapper = setup({initialIsOpen: true}) 214 | const item = wrapper.getByTestId(dataTestIds.item(index)) 215 | const menu = wrapper.getByTestId(dataTestIds.menu) 216 | const toggleButton = wrapper.getByTestId(dataTestIds.toggleButton) 217 | 218 | fireEvent.click(item) 219 | 220 | expect(menu.childNodes).toHaveLength(0) 221 | expect(toggleButton.textContent).toEqual(items[index]) 222 | }) 223 | 224 | test('it selects the item and resets to user defined defaults', () => { 225 | const index = 1 226 | const wrapper = setup({defaultIsOpen: true, defaultHighlightedIndex: 2}) 227 | const item = wrapper.getByTestId(dataTestIds.item(index)) 228 | const menu = wrapper.getByTestId(dataTestIds.menu) 229 | const toggleButton = wrapper.getByTestId(dataTestIds.toggleButton) 230 | 231 | fireEvent.click(item) 232 | 233 | expect(toggleButton.textContent).toEqual(items[index]) 234 | expect(menu.childNodes).toHaveLength(items.length) 235 | expect(menu.getAttribute('aria-activedescendant')).toBe( 236 | defaultIds.getItemId(2), 237 | ) 238 | }) 239 | }) 240 | }) 241 | 242 | describe('scrolling', () => { 243 | test('is performed by the menu to the item if highlighted and not 100% visible', () => { 244 | const scrollIntoView = jest.fn() 245 | const wrapper = setup({initialIsOpen: true, scrollIntoView}) 246 | const menu = wrapper.getByTestId(dataTestIds.menu) 247 | 248 | fireEvent.keyDown(menu, {keyCode: keyboardKey.End}) 249 | expect(scrollIntoView).toHaveBeenCalledTimes(1) 250 | }) 251 | }) 252 | }) 253 | -------------------------------------------------------------------------------- /flow-typed/npm/downshift_v2.x.x.js.flow: -------------------------------------------------------------------------------- 1 | /** 2 | * Flowtype definitions for index 3 | * Generated by Flowgen from a Typescript Definition 4 | * Flowgen v1.2.0 5 | * Author: [Joar Wilk](http://twitter.com/joarwilk) 6 | * Repo: http://github.com/joarwilk/flowgen 7 | */ 8 | 9 | import React from 'react' 10 | 11 | declare module downshift { 12 | declare type StateChangeTypes = { 13 | unknown: '__autocomplete_unknown__', 14 | mouseUp: '__autocomplete_mouseup__', 15 | itemMouseEnter: '__autocomplete_item_mouseenter__', 16 | keyDownArrowUp: '__autocomplete_keydown_arrow_up__', 17 | keyDownArrowDown: '__autocomplete_keydown_arrow_down__', 18 | keyDownEscape: '__autocomplete_keydown_escape__', 19 | keyDownEnter: '__autocomplete_keydown_enter__', 20 | clickItem: '__autocomplete_click_item__', 21 | blurInput: '__autocomplete_blur_input__', 22 | changeInput: '__autocomplete_change_input__', 23 | keyDownSpaceButton: '__autocomplete_keydown_space_button__', 24 | clickButton: '__autocomplete_click_button__', 25 | blurButton: '__autocomplete_blur_button__', 26 | controlledPropUpdatedSelectedItem: '__autocomplete_controlled_prop_updated_selected_item__', 27 | } 28 | declare type StateChangeValues = 29 | | '__autocomplete_unknown__' 30 | | '__autocomplete_mouseup__' 31 | | '__autocomplete_item_mouseenter__' 32 | | '__autocomplete_keydown_arrow_up__' 33 | | '__autocomplete_keydown_arrow_down__' 34 | | '__autocomplete_keydown_escape__' 35 | | '__autocomplete_keydown_enter__' 36 | | '__autocomplete_click_item__' 37 | | '__autocomplete_blur_input__' 38 | | '__autocomplete_change_input__' 39 | | '__autocomplete_keydown_space_button__' 40 | | '__autocomplete_click_button__' 41 | | '__autocomplete_blur_button__' 42 | | '__autocomplete_controlled_prop_updated_selected_item__' 43 | declare type Callback = () => void 44 | declare export interface DownshiftState { 45 | highlightedIndex: number | null; 46 | inputValue: string | null; 47 | isOpen: boolean; 48 | selectedItem: Item; 49 | } 50 | declare export interface DownshiftProps { 51 | defaultSelectedItem?: Item; 52 | defaultHighlightedIndex?: number | null; 53 | defaultInputValue?: string; 54 | defaultIsOpen?: boolean; 55 | itemToString?: (item: Item) => string; 56 | selectedItemChanged?: (prevItem: Item, item: Item) => boolean; 57 | getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string; 58 | onChange?: ( 59 | selectedItem: Item, 60 | stateAndHelpers: ControllerStateAndHelpers, 61 | ) => void; 62 | onSelect?: ( 63 | selectedItem: Item, 64 | stateAndHelpers: ControllerStateAndHelpers, 65 | ) => void; 66 | onStateChange?: ( 67 | options: StateChangeOptions, 68 | stateAndHelpers: ControllerStateAndHelpers, 69 | ) => void; 70 | onInputValueChange?: ( 71 | inputValue: string, 72 | stateAndHelpers: ControllerStateAndHelpers, 73 | ) => void; 74 | stateReducer?: ( 75 | state: DownshiftState, 76 | changes: StateChangeOptions, 77 | ) => StateChangeOptions; 78 | itemCount?: number; 79 | highlightedIndex?: number; 80 | inputValue?: string; 81 | isOpen?: boolean; 82 | selectedItem?: Item; 83 | children: ChildrenFunction; 84 | id?: string; 85 | environment?: Environment; 86 | onOuterClick?: () => void; 87 | onUserAction?: ( 88 | options: StateChangeOptions, 89 | stateAndHelpers: ControllerStateAndHelpers, 90 | ) => void; 91 | } 92 | declare export interface Environment { 93 | addEventListener: typeof window.addEventListener; 94 | removeEventListener: typeof window.removeEventListener; 95 | document: Document; 96 | } 97 | declare export interface A11yStatusMessageOptions { 98 | highlightedIndex: number | null; 99 | inputValue: string; 100 | isOpen: boolean; 101 | itemToString: (item: Item) => string; 102 | previousResultCount: number; 103 | resultCount: number; 104 | selectedItem: Item; 105 | } 106 | declare export interface StateChangeOptions { 107 | type: StateChangeValues; 108 | highlightedIndex: number; 109 | inputValue: string; 110 | isOpen: boolean; 111 | selectedItem: Item; 112 | } 113 | declare export type StateChangeFunction = ( 114 | state: DownshiftState, 115 | ) => StateChangeOptions 116 | 117 | declare export type GetRootPropsReturn = { 118 | role: 'combobox'; 119 | 'aria-expanded': boolean; 120 | 'aria-haspopup': 'listbox'; 121 | 'aria-owns': string | null; 122 | 'aria-labelledby': string; 123 | } 124 | declare export interface GetRootPropsOptions { 125 | refKey: string; 126 | } 127 | 128 | declare type GetToggleButtonCallbacks = { 129 | onMouseMove: (e: SyntheticEvent) => void; 130 | onMouseDown: (e: SyntheticEvent) => void; 131 | onBlur: (e: SyntheticEvent) => void; 132 | } | { 133 | onPress: (e: SyntheticEvent) => void; // should be react native type 134 | } | {} 135 | declare export type GetToggleButtonReturn = { 136 | type: 'button'; 137 | role: 'button'; 138 | 'aria-label': 'close menu' | 'open menu'; 139 | 'aria-haspopup': true; 140 | 'data-toggle': true; 141 | } & GetInputPropsCallbacks 142 | declare export interface getToggleButtonPropsOptions 143 | extends React.HTMLProps {} 144 | 145 | declare export interface GetLabelPropsReturn { 146 | htmlFor: string; 147 | id: string; 148 | } 149 | declare export interface GetLabelPropsOptions 150 | extends React.HTMLProps {} 151 | 152 | declare export type getMenuPropsReturn = { 153 | role: 'listbox'; 154 | 'aria-labelledby': string | null; 155 | id: string; 156 | } 157 | 158 | declare type GetInputPropsCallbacks = ({ 159 | onKeyDown: (e: SyntheticEvent) => void; 160 | onBlur: (e: SyntheticEvent) => void; 161 | } & ({ 162 | onInput: (e: SyntheticEvent) => void; 163 | } | { 164 | onChangeText: (e: SyntheticEvent) => void; 165 | } | { 166 | onChange: (e: SyntheticEvent) => void; 167 | })) | {} 168 | declare export type GetInputPropsReturn = { 169 | 'aria-autocomplete': 'list'; 170 | 'aria-activedescendant': string | null; 171 | 'aria-controls': string | null; 172 | 'aria-labelledby': string; 173 | autoComplete: 'off'; 174 | value: string; 175 | id: string; 176 | } & GetInputPropsCallbacks; 177 | declare export interface GetInputPropsOptions 178 | extends React.HTMLProps {} 179 | 180 | declare type GetItemPropsCallbacks = { 181 | onMouseMove: (e: SyntheticEvent) => void; 182 | onMouseDown: (e: SyntheticEvent) => void; 183 | } & ({ 184 | onPress: (e: SyntheticEvent) => void; 185 | } | { 186 | onClick: (e: SyntheticEvent) => void; 187 | }) 188 | declare export type GetItemPropsReturn = { 189 | id: string; 190 | role: 'option'; 191 | 'aria-selected': boolean; 192 | } & GetItemPropsCallbacks 193 | declare export type GetItemPropsOptions = { 194 | index?: number, 195 | item: Item, 196 | } 197 | 198 | declare export interface PropGetters { 199 | getRootProps: (options: GetRootPropsOptions & T) => GetRootPropsReturn & T; 200 | getButtonProps: (options?: getToggleButtonPropsOptions & T) => GetToggleButtonReturn & T; 201 | getToggleButtonProps: (options?: getToggleButtonPropsOptions & T) => GetToggleButtonReturn & T; 202 | getLabelProps: (options?: GetLabelPropsOptions & T) => GetLabelPropsReturn & T; 203 | getMenuProps: (options?: T) => getMenuPropsReturn & T; 204 | getInputProps: (options?: GetInputPropsOptions & T) => GetInputPropsReturn & T; 205 | getItemProps: (options: GetItemPropsOptions & T) => GetItemPropsReturn & T; 206 | } 207 | declare export interface Actions { 208 | reset: (otherStateToSet?: {}, cb?: Callback) => void; 209 | openMenu: (cb?: Callback) => void; 210 | closeMenu: (cb?: Callback) => void; 211 | toggleMenu: (otherStateToSet?: {}, cb?: Callback) => void; 212 | selectItem: (item: Item, otherStateToSet?: {}, cb?: Callback) => void; 213 | selectItemAtIndex: ( 214 | index: number, 215 | otherStateToSet?: {}, 216 | cb?: Callback, 217 | ) => void; 218 | selectHighlightedItem: (otherStateToSet?: {}, cb?: Callback) => void; 219 | setHighlightedIndex: ( 220 | index: number, 221 | otherStateToSet?: {}, 222 | cb?: Callback, 223 | ) => void; 224 | clearSelection: (cb?: Callback) => void; 225 | clearItems: () => void; 226 | setItemCount: (count: number) => void; 227 | unsetItemCount: () => void; 228 | setState: ( 229 | stateToSet: StateChangeOptions | StateChangeFunction, 230 | cb?: Callback, 231 | ) => void; 232 | // props 233 | itemToString: (item: Item) => string; 234 | } 235 | declare export type ControllerStateAndHelpers = DownshiftState & 236 | PropGetters & 237 | Actions 238 | declare export type ChildrenFunction = ( 239 | options: ControllerStateAndHelpers, 240 | ) => React.ReactNode 241 | declare export type DownshiftType = Class< 242 | React.Component, DownshiftState>, 243 | > & { 244 | stateChangeTypes: StateChangeTypes, 245 | } 246 | declare var DownshiftComponent: DownshiftType 247 | declare export default DownshiftComponent 248 | } 249 | -------------------------------------------------------------------------------- /src/__tests__/downshift.lifecycle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {fireEvent, render} from '@testing-library/react' 3 | import Downshift from '../' 4 | import setA11yStatus from '../set-a11y-status' 5 | import * as utils from '../utils' 6 | 7 | jest.useFakeTimers() 8 | jest.mock('../set-a11y-status') 9 | jest.mock('../utils', () => { 10 | const realUtils = require.requireActual('../utils') 11 | return { 12 | ...realUtils, 13 | scrollIntoView: jest.fn(), 14 | } 15 | }) 16 | 17 | afterEach(() => { 18 | utils.scrollIntoView.mockReset() 19 | }) 20 | 21 | test('do not set state after unmount', () => { 22 | const handleStateChange = jest.fn() 23 | const childrenSpy = jest.fn(({getInputProps}) => ( 24 |
25 | 26 | 27 |
28 | )) 29 | const MyComponent = () => ( 30 | {childrenSpy} 31 | ) 32 | const {queryByTestId, container, unmount} = render() 33 | const button = queryByTestId('button') 34 | document.body.appendChild(container) 35 | 36 | // blur toggle button 37 | fireEvent.blur(button) 38 | handleStateChange.mockClear() 39 | 40 | // unmount 41 | unmount() 42 | expect(handleStateChange).toHaveBeenCalledTimes(0) 43 | }) 44 | 45 | test('handles mouse events properly to reset state', () => { 46 | const handleStateChange = jest.fn() 47 | const childrenSpy = jest.fn(({getInputProps}) => ( 48 |
49 | 50 |
51 | )) 52 | const MyComponent = () => ( 53 | {childrenSpy} 54 | ) 55 | const {queryByTestId, container, unmount} = render() 56 | const input = queryByTestId('input') 57 | document.body.appendChild(container) 58 | 59 | // open the menu 60 | fireEvent.keyDown(input, {key: 'ArrowDown'}) 61 | handleStateChange.mockClear() 62 | 63 | // mouse down and up on within the autocomplete node 64 | mouseDownAndUp(input) 65 | expect(handleStateChange).toHaveBeenCalledTimes(0) 66 | 67 | // mouse down and up on outside the autocomplete node 68 | mouseDownAndUp(document.body) 69 | expect(handleStateChange).toHaveBeenCalledTimes(1) 70 | 71 | childrenSpy.mockClear() 72 | // does not call our state change handler when no state changes 73 | mouseDownAndUp(document.body) 74 | expect(handleStateChange).toHaveBeenCalledTimes(1) 75 | // does not rerender when no state changes 76 | expect(childrenSpy).not.toHaveBeenCalled() 77 | 78 | // cleans up 79 | unmount() 80 | mouseDownAndUp(document.body) 81 | expect(handleStateChange).toHaveBeenCalledTimes(1) 82 | }) 83 | 84 | test('handles state change for touchevent events', () => { 85 | const handleStateChange = jest.fn() 86 | const childrenSpy = jest.fn(({getToggleButtonProps}) => ( 87 |