├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── chromatic.yml │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js ├── preview-head.html └── preview.js ├── .yarnclean ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── babel.config.js ├── codecov.yml ├── docs ├── API.md ├── Data.md ├── Filtering.md ├── Methods.md ├── README.md ├── Rendering.md ├── Upgrading.md └── Usage.md ├── example ├── .eslintrc ├── index.html ├── package.json ├── public │ ├── examples.css │ └── favicon.ico ├── raw-loader.d.ts ├── src │ ├── components │ │ ├── Anchor.tsx │ │ ├── CodeSample.tsx │ │ ├── Context.tsx │ │ ├── ExampleSection.tsx │ │ ├── GitHubLogo.tsx │ │ ├── GithubStarsButton.tsx │ │ ├── Markdown.tsx │ │ ├── Page.tsx │ │ ├── PageFooter.tsx │ │ ├── PageHeader.tsx │ │ ├── PageMenu.tsx │ │ ├── ScrollSpy.tsx │ │ ├── Section.tsx │ │ └── Title.tsx │ ├── data.ts │ ├── examples │ │ ├── AsyncExample.tsx │ │ ├── BasicBehaviorsExample.tsx │ │ ├── BasicExample.tsx │ │ ├── CustomFilteringExample.tsx │ │ ├── CustomSelectionsExample.tsx │ │ ├── FilteringExample.tsx │ │ ├── FormExample.tsx │ │ ├── InputSizeExample.tsx │ │ ├── LabelKeyExample.tsx │ │ ├── MenuAlignExample.tsx │ │ ├── PaginationExample.tsx │ │ ├── PositionFixedExample.tsx │ │ ├── PublicMethodsExample.tsx │ │ ├── RenderingExample.js │ │ └── SelectionsExample.tsx │ ├── index.tsx │ ├── sections │ │ ├── AsyncSection.tsx │ │ ├── BasicSection.tsx │ │ ├── BehaviorsSection.tsx │ │ ├── CustomSelectionsSection.tsx │ │ ├── FilteringSection.tsx │ │ ├── PublicMethodsSection.tsx │ │ └── RenderingSection.tsx │ └── util │ │ └── getIdFromTitle.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── jest.config.js ├── jest.setup.js ├── package.json ├── rollup.config.js ├── scripts ├── buildCSS.js ├── buildModules.js └── deployExample.js ├── src ├── behaviors │ ├── async.tsx │ ├── item.tsx │ └── token.tsx ├── components │ ├── AsyncTypeahead │ │ ├── AsyncTypeahead.test.tsx │ │ ├── AsyncTypeahead.tsx │ │ └── index.ts │ ├── ClearButton │ │ ├── ClearButton.stories.tsx │ │ ├── ClearButton.test.tsx │ │ ├── ClearButton.tsx │ │ ├── __snapshots__ │ │ │ └── ClearButton.test.tsx.snap │ │ └── index.ts │ ├── Highlighter │ │ ├── Highlighter.stories.tsx │ │ ├── Highlighter.test.tsx │ │ ├── Highlighter.tsx │ │ └── index.ts │ ├── Hint │ │ ├── Hint.stories.tsx │ │ ├── Hint.test.tsx │ │ ├── Hint.tsx │ │ ├── __snapshots__ │ │ │ └── Hint.test.tsx.snap │ │ └── index.ts │ ├── Input │ │ ├── Input.stories.tsx │ │ ├── Input.test.tsx │ │ ├── Input.tsx │ │ ├── __snapshots__ │ │ │ └── Input.test.tsx.snap │ │ └── index.ts │ ├── Loader │ │ ├── Loader.stories.tsx │ │ ├── Loader.test.tsx │ │ ├── Loader.tsx │ │ ├── __snapshots__ │ │ │ └── Loader.test.tsx.snap │ │ └── index.ts │ ├── Menu │ │ ├── Menu.stories.tsx │ │ ├── Menu.test.tsx │ │ ├── Menu.tsx │ │ ├── __snapshots__ │ │ │ └── Menu.test.tsx.snap │ │ └── index.ts │ ├── MenuItem │ │ ├── BaseMenuItem.stories.tsx │ │ ├── MenuItem.stories.tsx │ │ ├── MenuItem.test.tsx │ │ ├── MenuItem.tsx │ │ ├── __snapshots__ │ │ │ └── MenuItem.test.tsx.snap │ │ └── index.ts │ ├── Overlay │ │ ├── Overlay.stories.tsx │ │ ├── Overlay.test.tsx │ │ ├── Overlay.tsx │ │ ├── __snapshots__ │ │ │ └── Overlay.test.tsx.snap │ │ ├── index.ts │ │ └── useOverlay.ts │ ├── RootClose │ │ ├── RootClose.tsx │ │ ├── index.ts │ │ └── useRootClose.ts │ ├── Token │ │ ├── Token.stories.tsx │ │ ├── Token.test.tsx │ │ ├── Token.tsx │ │ ├── __snapshots__ │ │ │ └── Token.test.tsx.snap │ │ └── index.ts │ ├── Typeahead │ │ ├── Typeahead.stories.tsx │ │ ├── Typeahead.test.tsx │ │ ├── Typeahead.tsx │ │ ├── __snapshots__ │ │ │ └── Typeahead.test.tsx.snap │ │ └── index.ts │ ├── TypeaheadInputMulti │ │ ├── TypeaheadInputMulti.stories.tsx │ │ ├── TypeaheadInputMulti.test.tsx │ │ ├── TypeaheadInputMulti.tsx │ │ ├── __snapshots__ │ │ │ └── TypeaheadInputMulti.test.tsx.snap │ │ └── index.ts │ ├── TypeaheadInputSingle │ │ ├── TypeaheadInputSingle.stories.tsx │ │ ├── TypeaheadInputSingle.test.tsx │ │ ├── TypeaheadInputSingle.tsx │ │ ├── __snapshots__ │ │ │ └── TypeaheadInputSingle.test.tsx.snap │ │ └── index.ts │ └── TypeaheadMenu │ │ ├── TypeaheadMenu.stories.tsx │ │ ├── TypeaheadMenu.test.tsx │ │ ├── TypeaheadMenu.tsx │ │ ├── __snapshots__ │ │ └── TypeaheadMenu.test.tsx.snap │ │ └── index.ts ├── constants.ts ├── core │ ├── Context.tsx │ ├── Typeahead.tsx │ ├── TypeaheadManager.tsx │ ├── TypeaheadState.test.tsx │ └── TypeaheadState.ts ├── index.ts ├── propTypes.ts ├── tests │ ├── data.ts │ ├── helpers.tsx │ ├── index.test.ts │ └── props.ts ├── types.ts └── utils │ ├── addCustomOption.test.ts │ ├── addCustomOption.ts │ ├── defaultFilterBy.test.ts │ ├── defaultFilterBy.ts │ ├── defaultSelectHint.test.ts │ ├── defaultSelectHint.ts │ ├── getDisplayName.test.tsx │ ├── getDisplayName.ts │ ├── getHintText.test.ts │ ├── getHintText.ts │ ├── getInputProps.test.ts │ ├── getInputProps.ts │ ├── getInputText.test.ts │ ├── getInputText.ts │ ├── getIsOnlyResult.test.ts │ ├── getIsOnlyResult.ts │ ├── getMatchBounds.test.ts │ ├── getMatchBounds.ts │ ├── getMenuItemId.test.ts │ ├── getMenuItemId.ts │ ├── getOptionLabel.test.ts │ ├── getOptionLabel.ts │ ├── getOptionProperty.test.ts │ ├── getOptionProperty.ts │ ├── getStringLabelKey.test.ts │ ├── getStringLabelKey.ts │ ├── getTruncatedOptions.test.ts │ ├── getTruncatedOptions.ts │ ├── getUpdatedActiveIndex.test.ts │ ├── getUpdatedActiveIndex.ts │ ├── hasOwnProperty.ts │ ├── index.ts │ ├── isSelectable.test.ts │ ├── isSelectable.ts │ ├── isShown.test.ts │ ├── isShown.ts │ ├── nodash.test.ts │ ├── nodash.ts │ ├── preventInputBlur.test.ts │ ├── preventInputBlur.ts │ ├── propsWithBsClassName.test.ts │ ├── propsWithBsClassName.ts │ ├── size.test.ts │ ├── size.ts │ ├── stripDiacritics.test.ts │ ├── stripDiacritics.ts │ ├── validateSelectedPropChange.test.ts │ ├── validateSelectedPropChange.ts │ ├── warn.test.ts │ └── warn.ts ├── styles ├── Typeahead.bs5.scss └── Typeahead.scss ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | css 2 | cjs 3 | dist 4 | es 5 | example/package-example.js 6 | example/public/prism.min.js 7 | lib 8 | types 9 | **/node_modules 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest": true 5 | }, 6 | "extends": [ 7 | "@ericgio/eslint-config-react", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:react-hooks/recommended", 10 | "prettier" 11 | ], 12 | "globals": {}, 13 | "parser": "@typescript-eslint/parser", 14 | "plugins": ["@typescript-eslint"], 15 | "settings": { 16 | "import/resolver": { 17 | "node": { 18 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 19 | } 20 | } 21 | }, 22 | "rules": { 23 | "@typescript-eslint/no-shadow": 2, 24 | "@typescript-eslint/no-unused-vars": [ 25 | 2, 26 | { "vars": "all", "args": "after-used", "ignoreRestSiblings": true } 27 | ], 28 | "@typescript-eslint/no-use-before-define": ["error"], 29 | "react/jsx-filename-extension": [ 30 | 1, 31 | { 32 | "extensions": [".js", ".jsx", ".tsx"] 33 | } 34 | ], 35 | "react/jsx-fragments": [2, "syntax"], 36 | "react/static-property-placement": [2, "static public field"], 37 | 38 | "@typescript-eslint/ban-ts-comment": "off", 39 | "@typescript-eslint/explicit-module-boundary-types": "off", 40 | "import/extensions": "off", 41 | "react/jsx-no-bind": "off", 42 | 43 | // Turn off the following rules since they conflict with the TS version. 44 | "no-shadow": "off", 45 | "no-use-before-define": "off" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | ### Version 27 | 28 | 29 | 30 | ### Steps to reproduce 31 | 32 | 33 | 34 | ### Expected Behavior 35 | 36 | 37 | 38 | ### Actual Behavior 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 23 | ### Version 24 | 25 | 26 | 27 | ### Steps to reproduce 28 | 29 | 34 | 35 | ### Expected Behavior 36 | 37 | 38 | 39 | ### Actual Behavior 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 21 | 22 | **Is your feature request related to a problem? Please describe.** 23 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 24 | 25 | **Describe the solution you'd like** 26 | A clear and concise description of what you want to happen. 27 | 28 | **How is this solution useful to others?** 29 | Does your feature address a common use case? Does it provide a more generalized way to solve the type of problem you're encountering? 30 | 31 | **Describe alternatives you've considered** 32 | A clear and concise description of any alternative solutions or features you've considered. 33 | 34 | **Additional context** 35 | Add any other context, sample code, or screenshots about the feature request here. 36 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | **What issue does this pull request resolve?** 13 | 14 | 15 | **What changes did you make?** 16 | 17 | 18 | **Is there anything that requires more attention while reviewing?** 19 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: Chromatic 2 | on: 3 | push: 4 | pull_request: 5 | branches: [$default_branch] 6 | jobs: 7 | chromatic-deployment: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # 👈 Required to retrieve git history w/actions v2 14 | - name: Install dependencies 15 | run: yarn 16 | - name: Publish to Chromatic 17 | uses: chromaui/action@v1 18 | with: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | branches: [$default_branch] 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [18.x, 20.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: yarn install --frozen-lockfile 19 | - run: yarn install:example --frozen-lockfile 20 | - run: yarn ci 21 | - uses: codecov/codecov-action@v1 22 | - run: yarn build 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cjs/ 2 | coverage/ 3 | css/ 4 | dist/ 5 | es/ 6 | lib/ 7 | node_modules/ 8 | types/ 9 | 10 | .DS_Store 11 | .coveralls.yml 12 | build-storybook.log 13 | example/package-example.js 14 | npm-debug.log 15 | tsconfig.tsbuildinfo 16 | yarn-error.log 17 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run check 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github 2 | .husky 3 | cjs 4 | coverage 5 | css 6 | dist 7 | docs 8 | es 9 | example/package-example.js 10 | example/public/prism.min.js 11 | lib 12 | types 13 | **/node_modules 14 | README.md 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSameLine": true, 3 | "jsxSingleQuote": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: [ 4 | '@storybook/addon-links', 5 | '@storybook/addon-essentials', 6 | '@storybook/addon-a11y', 7 | '@storybook/preset-scss', 8 | ], 9 | core: { 10 | builder: 'webpack5', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import '../styles/Typeahead.scss'; 2 | import '../styles/Typeahead.bs5.scss'; 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | @emotion/core/types 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | This project adheres to [semantic versioning](http://semver.org/). Each release is documented on the [releases](https://github.com/ericgio/react-bootstrap-typeahead/releases) page. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015–present Eric Giovanola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,no-template-curly-in-string */ 2 | 3 | // `ignore` option doesn't support wildcard for extension. 4 | // https://github.com/babel/babel/issues/12008 5 | const moduleIgnore = [ 6 | '**/*.stories.tsx', 7 | '**/*.test.tsx', 8 | '**/*.test.ts', 9 | 'src/tests/*', 10 | 'src/types.ts', 11 | ]; 12 | 13 | module.exports = { 14 | presets: [ 15 | ['@babel/preset-env', { modules: false }], 16 | '@babel/preset-typescript', 17 | '@babel/preset-react', 18 | ], 19 | plugins: [ 20 | '@babel/plugin-proposal-class-properties', 21 | '@babel/plugin-proposal-export-default-from', 22 | 'dev-expression', 23 | [ 24 | 'transform-imports', 25 | { 26 | lodash: { 27 | transform: 'lodash/${member}', 28 | preventFullImport: true, 29 | }, 30 | }, 31 | ], 32 | ], 33 | env: { 34 | cjs: { 35 | plugins: [ 36 | '@babel/transform-runtime', 37 | '@babel/transform-modules-commonjs', 38 | ], 39 | ignore: moduleIgnore, 40 | }, 41 | es: { 42 | plugins: ['@babel/transform-runtime'], 43 | ignore: moduleIgnore, 44 | }, 45 | production: { 46 | plugins: ['transform-react-remove-prop-types'], 47 | }, 48 | test: { 49 | plugins: [ 50 | '@babel/transform-runtime', 51 | '@babel/transform-modules-commonjs', 52 | ], 53 | }, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 0.1% 7 | patch: off 8 | 9 | comment: off 10 | -------------------------------------------------------------------------------- /docs/Data.md: -------------------------------------------------------------------------------- 1 | # Data 2 | `react-bootstrap-typeahead` accepts an array of either strings or objects. If you pass in objects, each one should have a string property to be used as the label for display. By default, the key is named `label`, but you can specify a different key via the `labelKey` prop. If you pass an array of strings, the `labelKey` prop will be ignored. 3 | 4 | The component will throw an error if any options are something other than a string or object with a valid `labelKey`. 5 | 6 | The following are valid data structures: 7 | 8 | ### `Array` 9 | ```jsx 10 | var options = [ 11 | 'John', 12 | 'Miles', 13 | 'Charles', 14 | 'Herbie', 15 | ]; 16 | ``` 17 | 18 | ### `Array` (w/default `labelKey`) 19 | ```jsx 20 | var options = [ 21 | {id: 1, label: 'John'}, 22 | {id: 2, label: 'Miles'}, 23 | {id: 3, label: 'Charles'}, 24 | {id: 4, label: 'Herbie'}, 25 | ]; 26 | ``` 27 | 28 | ### `Array` (w/custom `labelKey`) 29 | In this case, you would need to set `labelKey="name"` on the component. 30 | 31 | ```jsx 32 | var options = [ 33 | {id: 1, name: 'John'}, 34 | {id: 2, name: 'Miles'}, 35 | {id: 3, name: 'Charles'}, 36 | {id: 4, name: 'Herbie'}, 37 | ]; 38 | ``` 39 | 40 | ## Duplicate Data 41 | You may have unexpected results if your data contains duplicate options. For this reason, it is highly recommended that you pass in objects with unique identifiers (eg: an id) if possible. 42 | 43 | ## Data Sources 44 | The component simply handles rendering and selection of the data that is passed in. It is agnostic about the data source, which should be handled separately. The [`AsyncTypeahead`](API.md#asynctypeahead) component is provided to help in cases where data is being fetched asynchronously from an endpoint. 45 | 46 | [Next: Filtering](Filtering.md) 47 | -------------------------------------------------------------------------------- /docs/Filtering.md: -------------------------------------------------------------------------------- 1 | # Filtering 2 | By default, the component will filter results based on a case-insensitive string match between the input string and the `labelKey` property of each option (or the option itself, if an array of strings is passed). You can customize the filtering a few ways: 3 | 4 | ### `caseSensitive: boolean` (default: `false`) 5 | Setting to `true` changes the string match to be, you guessed it, case-sensitive. Defaults to `false`. 6 | ```jsx 7 | 11 | ``` 12 | 13 | ### `ignoreDiacritics: boolean` (default: `true`) 14 | By default, the component ignores accents and other diacritical marks when performing string matches. Set this prop to `false` to override that setting and perform a strict match. 15 | ```jsx 16 | 20 | ``` 21 | 22 | ### `filterBy` 23 | The `filterBy` prop can be used in one of two ways: to specify `option` properties that should be searched or to pass a custom callback. 24 | 25 | #### `Array` 26 | By default, the filtering algorithm only searches the field that corresponds to `labelKey`. However, you can pass an array of additional fields to search: 27 | ```jsx 28 | 32 | ``` 33 | The field corresponding to `labelKey` is always searched (once), whether or not you specify it. 34 | 35 | #### `(option: Object|string, props: Object) => boolean` 36 | You can also pass your own callback to take complete control over how the filtering works. Note that the `caseSensitive` and `ignoreDiacritics` props will be ignored in this case, since you are now completely overriding the algorithm. 37 | 38 | ```jsx 39 | { 42 | /* Your own filtering code goes here. */ 43 | }} 44 | /> 45 | ``` 46 | You can disable filtering completely by passing a function that returns `true`: 47 | 48 | ```jsx 49 | true} 52 | /> 53 | ``` 54 | 55 | [Next: Rendering](Rendering.md) 56 | -------------------------------------------------------------------------------- /docs/Methods.md: -------------------------------------------------------------------------------- 1 | # Public Methods 2 | To access the component's public methods, pass a ref to your typeahead then access the ref in your code: 3 | ```jsx 4 | const ref = React.createRef(); 5 | 6 | <> 7 | 11 | ref.current.clear()}> 12 | Clear Typeahead 13 | 14 | > 15 | ``` 16 | 17 | Name | Description 18 | ---- | ----------- 19 | `blur()` | Blurs the input. 20 | `clear()` | Resets the typeahead component. Clears both text and selection(s). 21 | `focus()` | Focuses the input. 22 | `getInput()` | Provides access to the component's input node. 23 | `hideMenu()` | Hides the menu. 24 | `toggleMenu()` | Shows the menu if it is currently hidden; hides the menu if it is currently shown. 25 | 26 | [Next: API](API.md) 27 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | - [Upgrade Guide](Upgrading.md) 3 | - [Basic Usage](Usage.md) 4 | - [Data](Data.md) 5 | - [Filtering](Filtering.md) 6 | - [Rendering](Rendering.md) 7 | - [Public Methods](Methods.md) 8 | - [API](API.md) 9 | -------------------------------------------------------------------------------- /docs/Usage.md: -------------------------------------------------------------------------------- 1 | # Basic Usage 2 | The typeahead behaves similarly to other form elements. It requires an array of data options to be filtered and displayed. 3 | ```jsx 4 | { 6 | // Handle selections... 7 | }} 8 | options={[ /* Array of objects or strings */ ]} 9 | /> 10 | ``` 11 | 12 | ### Single & Multi-Selection 13 | The component provides single-selection by default, but also supports multi-selection. Simply set the `multiple` prop and the component turns into a tokenizer: 14 | 15 | ```jsx 16 | { 19 | // Handle selections... 20 | }} 21 | options={[...]} 22 | /> 23 | ``` 24 | 25 | ### Controlled vs. Uncontrolled 26 | Similar to other form elements, the typeahead can be [controlled](https://facebook.github.io/react/docs/forms.html#controlled-components) or [uncontrolled](https://facebook.github.io/react/docs/forms.html#uncontrolled-components). Use the `selected` prop to control it via the parent, or `defaultSelected` to optionally set defaults and then allow the component to control itself. Note that the *selections* can be controlled, not the input value. 27 | 28 | #### Controlled (Recommended) 29 | ```jsx 30 | { 32 | this.setState({selected}); 33 | }} 34 | options={[...]} 35 | selected={this.state.selected} 36 | /> 37 | ``` 38 | 39 | #### Uncontrolled 40 | ```jsx 41 | { 44 | // Handle selections... 45 | }} 46 | options={[...]} 47 | /> 48 | ``` 49 | 50 | [Next: Data](Data.md) 51 | -------------------------------------------------------------------------------- /example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../.eslintrc"], 3 | "rules": { 4 | "@typescript-eslint/no-var-requires": "off", 5 | "import/no-extraneous-dependencies": "off", 6 | "sort-keys": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | React Bootstrap Typeahead Example 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-bootstrap-typeahead-examples", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "license": "MIT", 6 | "private": false, 7 | "dependencies": { 8 | "marked": "^4.0.16", 9 | "prismjs": "^1.28.0", 10 | "react-bootstrap": "^2.4.0", 11 | "react-waypoint": "^10.1.0" 12 | }, 13 | "scripts": { 14 | "build": "webpack --mode production", 15 | "start": "webpack --mode development -w --progress" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.18.2", 19 | "@babel/preset-env": "^7.18.2", 20 | "@babel/preset-react": "^7.17.12", 21 | "@babel/preset-typescript": "^7.17.12", 22 | "@types/marked": "^4.0.3", 23 | "@types/prismjs": "^1.26.0", 24 | "babel-loader": "^8.2.5", 25 | "babel-plugin-prismjs": "^2.1.0", 26 | "circular-dependency-plugin": "^5.2.2", 27 | "css-loader": "^6.7.1", 28 | "raw-loader": "^4.0.2", 29 | "sass-loader": "^13.0.0", 30 | "style-loader": "^3.3.1", 31 | "terser-webpack-plugin": "^5.3.3", 32 | "webpack": "^5.76.0", 33 | "webpack-cli": "^4.9.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericgio/react-bootstrap-typeahead/924627376275c78988718230c0438748ff673a6d/example/public/favicon.ico -------------------------------------------------------------------------------- /example/raw-loader.d.ts: -------------------------------------------------------------------------------- 1 | declare module '!raw-loader!*' { 2 | const contents: string; 3 | export = contents; 4 | } 5 | -------------------------------------------------------------------------------- /example/src/components/Anchor.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | 3 | interface AnchorProps { 4 | children: ReactNode; 5 | id: string; 6 | } 7 | 8 | const Anchor = ({ children, id }: AnchorProps) => ( 9 | <> 10 | 11 | 12 | # 13 | {children} 14 | 15 | > 16 | ); 17 | 18 | export default Anchor; 19 | -------------------------------------------------------------------------------- /example/src/components/CodeSample.tsx: -------------------------------------------------------------------------------- 1 | import Prism from 'prismjs'; 2 | import React, { useEffect, useRef } from 'react'; 3 | 4 | const START_STR = '/* example-start */'; 5 | const END_STR = '/* example-end */'; 6 | 7 | function getExampleCode(str: string) { 8 | return str.slice( 9 | str.indexOf(START_STR) + START_STR.length + 1, 10 | str.indexOf(END_STR) 11 | ); 12 | } 13 | 14 | interface CodeSampleProps { 15 | children: string; 16 | } 17 | 18 | const CodeSample = ({ children }: CodeSampleProps) => { 19 | const ref = useRef(null); 20 | 21 | useEffect(() => { 22 | if (ref.current) { 23 | Prism.highlightElement(ref.current); 24 | } 25 | }, []); 26 | 27 | return ( 28 | 29 | {getExampleCode(children)} 30 | 31 | ); 32 | }; 33 | 34 | export default CodeSample; 35 | -------------------------------------------------------------------------------- /example/src/components/Context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | const noop = () => {}; 4 | 5 | interface ExampleContextType { 6 | onAfter: (href: string) => void; 7 | onBefore: (href: string) => void; 8 | } 9 | 10 | const ExampleContext = createContext({ 11 | onAfter: noop, 12 | onBefore: noop, 13 | }); 14 | 15 | export const useExampleContext = () => useContext(ExampleContext); 16 | 17 | export default ExampleContext; 18 | -------------------------------------------------------------------------------- /example/src/components/ExampleSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useState } from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | 4 | import CodeSample from './CodeSample'; 5 | 6 | interface ExampleSectionProps { 7 | children: ReactNode; 8 | code: string; 9 | } 10 | 11 | const ExampleSection = ({ children, code }: ExampleSectionProps) => { 12 | const [isOpen, setIsOpen] = useState(false); 13 | 14 | return ( 15 | 16 | 17 | 18 | Example 19 | setIsOpen(!isOpen)} 22 | size="sm" 23 | variant="link"> 24 | {`${isOpen ? 'Hide' : 'Show'} Code`} 25 | 26 | 27 | {children} 28 | 29 | {isOpen ? {code} : null} 30 | 31 | ); 32 | }; 33 | 34 | export default ExampleSection; 35 | -------------------------------------------------------------------------------- /example/src/components/GitHubLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DATA = 4 | 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 ' + 5 | '0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.' + 6 | '13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.' + 7 | '07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.' + 8 | '08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.' + 9 | '09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 ' + 10 | '1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 ' + 11 | '1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z'; 12 | 13 | interface GitHubLogoProps { 14 | size?: number; 15 | style?: React.CSSProperties; 16 | } 17 | 18 | const GitHubLogo = ({ size = 24, ...props }: GitHubLogoProps) => ( 19 | 27 | 28 | 29 | ); 30 | 31 | export default GitHubLogo; 32 | -------------------------------------------------------------------------------- /example/src/components/GithubStarsButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | 3 | const AUTHOR_REPO = 'ericgio/react-bootstrap-typeahead'; 4 | 5 | const GitHubStarsButton = () => { 6 | const ref = useRef(null); 7 | 8 | // Set size to large on initial render. 9 | useEffect(() => { 10 | if (ref.current) { 11 | ref.current.dataset.size = 'large'; 12 | } 13 | }, []); 14 | 15 | return ( 16 | 24 | Star 25 | 26 | ); 27 | }; 28 | 29 | export default GitHubStarsButton; 30 | -------------------------------------------------------------------------------- /example/src/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import React from 'react'; 3 | 4 | marked.setOptions({ 5 | breaks: true, 6 | gfm: true, 7 | pedantic: false, 8 | smartLists: true, 9 | smartypants: false, 10 | }); 11 | 12 | interface MarkdownProps { 13 | children: string; 14 | } 15 | 16 | const Markdown = ({ children }: MarkdownProps) => ( 17 | 22 | ); 23 | 24 | export default Markdown; 25 | -------------------------------------------------------------------------------- /example/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, useState } from 'react'; 2 | import { Col, Container, Nav, Row } from 'react-bootstrap'; 3 | 4 | import ExampleContext from './Context'; 5 | import PageFooter from './PageFooter'; 6 | import PageHeader from './PageHeader'; 7 | import PageMenu from './PageMenu'; 8 | 9 | import getIdFromTitle from '../util/getIdFromTitle'; 10 | 11 | interface PageProps { 12 | children: JSX.Element[]; 13 | } 14 | 15 | const Page = ({ children }: PageProps) => { 16 | const [activeHref, setActiveHref] = useState(window.location.hash); 17 | 18 | const hrefs: string[] = []; 19 | const sections: string[] = []; 20 | 21 | Children.forEach(children, (child) => { 22 | const { title } = child.props; 23 | hrefs.push(`#${getIdFromTitle(title)}`); 24 | sections.push(title); 25 | }); 26 | 27 | const handleMenuItemClick = (href: string) => { 28 | window.location.hash = href; 29 | setActiveHref(href); 30 | }; 31 | 32 | const onAfter = (href: string) => setActiveHref(href); 33 | 34 | const onBefore = (href: string) => { 35 | const index = hrefs.indexOf(href) - 1; 36 | setActiveHref(hrefs[index]); 37 | }; 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | {children} 46 | 47 | 48 | {sections.map((title: string) => { 49 | const href = `#${getIdFromTitle(title)}`; 50 | return ( 51 | 52 | handleMenuItemClick(href)}> 55 | {title} 56 | 57 | 58 | ); 59 | })} 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default Page; 71 | -------------------------------------------------------------------------------- /example/src/components/PageFooter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from 'react-bootstrap'; 3 | 4 | import GithubStarsButton from './GithubStarsButton'; 5 | 6 | import pkg from '../../../package.json'; 7 | 8 | const AUTHOR_GITHUB_URL = 'https://github.com/ericgio'; 9 | const BASE_GITHUB_URL = `${AUTHOR_GITHUB_URL}/react-bootstrap-typeahead`; 10 | const BOOTSTRAP_VERSION = '4.4.1'; 11 | 12 | const authorLink = ( 13 | 14 | Eric Giovanola 15 | 16 | ); 17 | 18 | const currentYear = new Date().getFullYear(); 19 | const footerLinks = [ 20 | { href: BASE_GITHUB_URL, label: 'GitHub' }, 21 | { href: `${BASE_GITHUB_URL}/issues`, label: 'Issues' }, 22 | { href: `${BASE_GITHUB_URL}/releases`, label: 'Releases' }, 23 | ]; 24 | 25 | const licenseLink = ( 26 | 30 | MIT 31 | 32 | ); 33 | 34 | const versionLink = ( 35 | 39 | v{pkg.version} 40 | 41 | ); 42 | 43 | const bsLink = ( 44 | 48 | v{BOOTSTRAP_VERSION} 49 | 50 | ); 51 | 52 | const PageFooter = () => ( 53 | 75 | ); 76 | 77 | export default PageFooter; 78 | -------------------------------------------------------------------------------- /example/src/components/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container, Nav, Navbar } from 'react-bootstrap'; 3 | import pkg from '../../../package.json'; 4 | 5 | import GitHubLogo from './GitHubLogo'; 6 | 7 | const GITHUB_URL = 'https://github.com/ericgio/react-bootstrap-typeahead'; 8 | 9 | const PageHeader = () => ( 10 | 11 | 12 | React Bootstrap Typeahead 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | v{pkg.version} 23 | 24 | 25 | 26 | 27 | 28 | Github 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | export default PageHeader; 38 | -------------------------------------------------------------------------------- /example/src/components/PageMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Nav } from 'react-bootstrap'; 3 | 4 | interface PageMenuProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | const PageMenu = (props: PageMenuProps) => ( 9 | 10 | {props.children} 11 | 12 | Back to top 13 | 14 | 15 | ); 16 | 17 | export default PageMenu; 18 | -------------------------------------------------------------------------------- /example/src/components/ScrollSpy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Waypoint } from 'react-waypoint'; 3 | 4 | interface ScrollSpyProps { 5 | href: string; 6 | onBefore: (href: string) => void; 7 | onAfter: (href: string) => void; 8 | } 9 | 10 | const ScrollSpy = ({ href, onBefore, onAfter }: ScrollSpyProps) => ( 11 | 14 | previousPosition === Waypoint.above && onBefore(href) 15 | } 16 | onLeave={({ currentPosition }) => 17 | currentPosition === Waypoint.above && onAfter(href) 18 | } 19 | topOffset={10} 20 | /> 21 | ); 22 | 23 | export default ScrollSpy; 24 | -------------------------------------------------------------------------------- /example/src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Anchor from './Anchor'; 4 | import { useExampleContext } from './Context'; 5 | import ScrollSpy from './ScrollSpy'; 6 | 7 | import getIdFromTitle from '../util/getIdFromTitle'; 8 | 9 | interface SectionProps { 10 | children: React.ReactNode; 11 | title: string; 12 | } 13 | 14 | const Section = ({ children, title }: SectionProps) => { 15 | const { onAfter, onBefore } = useExampleContext(); 16 | const id = getIdFromTitle(title); 17 | 18 | return ( 19 | 20 | 21 | 22 | {title} 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export default Section; 30 | -------------------------------------------------------------------------------- /example/src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Anchor from './Anchor'; 4 | 5 | import getIdFromTitle from '../util/getIdFromTitle'; 6 | 7 | interface TitleProps { 8 | children: string; 9 | } 10 | 11 | const Title = ({ children }: TitleProps) => ( 12 | 13 | {children} 14 | 15 | ); 16 | 17 | export default Title; 18 | -------------------------------------------------------------------------------- /example/src/examples/AsyncExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved,camelcase */ 2 | 3 | import React, { useState } from 'react'; 4 | import { AsyncTypeahead } from 'react-bootstrap-typeahead'; 5 | 6 | interface Item { 7 | avatar_url: string; 8 | id: string; 9 | login: string; 10 | } 11 | 12 | interface Response { 13 | items: Item[]; 14 | } 15 | 16 | /* example-start */ 17 | const SEARCH_URI = 'https://api.github.com/search/users'; 18 | 19 | const AsyncExample = () => { 20 | const [isLoading, setIsLoading] = useState(false); 21 | const [options, setOptions] = useState([]); 22 | 23 | const handleSearch = (query: string) => { 24 | setIsLoading(true); 25 | 26 | fetch(`${SEARCH_URI}?q=${query}+in:login&page=1&per_page=50`) 27 | .then((resp) => resp.json()) 28 | .then(({ items }: Response) => { 29 | setOptions(items); 30 | setIsLoading(false); 31 | }); 32 | }; 33 | 34 | // Bypass client-side filtering by returning `true`. Results are already 35 | // filtered by the search endpoint, so no need to do it again. 36 | const filterBy = () => true; 37 | 38 | return ( 39 | ( 49 | <> 50 | 59 | {option.login} 60 | > 61 | )} 62 | /> 63 | ); 64 | }; 65 | /* example-end */ 66 | 67 | export default AsyncExample; 68 | -------------------------------------------------------------------------------- /example/src/examples/BasicBehaviorsExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { ChangeEvent, useReducer } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options from '../data'; 8 | 9 | interface State { 10 | disabled: boolean; 11 | dropup: boolean; 12 | flip: boolean; 13 | highlightOnlyResult: boolean; 14 | minLength: number; 15 | open?: boolean; 16 | } 17 | 18 | interface Action { 19 | checked: boolean; 20 | name: keyof State; 21 | } 22 | 23 | /* example-start */ 24 | const initialState = { 25 | disabled: false, 26 | dropup: false, 27 | flip: false, 28 | highlightOnlyResult: false, 29 | minLength: 0, 30 | open: undefined, 31 | }; 32 | 33 | function reducer(state: State, { checked, name }: Action) { 34 | switch (name) { 35 | case 'minLength': 36 | return { 37 | ...state, 38 | [name]: checked ? 2 : 0, 39 | }; 40 | case 'open': 41 | return { 42 | ...state, 43 | [name]: checked ?? undefined, 44 | }; 45 | default: 46 | return { 47 | ...state, 48 | [name]: checked, 49 | }; 50 | break; 51 | } 52 | } 53 | 54 | function getCheckboxes({ 55 | disabled, 56 | dropup, 57 | flip, 58 | highlightOnlyResult, 59 | minLength, 60 | open, 61 | }: State) { 62 | return [ 63 | { checked: disabled, label: 'Disable the input', name: 'disabled' }, 64 | { checked: dropup, label: 'Dropup menu', name: 'dropup' }, 65 | { 66 | checked: flip, 67 | label: 'Flip the menu position when it reaches the viewport bounds', 68 | name: 'flip', 69 | }, 70 | { 71 | checked: !!minLength, 72 | label: 'Require minimum input before showing results (2 chars)', 73 | name: 'minLength', 74 | }, 75 | { 76 | checked: highlightOnlyResult, 77 | label: 'Highlight the only result', 78 | name: 'highlightOnlyResult', 79 | }, 80 | { checked: !!open, label: 'Force the menu to stay open', name: 'open' }, 81 | ]; 82 | } 83 | 84 | function BasicBehaviorsExample() { 85 | const [state, dispatch] = useReducer(reducer, initialState); 86 | 87 | function onChange(e: ChangeEvent) { 88 | const { checked, name } = e.target; 89 | 90 | dispatch({ 91 | checked, 92 | name: name as keyof State, 93 | }); 94 | } 95 | 96 | return ( 97 | <> 98 | 105 | 106 | {getCheckboxes(state).map((props) => ( 107 | 114 | ))} 115 | 116 | > 117 | ); 118 | } 119 | /* example-end */ 120 | 121 | export default BasicBehaviorsExample; 122 | -------------------------------------------------------------------------------- /example/src/examples/BasicExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options from '../data'; 8 | 9 | /* example-start */ 10 | const BasicExample = () => { 11 | const [singleSelections, setSingleSelections] = useState([]); 12 | const [multiSelections, setMultiSelections] = useState([]); 13 | 14 | return ( 15 | <> 16 | 17 | Single Selection 18 | 26 | 27 | 28 | Multiple Selections 29 | 38 | 39 | > 40 | ); 41 | }; 42 | /* example-end */ 43 | 44 | export default BasicExample; 45 | -------------------------------------------------------------------------------- /example/src/examples/CustomFilteringExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options, { Option } from '../data'; 8 | 9 | /* example-start */ 10 | const CustomFilteringExample = () => { 11 | const [filterBy, setFilterBy] = useState('callback'); 12 | 13 | const radios = [ 14 | { label: 'Use callback', value: 'callback' }, 15 | { label: 'Use data fields', value: 'fields' }, 16 | ]; 17 | 18 | const filterByCallback = (option: Option, props) => 19 | option.capital.toLowerCase().indexOf(props.text.toLowerCase()) !== -1 || 20 | option.name.toLowerCase().indexOf(props.text.toLowerCase()) !== -1; 21 | 22 | const filterByFields = ['capital', 'name']; 23 | 24 | return ( 25 | <> 26 | ( 33 | 34 | {option.name} 35 | 36 | Capital: {option.capital} 37 | 38 | 39 | )} 40 | /> 41 | 42 | {radios.map(({ label, value }) => ( 43 | setFilterBy(value)} 49 | type="radio" 50 | value={value} 51 | /> 52 | ))} 53 | 54 | > 55 | ); 56 | }; 57 | /* example-end */ 58 | 59 | export default CustomFilteringExample; 60 | -------------------------------------------------------------------------------- /example/src/examples/CustomSelectionsExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | import { Typeahead } from 'react-bootstrap-typeahead'; 5 | 6 | /* example-start */ 7 | const CustomSelectionsExample = () => ( 8 | 16 | ); 17 | /* example-end */ 18 | 19 | export default CustomSelectionsExample; 20 | -------------------------------------------------------------------------------- /example/src/examples/FilteringExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | /* example-start */ 8 | const options = [ 9 | 'Warsaw', 10 | 'Kraków', 11 | 'Łódź', 12 | 'Wrocław', 13 | 'Poznań', 14 | 'Gdańsk', 15 | 'Szczecin', 16 | 'Bydgoszcz', 17 | 'Lublin', 18 | 'Katowice', 19 | 'Białystok', 20 | 'Gdynia', 21 | 'Częstochowa', 22 | 'Radom', 23 | 'Sosnowiec', 24 | 'Toruń', 25 | 'Kielce', 26 | 'Gliwice', 27 | 'Zabrze', 28 | 'Bytom', 29 | 'Olsztyn', 30 | 'Bielsko-Biała', 31 | 'Rzeszów', 32 | 'Ruda Śląska', 33 | 'Rybnik', 34 | ]; 35 | 36 | const FilteringExample = () => { 37 | const [caseSensitive, setCaseSensitive] = useState(false); 38 | const [ignoreDiacritics, setIgnoreDiacritics] = useState(true); 39 | 40 | return ( 41 | <> 42 | 49 | 50 | setCaseSensitive(e.target.checked)} 55 | type="checkbox" 56 | /> 57 | setIgnoreDiacritics(!e.target.checked)} 62 | type="checkbox" 63 | /> 64 | 65 | > 66 | ); 67 | }; 68 | /* example-end */ 69 | 70 | export default FilteringExample; 71 | -------------------------------------------------------------------------------- /example/src/examples/FormExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import { sortBy } from 'lodash'; 4 | import React, { useState } from 'react'; 5 | import { Button, Form, InputGroup } from 'react-bootstrap'; 6 | import { Typeahead } from 'react-bootstrap-typeahead'; 7 | 8 | import options, { Option } from '../data'; 9 | 10 | /* example-start */ 11 | const getIndex = () => Math.floor(Math.random() * options.length); 12 | 13 | const FormExample = () => { 14 | const [index, setIndex] = useState(getIndex()); 15 | const [selected, setSelected] = useState([]); 16 | 17 | const state = options[index]; 18 | 19 | let isInvalid; 20 | let isValid; 21 | 22 | if (selected.length) { 23 | const isMatch = selected[0].name === state.name; 24 | 25 | isInvalid = !isMatch; 26 | isValid = isMatch; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 | 33 | The capital of {state.name} is 34 | 44 | { 46 | setIndex(getIndex()); 47 | setSelected([]); 48 | }} 49 | variant="outline-secondary"> 50 | Play Again 51 | 52 | 53 | 54 | > 55 | ); 56 | }; 57 | /* example-end */ 58 | 59 | export default FormExample; 60 | -------------------------------------------------------------------------------- /example/src/examples/InputSizeExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options from '../data'; 8 | 9 | /* example-start */ 10 | type Size = 'sm' | 'lg' | undefined; 11 | 12 | interface Radio { 13 | label: string; 14 | value: Size; 15 | } 16 | 17 | const radios: Radio[] = [ 18 | { label: 'Small', value: 'sm' }, 19 | { label: 'Default', value: undefined }, 20 | { label: 'Large', value: 'lg' }, 21 | ]; 22 | 23 | const InputSizeExample = () => { 24 | const [size, setSize] = useState(); 25 | 26 | return ( 27 | <> 28 | 35 | 36 | {radios.map(({ label, value }) => ( 37 | setSize(value)} 43 | type="radio" 44 | value={value} 45 | /> 46 | ))} 47 | 48 | > 49 | ); 50 | }; 51 | /* example-end */ 52 | 53 | export default InputSizeExample; 54 | -------------------------------------------------------------------------------- /example/src/examples/LabelKeyExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved,import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | import { Typeahead } from 'react-bootstrap-typeahead'; 5 | 6 | /* example-start */ 7 | interface Option { 8 | firstName: string; 9 | lastName: string; 10 | } 11 | 12 | const options: Option[] = [ 13 | { firstName: 'Art', lastName: 'Blakey' }, 14 | { firstName: 'John', lastName: 'Coltrane' }, 15 | { firstName: 'Miles', lastName: 'Davis' }, 16 | { firstName: 'Herbie', lastName: 'Hancock' }, 17 | { firstName: 'Charlie', lastName: 'Parker' }, 18 | { firstName: 'Tony', lastName: 'Williams' }, 19 | ]; 20 | 21 | const LabelKeyExample = () => ( 22 | `${option.firstName} ${option.lastName}`} 25 | options={options} 26 | placeholder="Who's the coolest cat?" 27 | /> 28 | ); 29 | /* example-end */ 30 | 31 | export default LabelKeyExample; 32 | -------------------------------------------------------------------------------- /example/src/examples/MenuAlignExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options from '../data'; 8 | 9 | /* example-start */ 10 | const MenuAlignExample = () => { 11 | const [align, setAlign] = useState('justify'); 12 | 13 | const radios = [ 14 | { label: 'Justify (default)', value: 'justify' }, 15 | { label: 'Align left', value: 'left' }, 16 | { label: 'Align right', value: 'right' }, 17 | ]; 18 | 19 | return ( 20 | <> 21 | 28 | 29 | {radios.map(({ label, value }) => ( 30 | setAlign(value)} 36 | type="radio" 37 | value={value} 38 | /> 39 | ))} 40 | 41 | > 42 | ); 43 | }; 44 | /* example-end */ 45 | 46 | export default MenuAlignExample; 47 | -------------------------------------------------------------------------------- /example/src/examples/PaginationExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved,no-console */ 2 | 3 | import { range } from 'lodash'; 4 | import React, { useState } from 'react'; 5 | import { Form } from 'react-bootstrap'; 6 | import { Typeahead } from 'react-bootstrap-typeahead'; 7 | 8 | /* example-start */ 9 | const options = range(0, 1000).map((o) => `Item ${o}`); 10 | 11 | const PaginationExample = () => { 12 | const [paginate, setPaginate] = useState(true); 13 | 14 | return ( 15 | <> 16 | console.log('Results paginated')} 19 | options={options} 20 | paginate={paginate} 21 | placeholder="Pick a number..." 22 | /> 23 | 24 | setPaginate(!!e.target.checked)} 29 | type="checkbox" 30 | /> 31 | 32 | > 33 | ); 34 | }; 35 | /* example-end */ 36 | 37 | export default PaginationExample; 38 | -------------------------------------------------------------------------------- /example/src/examples/PositionFixedExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Form } from 'react-bootstrap'; 5 | import { Typeahead } from 'react-bootstrap-typeahead'; 6 | 7 | import options from '../data'; 8 | 9 | /* example-start */ 10 | const PositionFixedExample = () => { 11 | const [positionFixed, setPositionFixed] = useState(true); 12 | 13 | return ( 14 | <> 15 | 22 | 23 | 30 | 31 | 32 | 33 | setPositionFixed(e.target.checked)} 38 | type="checkbox" 39 | /> 40 | 41 | > 42 | ); 43 | }; 44 | /* example-end */ 45 | 46 | export default PositionFixedExample; 47 | -------------------------------------------------------------------------------- /example/src/examples/PublicMethodsExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React, { useRef } from 'react'; 4 | import { 5 | Button as RBButton, 6 | ButtonProps, 7 | ButtonToolbar, 8 | } from 'react-bootstrap'; 9 | import { Typeahead } from 'react-bootstrap-typeahead'; 10 | 11 | import options from '../data'; 12 | 13 | const Button = (props: ButtonProps) => ( 14 | 15 | ); 16 | 17 | /* example-start */ 18 | const PublicMethodsExample = () => { 19 | const ref = useRef(null); 20 | 21 | return ( 22 | <> 23 | 32 | 33 | ref.current?.clear()}>Clear 34 | ref.current?.focus()}>Focus 35 | { 37 | ref.current?.focus(); 38 | setTimeout(() => ref.current?.blur(), 1000); 39 | }}> 40 | Focus, then blur after 1 second 41 | 42 | ref.current?.toggleMenu()}>Toggle Menu 43 | 44 | > 45 | ); 46 | }; 47 | /* example-end */ 48 | 49 | export default PublicMethodsExample; 50 | -------------------------------------------------------------------------------- /example/src/examples/SelectionsExample.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved,no-console */ 2 | 3 | import React, { ChangeEvent } from 'react'; 4 | import { Typeahead } from 'react-bootstrap-typeahead'; 5 | 6 | import options from '../data'; 7 | 8 | /* example-start */ 9 | const SelectionsExample = () => ( 10 | ) => { 16 | console.log(text, e); 17 | }} 18 | options={options} 19 | placeholder="Choose a state..." 20 | /> 21 | ); 22 | /* example-end */ 23 | 24 | export default SelectionsExample; 25 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import Page from './components/Page'; 5 | 6 | import AsyncSection from './sections/AsyncSection'; 7 | import BasicSection from './sections/BasicSection'; 8 | import BehaviorsSection from './sections/BehaviorsSection'; 9 | import CustomSelectionsSection from './sections/CustomSelectionsSection'; 10 | import FilteringSection from './sections/FilteringSection'; 11 | import PublicMethodsSection from './sections/PublicMethodsSection'; 12 | import RenderingSection from './sections/RenderingSection'; 13 | 14 | import '../../styles/Typeahead.scss'; 15 | import '../../styles/Typeahead.bs5.scss'; 16 | 17 | render( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | , 29 | document.getElementById('root') 30 | ); 31 | -------------------------------------------------------------------------------- /example/src/sections/AsyncSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import AsyncExample from '../examples/AsyncExample'; 6 | import AsyncExampleCode from '!raw-loader!../examples/AsyncExample'; 7 | 8 | import ExampleSection from '../components/ExampleSection'; 9 | import Markdown from '../components/Markdown'; 10 | import Section from '../components/Section'; 11 | 12 | interface AsyncSectionProps { 13 | title: string; 14 | } 15 | 16 | const AsyncSection = (props: AsyncSectionProps) => ( 17 | 18 | 19 | You can use the `AsyncTypeahead` component for asynchronous searches. It 20 | debounces user input and includes an optional query cache to avoid making 21 | the same request more than once in basic cases. 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | export default AsyncSection; 30 | -------------------------------------------------------------------------------- /example/src/sections/BasicSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import BasicExample from '../examples/BasicExample'; 6 | import BasicExampleCode from '!raw-loader!../examples/BasicExample'; 7 | 8 | import ExampleSection from '../components/ExampleSection'; 9 | import Markdown from '../components/Markdown'; 10 | import Section from '../components/Section'; 11 | 12 | interface BasicSectionProps { 13 | title: string; 14 | } 15 | 16 | const BasicSection = (props: BasicSectionProps) => ( 17 | 18 | 19 | The typeahead allows single-selection by default. Setting the `multiple` 20 | prop turns the component into a tokenizer, allowing multiple selections. 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | export default BasicSection; 29 | -------------------------------------------------------------------------------- /example/src/sections/CustomSelectionsSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import CustomSelectionsExample from '../examples/CustomSelectionsExample'; 6 | import CustomSelectionsExampleCode from '!raw-loader!../examples/CustomSelectionsExample'; 7 | 8 | import ExampleSection from '../components/ExampleSection'; 9 | import Markdown from '../components/Markdown'; 10 | import Section from '../components/Section'; 11 | 12 | interface CustomSelectionsProps { 13 | title: string; 14 | } 15 | 16 | const CustomSelections = (props: CustomSelectionsProps) => ( 17 | 18 | 19 | Setting the `allowNew` prop provides the ability to create new options for 20 | the data set. You can change the label displayed before the custom option 21 | in the menu by using the `newSelectionPrefix` prop. 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | export default CustomSelections; 30 | -------------------------------------------------------------------------------- /example/src/sections/FilteringSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import CustomFilteringExample from '../examples/CustomFilteringExample'; 6 | import FilteringExample from '../examples/FilteringExample'; 7 | import FilteringExampleCode from '!raw-loader!../examples/FilteringExample'; 8 | import CustomFilteringExampleCode from '!raw-loader!../examples/CustomFilteringExample'; 9 | 10 | import ExampleSection from '../components/ExampleSection'; 11 | import Markdown from '../components/Markdown'; 12 | import Section from '../components/Section'; 13 | import Title from '../components/Title'; 14 | 15 | interface FilteringSectionProps { 16 | title: string; 17 | } 18 | 19 | const FilteringSection = (props: FilteringSectionProps) => ( 20 | 21 | 22 | By default, the typeahead is not case-sensitive and ignores diacritical 23 | marks when filtering. You can change these behaviors using the 24 | `caseSensitive` and `ignoreDiacritics` props. 25 | 26 | 27 | 28 | 29 | Custom Filtering 30 | 31 | Using the `filterBy` prop, you can either specify your own callback or an 32 | array of fields on your data object by which to filter. 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | export default FilteringSection; 41 | -------------------------------------------------------------------------------- /example/src/sections/PublicMethodsSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import PublicMethodsExample from '../examples/PublicMethodsExample'; 6 | import PublicMethodsExampleCode from '!raw-loader!../examples/PublicMethodsExample'; 7 | 8 | import ExampleSection from '../components/ExampleSection'; 9 | import Markdown from '../components/Markdown'; 10 | import Section from '../components/Section'; 11 | 12 | interface PublicMethodsSectionProps { 13 | title: string; 14 | } 15 | 16 | const PublicMethodsSection = (props: PublicMethodsSectionProps) => ( 17 | 18 | 19 | The `clear`, `focus`, and `blur` methods are exposed for programmatic 20 | control of the typeahead. 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | export default PublicMethodsSection; 29 | -------------------------------------------------------------------------------- /example/src/sections/RenderingSection.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import React from 'react'; 4 | 5 | import LabelKeyExample from '../examples/LabelKeyExample'; 6 | import RenderingExample from '../examples/RenderingExample'; 7 | 8 | import LabelKeyExampleCode from '!raw-loader!../examples/LabelKeyExample'; 9 | import RenderingExampleCode from '!raw-loader!../examples/RenderingExample'; 10 | 11 | import ExampleSection from '../components/ExampleSection'; 12 | import Markdown from '../components/Markdown'; 13 | import Section from '../components/Section'; 14 | import Title from '../components/Title'; 15 | 16 | interface RenderingSectionProps { 17 | title: string; 18 | } 19 | 20 | const RenderingSection = (props: RenderingSectionProps) => ( 21 | 22 | 23 | You can customize how the typeahead looks and behaves by using the 24 | provided rendering hooks. 25 | 26 | 27 | 28 | 29 | LabelKey 30 | 31 | The `labelKey` prop accepts a callback allowing you to transform your data 32 | and return a compound string rather than just a single data field. 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | export default RenderingSection; 41 | -------------------------------------------------------------------------------- /example/src/util/getIdFromTitle.ts: -------------------------------------------------------------------------------- 1 | export default (title: string) => 2 | title.toLocaleLowerCase().split(' ').join('-'); 3 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "src", 5 | "checkJs": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "isolatedModules": true, 11 | "jsx": "react", 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noEmit": false, 15 | "outDir": "types", 16 | "removeComments": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "esnext", 21 | "types": ["./raw-loader.d.ts"] 22 | }, 23 | "include": ["**/*.ts", "**/*.tsx"], 24 | "exclude": ["node_modules", "public", "**/*.js"] 25 | } 26 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const CircularDependencyPlugin = require('circular-dependency-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | 6 | module.exports = (env, argv) => { 7 | return { 8 | entry: path.join(__dirname, 'src/index.tsx'), 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.(ts|js)x?$/, 13 | exclude: /node_modules/, 14 | use: { 15 | loader: 'babel-loader', 16 | options: { 17 | presets: [ 18 | '@babel/preset-env', 19 | '@babel/preset-react', 20 | '@babel/preset-typescript', 21 | ], 22 | plugins: [ 23 | [ 24 | 'prismjs', 25 | { 26 | languages: ['jsx', 'tsx'], 27 | theme: 'okaidia', 28 | css: true, 29 | }, 30 | ], 31 | ], 32 | }, 33 | }, 34 | }, 35 | { 36 | test: /\.css$/, 37 | use: ['style-loader', 'css-loader'], 38 | }, 39 | { 40 | test: /\.scss$/, 41 | use: ['style-loader', 'css-loader', 'sass-loader'], 42 | }, 43 | ], 44 | }, 45 | optimization: { 46 | minimizer: [ 47 | new TerserPlugin({ 48 | extractComments: false, 49 | terserOptions: { 50 | output: { 51 | comments: false, 52 | }, 53 | }, 54 | }), 55 | ], 56 | }, 57 | output: { 58 | filename: 'package-example.js', 59 | path: path.resolve('.'), 60 | }, 61 | plugins: [ 62 | new CircularDependencyPlugin({ 63 | allowAsyncCycles: false, 64 | cwd: process.cwd(), 65 | exclude: /node_modules/, 66 | failOnError: true, 67 | }), 68 | ], 69 | resolve: { 70 | alias: { 71 | 'react-bootstrap-typeahead': path.resolve( 72 | __dirname, 73 | '..', 74 | 'src/index.ts' 75 | ), 76 | }, 77 | extensions: ['.ts', '.tsx', '.js'], 78 | }, 79 | stats: { 80 | warnings: argv.mode !== 'production', 81 | }, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coveragePathIgnorePatterns: [ 3 | '/node_modules/', 4 | '/src/propTypes', 5 | ], 6 | setupFilesAfterEnv: ['./jest.setup.js'], 7 | testEnvironment: 'jsdom', 8 | testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], 9 | testPathIgnorePatterns: [ 10 | '/node_modules', 11 | '/cjs', 12 | '/es', 13 | '/example', 14 | '/types', 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { toHaveNoViolations } from 'jest-axe'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | 4 | expect.extend(toHaveNoViolations); 5 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys, @typescript-eslint/no-var-requires */ 2 | 3 | const babel = require('@rollup/plugin-babel').default; 4 | const commonjs = require('@rollup/plugin-commonjs'); 5 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 6 | const replace = require('@rollup/plugin-replace'); 7 | const { terser } = require('rollup-plugin-terser'); 8 | 9 | const { name } = require('./package.json'); 10 | 11 | const globals = { 12 | react: 'React', 13 | 'react-dom': 'ReactDOM', 14 | }; 15 | 16 | const extensions = ['.ts', '.tsx', '.js', '.jsx']; 17 | 18 | const getUmdConfig = (isProd) => ({ 19 | input: './src/index.ts', 20 | output: { 21 | file: `./dist/${name}${isProd ? '.min' : ''}.js`, 22 | format: 'umd', 23 | globals, 24 | name: 'ReactBootstrapTypeahead', 25 | }, 26 | external: Object.keys(globals), 27 | plugins: [ 28 | nodeResolve({ 29 | extensions, 30 | }), 31 | commonjs({ 32 | include: /node_modules/, 33 | }), 34 | babel({ 35 | babelHelpers: 'bundled', 36 | exclude: /node_modules/, 37 | extensions, 38 | }), 39 | replace({ 40 | 'process.env.NODE_ENV': JSON.stringify( 41 | isProd ? 'production' : 'development' 42 | ), 43 | preventAssignment: true, 44 | }), 45 | isProd ? terser() : null, 46 | ], 47 | }); 48 | 49 | module.exports = [false, true].map(getUmdConfig); 50 | -------------------------------------------------------------------------------- /scripts/buildCSS.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | /* eslint-disable no-console */ 5 | /* eslint-disable @typescript-eslint/no-var-requires */ 6 | 7 | const chalk = require('chalk'); 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const sass = require('sass'); 11 | 12 | const ROOT = path.join(__dirname, '..'); 13 | const OUT_DIR = path.join(ROOT, 'css'); 14 | const STYLES_DIR = path.join(ROOT, 'styles'); 15 | 16 | console.log(chalk.cyan('Building CSS files...\n')); 17 | 18 | // Create the output directory if it doesn't exist. 19 | if (!fs.existsSync(OUT_DIR)) { 20 | fs.mkdirSync(OUT_DIR); 21 | } 22 | 23 | fs.readdirSync(STYLES_DIR).forEach((filename) => { 24 | const file = path.join(STYLES_DIR, filename); 25 | 26 | // Include the .scss files in the package by simply copying them over. 27 | fs.copyFileSync(file, path.join(OUT_DIR, filename)); 28 | 29 | // Output both expanded and minified versions. 30 | ['compressed', 'expanded'].forEach((style) => { 31 | // Get the base filename. 32 | let name = filename.replace('.scss', ''); 33 | if (style === 'compressed') { 34 | // Denote minified CSS. 35 | name += '.min'; 36 | } 37 | 38 | const result = sass.compile(file, { style }); 39 | 40 | fs.writeFileSync(path.join(OUT_DIR, `${name}.css`), result.css); 41 | }); 42 | }); 43 | 44 | console.log(chalk.cyan('Done.\n')); 45 | -------------------------------------------------------------------------------- /scripts/buildModules.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable @typescript-eslint/no-var-requires */ 4 | /* eslint-disable import/no-extraneous-dependencies */ 5 | /* eslint-disable no-console */ 6 | 7 | const chalk = require('chalk'); 8 | const execa = require('execa'); 9 | const path = require('path'); 10 | 11 | const shell = (cmd) => 12 | execa(cmd, { 13 | shell: true, 14 | stdio: ['pipe', 'pipe', 'inherit'], 15 | }); 16 | 17 | const srcRoot = path.join(__dirname, '../src'); 18 | const start = Date.now(); 19 | 20 | function buildModules() { 21 | const commands = []; 22 | ['cjs', 'es'].forEach((env) => { 23 | commands.push( 24 | shell( 25 | `yarn babel ${srcRoot} -x ".ts,.tsx,.js,.jsx" --out-dir ${env} --env-name "${env}"` 26 | ) 27 | ); 28 | }); 29 | return Promise.all(commands); 30 | } 31 | 32 | Promise.resolve() 33 | .then(() => { 34 | console.log(chalk.cyan('Transpiling modules...\n')); 35 | }) 36 | .then(buildModules) 37 | .then(() => { 38 | const seconds = (Date.now() - start) / 1000; 39 | console.log(chalk.green(`Finished building modules in ${seconds}s\n`)); 40 | }); 41 | -------------------------------------------------------------------------------- /scripts/deployExample.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | /* eslint-disable no-console */ 5 | /* eslint-disable @typescript-eslint/no-var-requires */ 6 | 7 | const ghpages = require('gh-pages'); 8 | const { version } = require('../package.json'); 9 | 10 | /** 11 | * Don't publish pre-release versions, as denoted by the presence of a hyphen 12 | * in the version number, (eg: 3.0.0-rc.1). 13 | * 14 | * See: https://semver.org/#spec-item-9 15 | */ 16 | if (version.split('-').length === 1) { 17 | ghpages.publish('example', { 18 | message: `v${version}`, 19 | src: '{index.html,package-example.js,public/*}', 20 | }); 21 | } else { 22 | console.log( 23 | `Skipped deploying examples for pre-release version: v${version}` 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/behaviors/item.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { 3 | ComponentType, 4 | HTMLProps, 5 | MouseEvent, 6 | MouseEventHandler, 7 | useCallback, 8 | useEffect, 9 | useRef, 10 | } from 'react'; 11 | import scrollIntoView from 'scroll-into-view-if-needed'; 12 | 13 | import { useTypeaheadContext } from '../core/Context'; 14 | import { 15 | getDisplayName, 16 | getMenuItemId, 17 | preventInputBlur, 18 | warn, 19 | } from '../utils'; 20 | 21 | import { optionType } from '../propTypes'; 22 | import { Option } from '../types'; 23 | 24 | const propTypes = { 25 | option: optionType.isRequired, 26 | position: PropTypes.number, 27 | }; 28 | 29 | export interface UseItemProps extends HTMLProps { 30 | onClick?: MouseEventHandler; 31 | option: Option; 32 | position: number; 33 | } 34 | 35 | export function useItem({ 36 | label, 37 | onClick, 38 | option, 39 | position, 40 | ...props 41 | }: UseItemProps) { 42 | const { 43 | activeIndex, 44 | id, 45 | isOnlyResult, 46 | onActiveItemChange, 47 | onInitialItemChange, 48 | onMenuItemClick, 49 | setItem, 50 | } = useTypeaheadContext(); 51 | 52 | const itemRef = useRef(null); 53 | 54 | useEffect(() => { 55 | if (position === 0) { 56 | onInitialItemChange(option); 57 | } 58 | }); 59 | 60 | useEffect(() => { 61 | if (position === activeIndex) { 62 | onActiveItemChange(option); 63 | 64 | // Automatically scroll the menu as the user keys through it. 65 | const node = itemRef.current; 66 | 67 | node && 68 | scrollIntoView(node, { 69 | boundary: node.parentNode as Element, 70 | scrollMode: 'if-needed', 71 | }); 72 | } 73 | }, [activeIndex, onActiveItemChange, option, position]); 74 | 75 | const handleClick = useCallback( 76 | (e: MouseEvent) => { 77 | onMenuItemClick(option, e); 78 | onClick && onClick(e); 79 | }, 80 | [onClick, onMenuItemClick, option] 81 | ); 82 | 83 | const active = isOnlyResult || activeIndex === position; 84 | 85 | // Update the item's position in the item stack. 86 | setItem(option, position); 87 | 88 | return { 89 | ...props, 90 | active, 91 | 'aria-label': label, 92 | 'aria-selected': active, 93 | id: getMenuItemId(id, position), 94 | onClick: handleClick, 95 | onMouseDown: preventInputBlur, 96 | ref: itemRef, 97 | role: 'option', 98 | }; 99 | } 100 | 101 | /* istanbul ignore next */ 102 | export function withItem>( 103 | Component: ComponentType 104 | ) { 105 | warn( 106 | false, 107 | 'Warning: `withItem` is deprecated and will be removed in the next ' + 108 | 'major version. Use `useItem` instead.' 109 | ); 110 | 111 | const WrappedMenuItem = (props: T) => ( 112 | 113 | ); 114 | 115 | WrappedMenuItem.displayName = `withItem(${getDisplayName(Component)})`; 116 | WrappedMenuItem.propTypes = propTypes; 117 | 118 | return WrappedMenuItem; 119 | } 120 | -------------------------------------------------------------------------------- /src/behaviors/token.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { 3 | ComponentType, 4 | FocusEvent, 5 | FocusEventHandler, 6 | HTMLProps, 7 | KeyboardEvent, 8 | MouseEvent, 9 | MouseEventHandler, 10 | useState, 11 | } from 'react'; 12 | 13 | import { useRootClose } from '../components/RootClose'; 14 | import { getDisplayName, isFunction, warn } from '../utils'; 15 | 16 | import { optionType } from '../propTypes'; 17 | import { Option, OptionHandler } from '../types'; 18 | 19 | export interface UseTokenProps extends Omit, 'onBlur'> { 20 | // `onBlur` is typed more generically because it's passed to `useRootClose`, 21 | // which passes a generic event to the callback. 22 | onBlur?: (event: Event) => void; 23 | onClick?: MouseEventHandler; 24 | onFocus?: FocusEventHandler; 25 | onRemove?: OptionHandler; 26 | option: Option; 27 | } 28 | 29 | const propTypes = { 30 | onBlur: PropTypes.func, 31 | onClick: PropTypes.func, 32 | onFocus: PropTypes.func, 33 | onRemove: PropTypes.func, 34 | option: optionType.isRequired, 35 | }; 36 | 37 | export function useToken({ 38 | onBlur, 39 | onClick, 40 | onFocus, 41 | onRemove, 42 | option, 43 | ...props 44 | }: UseTokenProps) { 45 | const [active, setActive] = useState(false); 46 | 47 | const handleBlur = (e: Event) => { 48 | setActive(false); 49 | onBlur && onBlur(e); 50 | }; 51 | 52 | const handleClick = (e: MouseEvent) => { 53 | setActive(true); 54 | onClick && onClick(e); 55 | }; 56 | 57 | const handleFocus = (e: FocusEvent) => { 58 | setActive(true); 59 | onFocus && onFocus(e); 60 | }; 61 | 62 | const handleRemove = () => { 63 | onRemove && onRemove(option); 64 | }; 65 | 66 | const handleKeyDown = (e: KeyboardEvent) => { 67 | if (e.key === 'Backspace' && active) { 68 | // Prevent browser from going back. 69 | e.preventDefault(); 70 | handleRemove(); 71 | } 72 | }; 73 | 74 | const attachRef = useRootClose(handleBlur, { 75 | ...props, 76 | disabled: !active, 77 | }); 78 | 79 | return { 80 | active, 81 | onBlur: handleBlur, 82 | onClick: handleClick, 83 | onFocus: handleFocus, 84 | onKeyDown: handleKeyDown, 85 | onRemove: isFunction(onRemove) ? handleRemove : undefined, 86 | ref: attachRef, 87 | }; 88 | } 89 | 90 | /* istanbul ignore next */ 91 | export function withToken>( 92 | Component: ComponentType 93 | ) { 94 | warn( 95 | false, 96 | 'Warning: `withToken` is deprecated and will be removed in the next ' + 97 | 'major version. Use `useToken` instead.' 98 | ); 99 | 100 | const displayName = `withToken(${getDisplayName(Component)})`; 101 | 102 | const WrappedToken = (props: T) => ( 103 | 104 | ); 105 | 106 | WrappedToken.displayName = displayName; 107 | WrappedToken.propTypes = propTypes; 108 | 109 | return WrappedToken; 110 | } 111 | -------------------------------------------------------------------------------- /src/components/AsyncTypeahead/AsyncTypeahead.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { useAsync, UseAsyncProps } from '../../behaviors/async'; 3 | import TypeaheadComponent from '../Typeahead'; 4 | import Typeahead from '../../core/Typeahead'; 5 | 6 | const AsyncTypeahead = forwardRef((props, ref) => ( 7 | 8 | )); 9 | 10 | export default AsyncTypeahead; 11 | -------------------------------------------------------------------------------- /src/components/AsyncTypeahead/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './AsyncTypeahead'; 2 | export * from './AsyncTypeahead'; 3 | -------------------------------------------------------------------------------- /src/components/ClearButton/ClearButton.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Meta, Story } from '@storybook/react'; 5 | 6 | import ClearButton, { ClearButtonProps } from './ClearButton'; 7 | 8 | export default { 9 | title: 'Components/ClearButton', 10 | component: ClearButton, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ; 14 | 15 | export const Default = Template.bind({}); 16 | Default.args = {}; 17 | 18 | export const Large = Template.bind({}); 19 | Large.args = { 20 | size: 'lg', 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/ClearButton/ClearButton.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './ClearButton.stories'; 4 | 5 | import { 6 | composeStories, 7 | fireEvent, 8 | generateSnapshots, 9 | render, 10 | screen, 11 | userEvent, 12 | } from '../../tests/helpers'; 13 | 14 | const { Default, Large } = composeStories(stories); 15 | 16 | describe('', () => { 17 | generateSnapshots(stories); 18 | 19 | it('renders a default clear button', () => { 20 | render(); 21 | expect(screen.getByRole('button').className).toBe( 22 | 'close btn-close rbt-close' 23 | ); 24 | }); 25 | 26 | it('renders a large clear button', () => { 27 | render(); 28 | expect(screen.getByRole('button').className).toContain('rbt-close-lg'); 29 | }); 30 | 31 | it('registers a click', async () => { 32 | const user = userEvent.setup(); 33 | const onClick = jest.fn(); 34 | render(); 35 | 36 | const button = screen.getByRole('button'); 37 | await user.click(button); 38 | 39 | expect(onClick).toHaveBeenCalledTimes(1); 40 | }); 41 | 42 | it('prevents the default backspace behavior', () => { 43 | const onKeyDown = jest.fn(); 44 | let isDefault; 45 | 46 | render(); 47 | 48 | const button = screen.getByRole('button'); 49 | 50 | isDefault = fireEvent.keyDown(button, { 51 | key: 'Backspace', 52 | }); 53 | 54 | expect(onKeyDown).toHaveBeenCalledTimes(1); 55 | expect(isDefault).toBe(false); 56 | 57 | isDefault = fireEvent.keyDown(button, { 58 | key: 'Enter', 59 | }); 60 | 61 | expect(onKeyDown).toHaveBeenCalledTimes(2); 62 | expect(isDefault).toBe(true); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/ClearButton/ClearButton.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import PropTypes from 'prop-types'; 3 | import React, { HTMLProps, KeyboardEvent, MouseEvent } from 'react'; 4 | 5 | import type { Size } from '../../types'; 6 | import { isSizeLarge, isSizeSmall } from '../../utils'; 7 | 8 | import { sizeType } from '../../propTypes'; 9 | 10 | const propTypes = { 11 | label: PropTypes.string, 12 | onClick: PropTypes.func, 13 | onKeyDown: PropTypes.func, 14 | size: sizeType, 15 | }; 16 | 17 | export interface ClearButtonProps 18 | extends Omit, 'size'> { 19 | label?: string; 20 | size?: Size; 21 | } 22 | 23 | /** 24 | * ClearButton 25 | * 26 | * http://getbootstrap.com/css/#helper-classes-close 27 | */ 28 | const ClearButton = ({ 29 | className, 30 | label = 'Clear', 31 | onClick, 32 | onKeyDown, 33 | size, 34 | ...props 35 | }: ClearButtonProps): JSX.Element => ( 36 | ) => { 50 | e.stopPropagation(); 51 | onClick && onClick(e); 52 | }} 53 | onKeyDown={(e: KeyboardEvent) => { 54 | // Prevent browser from navigating back. 55 | if (e.key === 'Backspace') { 56 | e.preventDefault(); 57 | } 58 | onKeyDown && onKeyDown(e); 59 | }} 60 | type="button"> 61 | 62 | × 63 | 64 | {label} 65 | 66 | ); 67 | 68 | ClearButton.propTypes = propTypes; 69 | 70 | export default ClearButton; 71 | -------------------------------------------------------------------------------- /src/components/ClearButton/__snapshots__/ClearButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 9 | 13 | × 14 | 15 | 18 | Clear 19 | 20 | 21 | `; 22 | 23 | exports[` Large story renders snapshot 1`] = ` 24 | 29 | 33 | × 34 | 35 | 38 | Clear 39 | 40 | 41 | `; 42 | -------------------------------------------------------------------------------- /src/components/ClearButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ClearButton'; 2 | export * from './ClearButton'; 3 | -------------------------------------------------------------------------------- /src/components/Highlighter/Highlighter.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Highlighter, { HighlighterProps } from './Highlighter'; 7 | 8 | export default { 9 | title: 'Components/Highlighter', 10 | component: Highlighter, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ; 14 | 15 | const children = 'This is a sentence.'; 16 | 17 | export const Default = Template.bind({}); 18 | Default.args = { 19 | children, 20 | search: '', 21 | }; 22 | 23 | export const Highlighted = Template.bind({}); 24 | Highlighted.args = { 25 | children, 26 | search: 'sent', 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Highlighter/Highlighter.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Highlighter from './Highlighter'; 4 | import { render } from '../../tests/helpers'; 5 | 6 | function getMatches(nodes: NodeListOf) { 7 | const arr = Array.from(nodes) as Element[]; 8 | return arr.filter((node) => node.tagName === 'MARK'); 9 | } 10 | 11 | describe('', () => { 12 | it('does not highlight text when there is no search string', () => { 13 | const { container } = render( 14 | California 15 | ); 16 | 17 | const nodes = container.childNodes; 18 | expect(nodes).toHaveLength(1); 19 | expect(nodes.item(0)).toHaveTextContent('California'); 20 | expect(getMatches(nodes)).toHaveLength(0); 21 | }); 22 | 23 | it('does not highlight text when there is no match', () => { 24 | const { container } = render( 25 | California 26 | ); 27 | 28 | expect(getMatches(container.childNodes)).toHaveLength(0); 29 | }); 30 | 31 | it('handles an empty child string', () => { 32 | // Explicitly set a string as the child. 33 | // eslint-disable-next-line react/jsx-curly-brace-presence 34 | const { container } = render({''}); 35 | 36 | expect(container.childNodes.item(0)).toHaveTextContent(''); 37 | }); 38 | 39 | it('highlights text within a string', () => { 40 | const { container } = render( 41 | California 42 | ); 43 | 44 | const nodes = container.childNodes; 45 | const matches = getMatches(nodes); 46 | 47 | // Output: [Cal, i, forn, i, a] 48 | expect(nodes.length).toBe(5); 49 | expect(nodes.item(0)).toHaveTextContent('Cal'); 50 | 51 | expect(matches.length).toBe(2); 52 | expect(matches[0]).toHaveTextContent('i'); 53 | expect(matches[0]).toHaveClass('rbt-highlight-text'); 54 | }); 55 | 56 | it('highlights text at the beginning of a string', () => { 57 | const { container } = render( 58 | California 59 | ); 60 | 61 | const nodes = container.childNodes; 62 | const matches = getMatches(nodes); 63 | 64 | // Output: [Cal, ifornia] 65 | expect(nodes).toHaveLength(2); 66 | expect(nodes.item(0)).toHaveTextContent('Cal'); 67 | 68 | expect(matches).toHaveLength(1); 69 | expect(matches[0]).toHaveTextContent('Cal'); 70 | }); 71 | 72 | it('adds custom classnames to the highlighted children', () => { 73 | const { container } = render( 74 | 75 | California 76 | 77 | ); 78 | 79 | expect(getMatches(container.childNodes)[0]).toHaveClass('foo'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/components/Highlighter/Highlighter.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | import { getMatchBounds } from '../../utils'; 5 | 6 | const propTypes = { 7 | children: PropTypes.string.isRequired, 8 | highlightClassName: PropTypes.string, 9 | search: PropTypes.string.isRequired, 10 | }; 11 | 12 | export interface HighlighterProps { 13 | children: string; 14 | highlightClassName?: string; 15 | search: string; 16 | } 17 | 18 | /** 19 | * Stripped-down version of https://github.com/helior/react-highlighter 20 | * 21 | * Results are already filtered by the time the component is used internally so 22 | * we can safely ignore case and diacritical marks for the purposes of matching. 23 | */ 24 | const Highlighter = ({ 25 | children, 26 | highlightClassName = 'rbt-highlight-text', 27 | search, 28 | }: HighlighterProps) => { 29 | if (!search || !children) { 30 | return <>{children}>; 31 | } 32 | 33 | let matchCount = 0; 34 | let remaining = children; 35 | 36 | const highlighterChildren = []; 37 | 38 | while (remaining) { 39 | const bounds = getMatchBounds(remaining, search); 40 | 41 | // No match anywhere in the remaining string, stop. 42 | if (!bounds) { 43 | highlighterChildren.push(remaining); 44 | break; 45 | } 46 | 47 | // Capture the string that leads up to a match. 48 | const nonMatch = remaining.slice(0, bounds.start); 49 | if (nonMatch) { 50 | highlighterChildren.push(nonMatch); 51 | } 52 | 53 | // Capture the matching string. 54 | const match = remaining.slice(bounds.start, bounds.end); 55 | highlighterChildren.push( 56 | 57 | {match} 58 | 59 | ); 60 | matchCount += 1; 61 | 62 | // And if there's anything left over, continue the loop. 63 | remaining = remaining.slice(bounds.end); 64 | } 65 | 66 | return <>{highlighterChildren}>; 67 | }; 68 | 69 | Highlighter.propTypes = propTypes; 70 | 71 | export default Highlighter; 72 | -------------------------------------------------------------------------------- /src/components/Highlighter/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Highlighter'; 2 | export * from './Highlighter'; 3 | -------------------------------------------------------------------------------- /src/components/Hint/Hint.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Hint, { HintProps } from './Hint'; 7 | import { HintProvider, noop } from '../../tests/helpers'; 8 | 9 | export default { 10 | title: 'Components/Hint', 11 | component: Hint, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => { 15 | const [inputNode, setInputNode] = useState(null); 16 | 17 | return ( 18 | 19 | 20 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export const Default = Template.bind({}); 32 | Default.args = {}; 33 | -------------------------------------------------------------------------------- /src/components/Hint/Hint.test.tsx: -------------------------------------------------------------------------------- 1 | import * as stories from './Hint.stories'; 2 | import { generateSnapshots } from '../../tests/helpers'; 3 | 4 | describe('', () => { 5 | generateSnapshots(stories); 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/Hint/__snapshots__/Hint.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 7 | 11 | 19 | 20 | `; 21 | -------------------------------------------------------------------------------- /src/components/Hint/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Hint'; 2 | export * from './Hint'; 3 | -------------------------------------------------------------------------------- /src/components/Input/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Input, { InputProps } from './Input'; 7 | 8 | export default { 9 | title: 'Components/Input', 10 | component: Input, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ; 14 | 15 | export const Default = Template.bind({}); 16 | Default.args = {}; 17 | -------------------------------------------------------------------------------- /src/components/Input/Input.test.tsx: -------------------------------------------------------------------------------- 1 | import * as stories from './Input.stories'; 2 | import { generateSnapshots } from '../../tests/helpers'; 3 | 4 | describe('', () => { 5 | generateSnapshots(stories); 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import React, { forwardRef, HTMLAttributes } from 'react'; 3 | 4 | export type InputProps = HTMLAttributes; 5 | 6 | const Input = forwardRef((props, ref) => ( 7 | 12 | )); 13 | 14 | export default Input; 15 | -------------------------------------------------------------------------------- /src/components/Input/__snapshots__/Input.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/components/Input/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Input'; 2 | export * from './Input'; 3 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Loader, { LoaderProps } from './Loader'; 7 | 8 | export default { 9 | title: 'Components/Loader', 10 | component: Loader, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ; 14 | 15 | export const Default = Template.bind({}); 16 | Default.args = {}; 17 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './Loader.stories'; 4 | 5 | import { 6 | composeStories, 7 | generateSnapshots, 8 | render, 9 | screen, 10 | } from '../../tests/helpers'; 11 | 12 | const { Default } = composeStories(stories); 13 | 14 | describe('', () => { 15 | generateSnapshots(stories); 16 | 17 | it('renders a loading indicator', () => { 18 | render(); 19 | 20 | expect(screen.getByRole('status')).toHaveClass( 21 | 'rbt-loader spinner-border spinner-border-sm' 22 | ); 23 | expect(screen.getByText('Loading...')).toBeTruthy(); 24 | }); 25 | 26 | it('renders a custom label for accessibility', () => { 27 | render(); 28 | expect(screen.getByText('Waiting...')).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const propTypes = { 5 | label: PropTypes.string, 6 | }; 7 | 8 | export interface LoaderProps { 9 | label?: string; 10 | } 11 | 12 | const Loader = ({ label = 'Loading...' }: LoaderProps) => ( 13 | 14 | {label} 15 | 16 | ); 17 | 18 | Loader.propTypes = propTypes; 19 | 20 | export default Loader; 21 | -------------------------------------------------------------------------------- /src/components/Loader/__snapshots__/Loader.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 8 | 11 | Loading... 12 | 13 | 14 | `; 15 | -------------------------------------------------------------------------------- /src/components/Loader/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Loader'; 2 | export * from './Loader'; 3 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Menu, { MenuProps } from './Menu'; 7 | import MenuItem from '../MenuItem'; 8 | 9 | const options = [{ label: 'Item 1' }, { label: 'Item 2' }, { label: 'Item 3' }]; 10 | 11 | // TODO: Caused by `isRequiredForA11y` validator. 12 | // @ts-ignore 13 | export default { 14 | title: 'Components/Menu', 15 | component: Menu, 16 | } as Meta; 17 | 18 | const children = options.map((o, idx) => ( 19 | 20 | {o.label} 21 | 22 | )); 23 | 24 | const Template: Story = (args) => ( 25 | 32 | ); 33 | 34 | export const Default = Template.bind({}); 35 | Default.args = { 36 | children, 37 | id: 'default-menu', 38 | }; 39 | 40 | export const Empty = Template.bind({}); 41 | Empty.args = { 42 | id: 'empty-menu', 43 | }; 44 | 45 | export const HeaderAndDivider = Template.bind({}); 46 | HeaderAndDivider.args = { 47 | children: ( 48 | <> 49 | This is a menu header 50 | 51 | 52 | {options[0].label} 53 | 54 | > 55 | ), 56 | id: 'header-and-divider', 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './Menu.stories'; 4 | 5 | import { 6 | composeStories, 7 | fireEvent, 8 | getItems, 9 | getMenu, 10 | generateSnapshots, 11 | render, 12 | screen, 13 | } from '../../tests/helpers'; 14 | 15 | const { Default, Empty, HeaderAndDivider } = composeStories(stories); 16 | 17 | describe('', () => { 18 | generateSnapshots(stories); 19 | 20 | it('renders a basic menu with menu items', () => { 21 | render(); 22 | 23 | expect(getMenu()).toHaveClass('rbt-menu dropdown-menu'); 24 | expect(getItems()).toHaveLength(3); 25 | }); 26 | 27 | it('sets the maxHeight and other styles', () => { 28 | render(); 29 | 30 | const menu = getMenu(); 31 | expect(menu).toHaveStyle('background-color: red'); 32 | expect(menu).toHaveStyle('max-height: 100px'); 33 | }); 34 | 35 | it('renders an empty label when there are no children', () => { 36 | const emptyLabel = 'No matches.'; 37 | render(); 38 | 39 | const items = getItems(); 40 | expect(items).toHaveLength(1); 41 | expect(items[0]).toHaveClass('disabled'); 42 | expect(items[0]).toHaveTextContent(emptyLabel); 43 | }); 44 | 45 | it('adds an aria-label attribute to the menu', () => { 46 | render(); 47 | expect(getMenu()).toHaveAttribute('aria-label', 'custom-label'); 48 | }); 49 | 50 | it('prevents the input from blurring on mousedown', () => { 51 | render(); 52 | 53 | // `false` means e.preventDefault was called. 54 | expect(fireEvent.mouseDown(screen.getByRole('listbox'))).toBe(false); 55 | }); 56 | 57 | it('checks the menu header and divider', () => { 58 | render(); 59 | 60 | const header = screen.getByRole('heading'); 61 | expect(header.tagName).toBe('DIV'); 62 | expect(header).toHaveClass('dropdown-header'); 63 | expect(header).toHaveTextContent('This is a menu header'); 64 | 65 | const divider = screen.getByRole('separator'); 66 | expect(divider.tagName).toBe('DIV'); 67 | expect(divider).toHaveClass('dropdown-divider'); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import PropTypes from 'prop-types'; 3 | import React, { Children, HTMLProps, ReactNode, Ref } from 'react'; 4 | 5 | import { BaseMenuItem } from '../MenuItem'; 6 | 7 | import { preventInputBlur } from '../../utils'; 8 | import { checkPropType, isRequiredForA11y } from '../../propTypes'; 9 | 10 | const MenuDivider = () => ; 11 | 12 | const MenuHeader = (props: HTMLProps) => ( 13 | // eslint-disable-next-line jsx-a11y/role-has-required-aria-props 14 | 15 | ); 16 | 17 | const propTypes = { 18 | 'aria-label': PropTypes.string, 19 | /** 20 | * Message to display in the menu if there are no valid results. 21 | */ 22 | emptyLabel: PropTypes.node, 23 | /** 24 | * Needed for accessibility. 25 | */ 26 | id: checkPropType( 27 | PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 28 | isRequiredForA11y 29 | ), 30 | /** 31 | * Maximum height of the dropdown menu. 32 | */ 33 | maxHeight: PropTypes.string, 34 | }; 35 | 36 | export interface MenuProps extends HTMLProps { 37 | emptyLabel?: ReactNode; 38 | innerRef?: Ref; 39 | maxHeight?: string; 40 | } 41 | 42 | /** 43 | * Menu component that handles empty state when passed a set of results. 44 | */ 45 | const Menu = ({ 46 | emptyLabel = 'No matches found.', 47 | innerRef, 48 | maxHeight = '300px', 49 | style, 50 | ...props 51 | }: MenuProps) => { 52 | const children = 53 | Children.count(props.children) === 0 ? ( 54 | 55 | {emptyLabel} 56 | 57 | ) : ( 58 | props.children 59 | ); 60 | 61 | return ( 62 | /* eslint-disable jsx-a11y/interactive-supports-focus */ 63 | 79 | {children} 80 | 81 | /* eslint-enable jsx-a11y/interactive-supports-focus */ 82 | ); 83 | }; 84 | 85 | Menu.propTypes = propTypes; 86 | Menu.Divider = MenuDivider; 87 | Menu.Header = MenuHeader; 88 | 89 | export default Menu; 90 | -------------------------------------------------------------------------------- /src/components/Menu/__snapshots__/Menu.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 11 | 18 | Item 1 19 | 20 | 27 | Item 2 28 | 29 | 36 | Item 3 37 | 38 | 39 | `; 40 | 41 | exports[` Empty story renders snapshot 1`] = ` 42 | 49 | 54 | No matches found. 55 | 56 | 57 | `; 58 | 59 | exports[` HeaderAndDivider story renders snapshot 1`] = ` 60 | 67 | 71 | This is a menu header 72 | 73 | 77 | 84 | Item 1 85 | 86 | 87 | `; 88 | -------------------------------------------------------------------------------- /src/components/Menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Menu'; 2 | export * from './Menu'; 3 | -------------------------------------------------------------------------------- /src/components/MenuItem/BaseMenuItem.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import { BaseMenuItem, BaseMenuItemProps } from './MenuItem'; 7 | 8 | export default { 9 | title: 'Components/MenuItem/BaseMenuItem', 10 | component: BaseMenuItem, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ; 14 | 15 | export const Default = Template.bind({}); 16 | Default.args = { 17 | children: 'This is a base menu item', 18 | }; 19 | 20 | export const Active = Template.bind({}); 21 | Active.args = { 22 | active: true, 23 | children: 'This is an active base menu item', 24 | }; 25 | 26 | export const Disabled = Template.bind({}); 27 | Disabled.args = { 28 | children: 'This is a disabled base menu item', 29 | disabled: true, 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/MenuItem/MenuItem.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import MenuItem, { MenuItemProps } from './MenuItem'; 7 | import { 8 | defaultContext, 9 | TypeaheadContext, 10 | TypeaheadContextType, 11 | } from '../../core/Context'; 12 | 13 | export default { 14 | title: 'Components/MenuItem/MenuItem', 15 | component: MenuItem, 16 | } as Meta; 17 | 18 | interface Args { 19 | context: Partial; 20 | props: MenuItemProps; 21 | } 22 | 23 | const value = { 24 | ...defaultContext, 25 | id: 'test-id', 26 | }; 27 | 28 | const Template: Story = ({ context, props }) => ( 29 | 30 | 31 | 32 | ); 33 | 34 | export const Default = Template.bind({}); 35 | Default.args = { 36 | props: { 37 | children: 'This is a menu item', 38 | label: 'test label', 39 | option: '', 40 | position: 0, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/MenuItem/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import React, { forwardRef, HTMLAttributes, MouseEvent } from 'react'; 3 | 4 | import { useItem, UseItemProps } from '../../behaviors/item'; 5 | 6 | export interface BaseMenuItemProps extends HTMLAttributes { 7 | active?: boolean; 8 | disabled?: boolean; 9 | href?: string; 10 | } 11 | 12 | export const BaseMenuItem = forwardRef( 13 | ({ active, children, className, disabled, onClick, ...props }, ref) => { 14 | return ( 15 | ) => { 20 | e.preventDefault(); 21 | !disabled && onClick && onClick(e); 22 | }} 23 | ref={ref}> 24 | {children} 25 | 26 | ); 27 | } 28 | ); 29 | 30 | export type MenuItemProps = UseItemProps; 31 | 32 | export default function MenuItem(props: MenuItemProps) { 33 | return ; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Active story renders snapshot 1`] = ` 4 | 8 | This is an active base menu item 9 | 10 | `; 11 | 12 | exports[` Default story renders snapshot 1`] = ` 13 | 17 | This is a base menu item 18 | 19 | `; 20 | 21 | exports[` Disabled story renders snapshot 1`] = ` 22 | 26 | This is a disabled base menu item 27 | 28 | `; 29 | 30 | exports[` Default story renders snapshot 1`] = ` 31 | 39 | This is a menu item 40 | 41 | `; 42 | -------------------------------------------------------------------------------- /src/components/MenuItem/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MenuItem'; 2 | export * from './MenuItem'; 3 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Overlay, { OverlayProps } from './Overlay'; 7 | import Menu from '../Menu'; 8 | import { BaseMenuItem } from '../MenuItem'; 9 | 10 | export default { 11 | title: 'Components/Overlay', 12 | component: Overlay, 13 | } as Meta; 14 | 15 | const Template: Story = (args) => { 16 | const [referenceElement, setReferenceElement] = 17 | useState(null); 18 | 19 | return ( 20 | 21 | 24 | Reference element 25 | 26 | 27 | {(menuProps) => ( 28 | 29 | This is the menu 30 | 31 | )} 32 | 33 | 34 | ); 35 | }; 36 | 37 | export const Default = Template.bind({}); 38 | Default.args = { 39 | isMenuShown: true, 40 | positionFixed: true, 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { CSSProperties, ReactElement, RefCallback } from 'react'; 3 | 4 | import useOverlay, { OverlayOptions, ReferenceElement } from './useOverlay'; 5 | 6 | import { ALIGN_VALUES } from '../../constants'; 7 | import { noop } from '../../utils'; 8 | 9 | // `Element` is not defined during server-side rendering, so shim it here. 10 | /* istanbul ignore next */ 11 | const SafeElement = typeof Element === 'undefined' ? noop : Element; 12 | 13 | const propTypes = { 14 | /** 15 | * Specify menu alignment. The default value is `justify`, which makes the 16 | * menu as wide as the input and truncates long values. Specifying `left` 17 | * or `right` will align the menu to that side and the width will be 18 | * determined by the length of menu item values. 19 | */ 20 | align: PropTypes.oneOf(ALIGN_VALUES), 21 | children: PropTypes.func.isRequired, 22 | /** 23 | * Specify whether the menu should appear above the input. 24 | */ 25 | dropup: PropTypes.bool, 26 | /** 27 | * Whether or not to automatically adjust the position of the menu when it 28 | * reaches the viewport boundaries. 29 | */ 30 | flip: PropTypes.bool, 31 | isMenuShown: PropTypes.bool, 32 | positionFixed: PropTypes.bool, 33 | // @ts-ignore 34 | referenceElement: PropTypes.instanceOf(SafeElement), 35 | }; 36 | 37 | export interface OverlayRenderProps { 38 | innerRef: RefCallback; 39 | style: CSSProperties; 40 | } 41 | 42 | export interface OverlayProps extends OverlayOptions { 43 | children: (props: OverlayRenderProps) => ReactElement | null; 44 | isMenuShown: boolean; 45 | referenceElement: ReferenceElement; 46 | } 47 | 48 | const Overlay = ({ referenceElement, isMenuShown, ...props }: OverlayProps) => { 49 | const overlayProps = useOverlay(referenceElement, props); 50 | 51 | if (!isMenuShown) { 52 | return null; 53 | } 54 | 55 | return props.children(overlayProps); 56 | }; 57 | 58 | Overlay.propTypes = propTypes; 59 | 60 | export default Overlay; 61 | -------------------------------------------------------------------------------- /src/components/Overlay/__snapshots__/Overlay.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Default story renders snapshot 1`] = ` 4 | 5 | 8 | Reference element 9 | 10 | 17 | 22 | This is the menu 23 | 24 | 25 | 26 | `; 27 | -------------------------------------------------------------------------------- /src/components/Overlay/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Overlay'; 2 | export * from './Overlay'; 3 | 4 | export { default as useOverlay } from './useOverlay'; 5 | -------------------------------------------------------------------------------- /src/components/Overlay/useOverlay.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autoUpdate, 3 | flip, 4 | Middleware, 5 | Placement, 6 | size, 7 | useFloating, 8 | } from '@floating-ui/react-dom'; 9 | import { useState } from 'react'; 10 | 11 | import { Align } from '../../types'; 12 | 13 | export type ReferenceElement = HTMLElement | null; 14 | 15 | export interface OverlayOptions { 16 | align?: Align; 17 | dropup?: boolean; 18 | flip?: boolean; 19 | positionFixed?: boolean; 20 | } 21 | 22 | export function getMiddleware(props: Pick) { 23 | const middleware: Middleware[] = []; 24 | if (props.flip) { 25 | middleware.push(flip()); 26 | } 27 | 28 | if (props.align !== 'right' && props.align !== 'left') { 29 | middleware.push( 30 | size({ 31 | apply({ rects, elements }) { 32 | Object.assign(elements.floating.style, { 33 | width: `${rects.reference.width}px`, 34 | }); 35 | }, 36 | }) 37 | ); 38 | } 39 | 40 | return middleware; 41 | } 42 | 43 | export function getPlacement( 44 | props: Pick 45 | ): Placement { 46 | const x = props.align === 'right' ? 'end' : 'start'; 47 | const y = props.dropup ? 'top' : 'bottom'; 48 | 49 | return `${y}-${x}`; 50 | } 51 | 52 | export function useOverlay( 53 | referenceElement: ReferenceElement, 54 | options: OverlayOptions 55 | ) { 56 | const [floatingElement, attachRef] = useState(null); 57 | const { floatingStyles } = useFloating({ 58 | elements: { 59 | floating: floatingElement, 60 | reference: referenceElement, 61 | }, 62 | middleware: getMiddleware(options), 63 | placement: getPlacement(options), 64 | strategy: options.positionFixed ? 'fixed' : 'absolute', 65 | whileElementsMounted: autoUpdate, 66 | }); 67 | 68 | return { 69 | innerRef: attachRef, 70 | style: floatingStyles, 71 | }; 72 | } 73 | 74 | export default useOverlay; 75 | -------------------------------------------------------------------------------- /src/components/RootClose/RootClose.tsx: -------------------------------------------------------------------------------- 1 | import { Ref } from 'react'; 2 | import useRootClose from './useRootClose'; 3 | 4 | interface RootCloseProps { 5 | children: (ref: Ref) => JSX.Element; 6 | disabled?: boolean; 7 | onRootClose: (event: Event) => void; 8 | } 9 | 10 | function RootClose({ children, onRootClose, ...props }: RootCloseProps) { 11 | const rootRef = useRootClose(onRootClose, props); 12 | return children(rootRef); 13 | } 14 | 15 | export default RootClose; 16 | -------------------------------------------------------------------------------- /src/components/RootClose/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './RootClose'; 2 | export * from './RootClose'; 3 | 4 | export { default as useRootClose } from './useRootClose'; 5 | -------------------------------------------------------------------------------- /src/components/RootClose/useRootClose.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import _useRootClose, { RootCloseOptions } from '@restart/ui/useRootClose'; 3 | 4 | function useRootClose( 5 | onRootClose: (e: Event) => void, 6 | options: RootCloseOptions 7 | ) { 8 | const ref = useRef(null); 9 | _useRootClose(ref, onRootClose, options); 10 | return ref; 11 | } 12 | 13 | export default useRootClose; 14 | -------------------------------------------------------------------------------- /src/components/Token/Token.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Token, { TokenProps } from './Token'; 7 | import { noop } from '../../utils'; 8 | 9 | export default { 10 | title: 'Components/Token', 11 | component: Token, 12 | } as Meta; 13 | 14 | const Template: Story> = (args) => ; 15 | 16 | export const Interactive = Template.bind({}); 17 | Interactive.args = { 18 | children: 'This is an interactive token', 19 | onRemove: noop, 20 | }; 21 | 22 | export const Static = Template.bind({}); 23 | Static.args = { 24 | children: 'This is a static token', 25 | readOnly: true, 26 | }; 27 | 28 | export const Anchor = Template.bind({}); 29 | Anchor.args = { 30 | children: 'This is a link token', 31 | href: '#', 32 | readOnly: true, 33 | }; 34 | 35 | export const Disabled = Template.bind({}); 36 | Disabled.args = { 37 | children: 'This is a disabled token', 38 | disabled: true, 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/Token/Token.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './Token.stories'; 4 | 5 | import { 6 | composeStories, 7 | generateSnapshots, 8 | render, 9 | screen, 10 | userEvent, 11 | } from '../../tests/helpers'; 12 | 13 | const ACTIVE_CLASS = 'rbt-token-active'; 14 | const DISABLED_CLASS = 'rbt-token-disabled'; 15 | const REMOVEABLE_CLASS = 'rbt-token-removeable'; 16 | 17 | const { Anchor, Disabled, Interactive, Static } = composeStories(stories); 18 | 19 | describe('', () => { 20 | generateSnapshots(stories); 21 | 22 | it('renders non-removeable tokens', () => { 23 | render( 24 | <> 25 | 26 | 27 | 28 | > 29 | ); 30 | 31 | expect(screen.queryAllByRole('button').length).toBe(0); 32 | }); 33 | 34 | it('renders a removeable token', async () => { 35 | const user = userEvent.setup(); 36 | const onRemove = jest.fn(); 37 | const { container } = render(); 38 | 39 | const token = container.firstChild; 40 | expect(token).toHaveClass(REMOVEABLE_CLASS); 41 | 42 | const closeButton = screen.getByRole('button'); 43 | await user.click(closeButton); 44 | expect(onRemove).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('makes disabled tokens non-interactive', () => { 48 | const { container } = render(); 49 | 50 | const token = container.firstChild as HTMLElement; 51 | expect(token.tagName).toBe('DIV'); 52 | expect(token).not.toHaveAttribute('href'); 53 | expect(token).toHaveClass(DISABLED_CLASS); 54 | }); 55 | 56 | it('handles events', async () => { 57 | const user = userEvent.setup(); 58 | const onBlur = jest.fn(); 59 | const onClick = jest.fn(); 60 | const onFocus = jest.fn(); 61 | const onRemove = jest.fn(); 62 | 63 | const { container } = render( 64 | 70 | ); 71 | 72 | const token = container.firstChild as HTMLElement; 73 | 74 | expect(token).not.toHaveClass(ACTIVE_CLASS); 75 | 76 | token.focus(); 77 | expect(onFocus).toHaveBeenCalledTimes(1); 78 | expect(token).toHaveClass(ACTIVE_CLASS); 79 | 80 | token.blur(); 81 | expect(onBlur).toHaveBeenCalledTimes(1); 82 | expect(token).not.toHaveClass(ACTIVE_CLASS); 83 | 84 | await user.click(token); 85 | expect(onClick).toHaveBeenCalledTimes(1); 86 | expect(onFocus).toHaveBeenCalledTimes(2); 87 | expect(token).toHaveClass(ACTIVE_CLASS); 88 | 89 | // `onRemove` called only when token is active/focused. 90 | token.blur(); 91 | await user.keyboard('{backspace}'); 92 | expect(onRemove).toHaveBeenCalledTimes(0); 93 | 94 | token.focus(); 95 | await user.keyboard('{backspace}'); 96 | expect(onRemove).toHaveBeenCalledTimes(1); 97 | 98 | // Other events are ignored. 99 | await user.keyboard('{enter}'); 100 | expect(onRemove).toHaveBeenCalledTimes(1); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/components/Token/Token.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import React, { forwardRef, HTMLProps, MouseEventHandler } from 'react'; 3 | 4 | import ClearButton from '../ClearButton'; 5 | 6 | import { useToken, UseTokenProps } from '../../behaviors/token'; 7 | import { isFunction } from '../../utils'; 8 | 9 | type HTMLElementProps = Omit, 'onBlur' | 'ref'>; 10 | 11 | interface InteractiveTokenProps extends HTMLElementProps { 12 | active?: boolean; 13 | onRemove?: MouseEventHandler; 14 | } 15 | 16 | const InteractiveToken = forwardRef( 17 | ({ active, children, className, onRemove, tabIndex, ...props }, ref) => ( 18 | 30 | {children} 31 | 37 | 38 | ) 39 | ); 40 | 41 | interface StaticTokenProps extends HTMLElementProps { 42 | disabled?: boolean; 43 | href?: string; 44 | } 45 | 46 | const StaticToken = ({ 47 | children, 48 | className, 49 | disabled, 50 | href, 51 | }: StaticTokenProps) => { 52 | const classnames = cx( 53 | 'rbt-token', 54 | { 55 | 'rbt-token-disabled': disabled, 56 | }, 57 | className 58 | ); 59 | 60 | if (href && !disabled) { 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | } 67 | 68 | return {children}; 69 | }; 70 | 71 | export interface TokenProps extends UseTokenProps { 72 | disabled?: boolean; 73 | readOnly?: boolean; 74 | } 75 | 76 | /** 77 | * Individual token component, generally displayed within the 78 | * `TypeaheadInputMulti` component, but can also be rendered on its own. 79 | */ 80 | const Token = ({ 81 | children, 82 | option, 83 | readOnly, 84 | ...props 85 | }: TokenProps) => { 86 | const { ref, ...tokenProps } = useToken({ ...props, option }); 87 | const child = {children}; 88 | 89 | return !props.disabled && !readOnly && isFunction(tokenProps.onRemove) ? ( 90 | 91 | {child} 92 | 93 | ) : ( 94 | {child} 95 | ); 96 | }; 97 | 98 | export default Token; 99 | -------------------------------------------------------------------------------- /src/components/Token/__snapshots__/Token.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Anchor story renders snapshot 1`] = ` 4 | 8 | 11 | This is a link token 12 | 13 | 14 | `; 15 | 16 | exports[` Disabled story renders snapshot 1`] = ` 17 | 20 | 23 | This is a disabled token 24 | 25 | 26 | `; 27 | 28 | exports[` Interactive story renders snapshot 1`] = ` 29 | 33 | 36 | This is an interactive token 37 | 38 | 44 | 48 | × 49 | 50 | 53 | Remove 54 | 55 | 56 | 57 | `; 58 | 59 | exports[` Static story renders snapshot 1`] = ` 60 | 63 | 66 | This is a static token 67 | 68 | 69 | `; 70 | -------------------------------------------------------------------------------- /src/components/Token/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Token'; 2 | export * from './Token'; 3 | -------------------------------------------------------------------------------- /src/components/Typeahead/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Typeahead'; 2 | export * from './Typeahead'; 3 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputMulti/TypeaheadInputMulti.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React, { ChangeEvent, useState } from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import Token from '../Token'; 7 | import TypeaheadInputMulti, { 8 | TypeaheadInputMultiProps, 9 | } from './TypeaheadInputMulti'; 10 | 11 | import options from '../../tests/data'; 12 | import { HintProvider, noop } from '../../tests/helpers'; 13 | import type { Size } from '../../types'; 14 | 15 | export default { 16 | title: 'Components/TypeaheadInputMulti', 17 | component: TypeaheadInputMulti, 18 | } as Meta; 19 | 20 | const selected = options.slice(1, 4); 21 | 22 | const defaultProps = { 23 | children: selected.map((option) => ( 24 | 25 | {option.name} 26 | 27 | )), 28 | selected, 29 | }; 30 | 31 | interface Args extends TypeaheadInputMultiProps { 32 | hintText?: string; 33 | isValid?: boolean; 34 | isInvalid?: boolean; 35 | size?: Size; 36 | } 37 | 38 | const Template: Story = ({ hintText = '', ...args }) => { 39 | const [value, setValue] = useState(args.value); 40 | const [inputNode, setInputNode] = useState(null); 41 | 42 | return ( 43 | 44 | ) => 48 | setValue(e.target.value) 49 | } 50 | referenceElementRef={noop} 51 | value={value} 52 | /> 53 | 54 | ); 55 | }; 56 | 57 | export const Default = Template.bind({}); 58 | Default.args = { 59 | ...defaultProps, 60 | }; 61 | 62 | export const FocusState = Template.bind({}); 63 | FocusState.args = { 64 | ...defaultProps, 65 | className: 'focus', 66 | }; 67 | 68 | export const Disabled = Template.bind({}); 69 | Disabled.args = { 70 | ...defaultProps, 71 | children: selected.map((option) => ( 72 | 73 | {option.name} 74 | 75 | )), 76 | disabled: true, 77 | }; 78 | 79 | export const Small = Template.bind({}); 80 | Small.args = { 81 | ...defaultProps, 82 | size: 'sm', 83 | }; 84 | 85 | export const Large = Template.bind({}); 86 | Large.args = { 87 | ...defaultProps, 88 | size: 'lg', 89 | }; 90 | 91 | export const Valid = Template.bind({}); 92 | Valid.args = { 93 | ...defaultProps, 94 | className: 'focus', 95 | isValid: true, 96 | }; 97 | 98 | export const Invalid = Template.bind({}); 99 | Invalid.args = { 100 | ...defaultProps, 101 | className: 'focus', 102 | isInvalid: true, 103 | }; 104 | 105 | export const WithHint = Template.bind({}); 106 | WithHint.args = { 107 | ...defaultProps, 108 | hintText: 'california', 109 | value: 'cali', 110 | }; 111 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputMulti/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './TypeaheadInputMulti'; 2 | export * from './TypeaheadInputMulti'; 3 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputSingle/TypeaheadInputSingle.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React, { useState } from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import TypeaheadInputSingle from './TypeaheadInputSingle'; 7 | 8 | import type { Size, TypeaheadInputProps } from '../../types'; 9 | import { HintProvider, noop } from '../../tests/helpers'; 10 | 11 | export default { 12 | title: 'Components/TypeaheadInputSingle', 13 | component: TypeaheadInputSingle, 14 | } as Meta; 15 | 16 | interface Args extends Omit { 17 | hintText?: string; 18 | isInvalid?: boolean; 19 | isValid?: boolean; 20 | size?: Size; 21 | } 22 | 23 | const Template: Story = ({ hintText = '', ...args }) => { 24 | const [inputNode, setInputNode] = useState(null); 25 | 26 | return ( 27 | 28 | 33 | 34 | ); 35 | }; 36 | 37 | export const Default = Template.bind({}); 38 | Default.args = { 39 | placeholder: 'This is a default input...', 40 | }; 41 | 42 | export const Disabled = Template.bind({}); 43 | Disabled.args = { 44 | disabled: true, 45 | placeholder: 'This is a disabled input...', 46 | }; 47 | 48 | export const Small = Template.bind({}); 49 | Small.args = { 50 | placeholder: 'This is a small input...', 51 | size: 'sm', 52 | }; 53 | 54 | export const Large = Template.bind({}); 55 | Large.args = { 56 | placeholder: 'This is a large input...', 57 | size: 'lg', 58 | }; 59 | 60 | export const Valid = Template.bind({}); 61 | Valid.args = { 62 | placeholder: 'This is a valid input...', 63 | isValid: true, 64 | }; 65 | 66 | export const Invalid = Template.bind({}); 67 | Invalid.args = { 68 | placeholder: 'This is an invalid input...', 69 | isInvalid: true, 70 | }; 71 | 72 | export const WithHint = Template.bind({}); 73 | WithHint.args = { 74 | hintText: 'California', 75 | onChange: noop, 76 | value: 'Ca', 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputSingle/TypeaheadInputSingle.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './TypeaheadInputSingle.stories'; 4 | 5 | import { 6 | composeStories, 7 | generateSnapshots, 8 | render, 9 | screen, 10 | userEvent, 11 | } from '../../tests/helpers'; 12 | 13 | const { Disabled } = composeStories(stories); 14 | 15 | describe('', () => { 16 | generateSnapshots(stories); 17 | 18 | it('does not focus a disabled input', async () => { 19 | const user = userEvent.setup(); 20 | render(); 21 | 22 | const input = screen.getByRole('textbox'); 23 | expect(input).toBeDisabled(); 24 | 25 | await user.click(input); 26 | expect(input).not.toHaveFocus(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputSingle/TypeaheadInputSingle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Hint from '../Hint'; 4 | import Input from '../Input'; 5 | 6 | import { TypeaheadInputProps } from '../../types'; 7 | import { propsWithBsClassName } from '../../utils'; 8 | 9 | const TypeaheadInputSingle = ({ 10 | inputRef, 11 | referenceElementRef, 12 | ...props 13 | }: TypeaheadInputProps) => ( 14 | 15 | { 18 | inputRef(node); 19 | referenceElementRef(node); 20 | }} 21 | /> 22 | 23 | ); 24 | 25 | export default TypeaheadInputSingle; 26 | -------------------------------------------------------------------------------- /src/components/TypeaheadInputSingle/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './TypeaheadInputSingle'; 2 | export * from './TypeaheadInputSingle'; 3 | -------------------------------------------------------------------------------- /src/components/TypeaheadMenu/TypeaheadMenu.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys,import/no-extraneous-dependencies */ 2 | 3 | import React from 'react'; 4 | import { Story, Meta } from '@storybook/react'; 5 | 6 | import TypeaheadMenu, { TypeaheadMenuProps } from './TypeaheadMenu'; 7 | 8 | import options from '../../tests/data'; 9 | import { getOptionProperty } from '../../utils'; 10 | 11 | export default { 12 | title: 'Components/TypeaheadMenu', 13 | component: TypeaheadMenu, 14 | } as Meta; 15 | 16 | const defaultProps = { 17 | id: 'typeahead-menu', 18 | labelKey: 'name', 19 | options, 20 | text: '', 21 | }; 22 | 23 | const Template: Story = (args) => ( 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | export const Default = Template.bind({}); 32 | Default.args = { 33 | ...defaultProps, 34 | }; 35 | 36 | export const CustomOption = Template.bind({}); 37 | CustomOption.args = { 38 | ...defaultProps, 39 | options: [{ customOption: true, name: 'custom option' }], 40 | text: 'custom option', 41 | }; 42 | 43 | export const Pagination = Template.bind({}); 44 | Pagination.args = { 45 | ...defaultProps, 46 | options: [...options.slice(0, 5), { paginationOption: true }], 47 | }; 48 | 49 | export const CustomChildren = Template.bind({}); 50 | CustomChildren.args = { 51 | ...defaultProps, 52 | renderMenuItemChildren: (option) => { 53 | const name = getOptionProperty(option, 'name'); 54 | const population = getOptionProperty(option, 'population'); 55 | 56 | return ( 57 | <> 58 | {name} 59 | 60 | Population: {population.toString()} 61 | 62 | > 63 | ); 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/TypeaheadMenu/TypeaheadMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as stories from './TypeaheadMenu.stories'; 4 | 5 | import { 6 | composeStories, 7 | generateSnapshots, 8 | render, 9 | screen, 10 | } from '../../tests/helpers'; 11 | 12 | const { CustomChildren, CustomOption, Pagination } = composeStories(stories); 13 | 14 | describe('', () => { 15 | generateSnapshots(stories); 16 | 17 | it('renders a custom option', () => { 18 | render(); 19 | expect(screen.getByRole('option')).toHaveTextContent('New selection:'); 20 | }); 21 | 22 | it('renders custom new selection text', () => { 23 | render(); 24 | expect(screen.getByRole('option')).toHaveTextContent('Select new:'); 25 | }); 26 | 27 | it('renders a pagination item', () => { 28 | render(); 29 | 30 | const paginationItem = screen.queryByRole('option', { 31 | name: 'Display additional results...', 32 | }); 33 | expect(paginationItem).toBeInTheDocument(); 34 | }); 35 | 36 | it('renders a custom pagination label', () => { 37 | const paginationText = 'Show more...'; 38 | render(); 39 | 40 | expect( 41 | screen.queryByRole('option', { name: paginationText }) 42 | ).toBeInTheDocument(); 43 | }); 44 | 45 | it('renders a custom pagination label component', () => { 46 | const paginationText = Show more...; 47 | render(); 48 | 49 | const item = screen.queryByRole('option', { name: 'Show more...' }); 50 | expect(item).toBeInTheDocument(); 51 | expect(item).toHaveAttribute('aria-label', ''); 52 | }); 53 | 54 | it('does not show a paginator when there are no results', () => { 55 | render(); 56 | expect( 57 | screen.queryByRole('option', { name: 'Display additional results...' }) 58 | ).not.toBeInTheDocument(); 59 | }); 60 | 61 | it('renders custom menu item children', () => { 62 | render(); 63 | expect(screen.getAllByRole('option')[0]).toHaveTextContent('Population'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/components/TypeaheadMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './TypeaheadMenu'; 2 | export * from './TypeaheadMenu'; 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ALIGN_VALUES = ['justify', 'left', 'right'] as const; 2 | export const DEFAULT_LABELKEY = 'label'; 3 | export const SIZES = ['lg', 'sm'] as const; 4 | -------------------------------------------------------------------------------- /src/core/Context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import { noop } from '../utils'; 4 | import { Id, Option, OptionHandler, SelectEvent } from '../types'; 5 | 6 | export interface TypeaheadContextType { 7 | activeIndex: number; 8 | hintText: string; 9 | id: Id; 10 | initialItem: Option | null; 11 | inputNode: HTMLInputElement | null; 12 | isOnlyResult: boolean; 13 | onActiveItemChange: OptionHandler; 14 | onAdd: OptionHandler; 15 | onInitialItemChange: (option?: Option) => void; 16 | onMenuItemClick: (option: Option, event: SelectEvent) => void; 17 | setItem: (option: Option, position: number) => void; 18 | } 19 | 20 | export const defaultContext = { 21 | activeIndex: -1, 22 | hintText: '', 23 | id: '', 24 | initialItem: null, 25 | inputNode: null, 26 | isOnlyResult: false, 27 | onActiveItemChange: noop, 28 | onAdd: noop, 29 | onInitialItemChange: noop, 30 | onMenuItemClick: noop, 31 | setItem: noop, 32 | }; 33 | 34 | export const TypeaheadContext = 35 | createContext(defaultContext); 36 | 37 | export const useTypeaheadContext = () => useContext(TypeaheadContext); 38 | -------------------------------------------------------------------------------- /src/core/TypeaheadManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { KeyboardEvent, useEffect, useRef } from 'react'; 2 | 3 | import { TypeaheadContext, TypeaheadContextType } from './Context'; 4 | import { 5 | defaultSelectHint, 6 | getHintText, 7 | getInputProps, 8 | getInputText, 9 | getIsOnlyResult, 10 | isFunction, 11 | pick, 12 | } from '../utils'; 13 | import { TypeaheadManagerProps } from '../types'; 14 | 15 | const inputPropKeys = [ 16 | 'activeIndex', 17 | 'disabled', 18 | 'id', 19 | 'inputRef', 20 | 'isFocused', 21 | 'isMenuShown', 22 | 'multiple', 23 | 'onBlur', 24 | 'onChange', 25 | 'onClick', 26 | 'onFocus', 27 | 'onKeyDown', 28 | 'placeholder', 29 | ] as (keyof TypeaheadManagerProps)[]; 30 | 31 | const propKeys = [ 32 | 'activeIndex', 33 | 'hideMenu', 34 | 'isMenuShown', 35 | 'labelKey', 36 | 'onClear', 37 | 'onHide', 38 | 'onRemove', 39 | 'results', 40 | 'selected', 41 | 'text', 42 | 'toggleMenu', 43 | ] as (keyof TypeaheadManagerProps)[]; 44 | 45 | const contextKeys = [ 46 | 'activeIndex', 47 | 'id', 48 | 'initialItem', 49 | 'inputNode', 50 | 'onActiveItemChange', 51 | 'onAdd', 52 | 'onInitialItemChange', 53 | 'onMenuItemClick', 54 | 'setItem', 55 | ] as (keyof TypeaheadManagerProps)[]; 56 | 57 | const TypeaheadManager = (props: TypeaheadManagerProps) => { 58 | const { 59 | allowNew, 60 | children, 61 | initialItem, 62 | isMenuShown, 63 | onAdd, 64 | onInitialItemChange, 65 | onKeyDown, 66 | onMenuToggle, 67 | results, 68 | selectHint, 69 | } = props; 70 | 71 | const hintText = getHintText(props); 72 | 73 | useEffect(() => { 74 | // Clear the initial item when there are no results. 75 | if (!(allowNew || results.length)) { 76 | onInitialItemChange(); 77 | } 78 | }); 79 | 80 | const isInitialRender = useRef(true); 81 | useEffect(() => { 82 | if (isInitialRender.current) { 83 | isInitialRender.current = false; 84 | return; 85 | } 86 | onMenuToggle(isMenuShown); 87 | }, [isMenuShown, onMenuToggle]); 88 | 89 | const handleKeyDown = (e: KeyboardEvent) => { 90 | onKeyDown(e); 91 | 92 | if (!initialItem) { 93 | return; 94 | } 95 | 96 | const addOnlyResult = e.key === 'Enter' && getIsOnlyResult(props); 97 | const shouldSelectHint = hintText && defaultSelectHint(e, selectHint); 98 | 99 | if (addOnlyResult || shouldSelectHint) { 100 | onAdd(initialItem); 101 | } 102 | }; 103 | 104 | const childProps = { 105 | ...pick(props, propKeys), 106 | getInputProps: getInputProps({ 107 | ...pick(props, inputPropKeys), 108 | onKeyDown: handleKeyDown, 109 | value: getInputText(props), 110 | }), 111 | }; 112 | 113 | const contextValue: TypeaheadContextType = { 114 | ...pick(props, contextKeys), 115 | hintText, 116 | isOnlyResult: getIsOnlyResult(props), 117 | }; 118 | 119 | return ( 120 | 121 | {isFunction(children) ? children(childProps) : children} 122 | 123 | ); 124 | }; 125 | 126 | export default TypeaheadManager; 127 | -------------------------------------------------------------------------------- /src/core/TypeaheadState.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | clearTypeahead, 3 | clickOrFocusInput, 4 | getInitialState, 5 | hideMenu, 6 | toggleMenu, 7 | } from './TypeaheadState'; 8 | 9 | import { defaultProps, defaultState } from '../tests/data'; 10 | 11 | describe('State modifiers', () => { 12 | it('calls the clearTypeahead modifier', () => { 13 | const props = { 14 | ...defaultProps, 15 | defaultOpen: false, 16 | defaultSelected: [], 17 | maxResults: 10, 18 | }; 19 | 20 | const state = { 21 | ...defaultState, 22 | isFocused: true, 23 | }; 24 | 25 | expect(clearTypeahead(state, props)).toEqual({ 26 | ...defaultState, 27 | isFocused: true, 28 | shownResults: 10, 29 | }); 30 | }); 31 | 32 | it('calls the clickOrFocusInput modifier', () => { 33 | const state = { 34 | ...defaultState, 35 | isFocused: false, 36 | showMenu: false, 37 | }; 38 | 39 | expect(clickOrFocusInput(state)).toEqual({ 40 | ...defaultState, 41 | isFocused: true, 42 | showMenu: true, 43 | }); 44 | }); 45 | 46 | it('calls the getInitialState modifier', () => { 47 | expect( 48 | getInitialState({ 49 | ...defaultProps, 50 | defaultInputValue: 'foo', 51 | defaultOpen: false, 52 | defaultSelected: [], 53 | maxResults: 10, 54 | }) 55 | ).toEqual({ 56 | ...defaultState, 57 | shownResults: 10, 58 | text: 'foo', 59 | }); 60 | 61 | expect( 62 | getInitialState({ 63 | ...defaultProps, 64 | defaultInputValue: 'foo', 65 | defaultOpen: true, 66 | defaultSelected: ['bar', 'foo'], 67 | maxResults: 10, 68 | }) 69 | ).toEqual({ 70 | ...defaultState, 71 | selected: ['bar'], 72 | showMenu: true, 73 | shownResults: 10, 74 | text: 'bar', 75 | }); 76 | }); 77 | 78 | it('calls the hideMenu modifier', () => { 79 | const props = { 80 | ...defaultProps, 81 | defaultSelected: [], 82 | maxResults: 10, 83 | }; 84 | 85 | expect(hideMenu(defaultState, props)).toEqual({ 86 | ...defaultState, 87 | activeIndex: -1, 88 | activeItem: undefined, 89 | initialItem: undefined, 90 | showMenu: false, 91 | shownResults: props.maxResults, 92 | }); 93 | }); 94 | 95 | it('calls the toggleMenu modifier', () => { 96 | const props = { 97 | ...defaultProps, 98 | defaultSelected: [], 99 | maxResults: 10, 100 | }; 101 | 102 | expect(toggleMenu({ ...defaultState, showMenu: false }, props)).toEqual({ 103 | ...defaultState, 104 | showMenu: true, 105 | }); 106 | 107 | expect(toggleMenu({ ...defaultState, showMenu: true }, props)).toEqual({ 108 | ...defaultState, 109 | activeIndex: -1, 110 | activeItem: undefined, 111 | initialItem: undefined, 112 | showMenu: false, 113 | shownResults: props.maxResults, 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/core/TypeaheadState.ts: -------------------------------------------------------------------------------- 1 | import { TypeaheadProps, TypeaheadState } from '../types'; 2 | import { getOptionLabel } from '../utils'; 3 | 4 | type Props = TypeaheadProps; 5 | 6 | export function getInitialState(props: Props): TypeaheadState { 7 | const { 8 | defaultInputValue, 9 | defaultOpen, 10 | defaultSelected, 11 | maxResults, 12 | multiple, 13 | } = props; 14 | 15 | let selected = props.selected 16 | ? props.selected.slice() 17 | : defaultSelected.slice(); 18 | 19 | let text = defaultInputValue; 20 | 21 | if (!multiple && selected.length) { 22 | // Set the text if an initial selection is passed in. 23 | text = getOptionLabel(selected[0], props.labelKey); 24 | 25 | if (selected.length > 1) { 26 | // Limit to 1 selection in single-select mode. 27 | selected = selected.slice(0, 1); 28 | } 29 | } 30 | 31 | return { 32 | activeIndex: -1, 33 | activeItem: undefined, 34 | initialItem: undefined, 35 | isFocused: false, 36 | selected, 37 | showMenu: defaultOpen, 38 | shownResults: maxResults, 39 | text, 40 | }; 41 | } 42 | 43 | export function clearTypeahead(state: TypeaheadState, props: Props) { 44 | return { 45 | ...getInitialState(props), 46 | isFocused: state.isFocused, 47 | selected: [], 48 | text: '', 49 | }; 50 | } 51 | 52 | export function clickOrFocusInput(state: TypeaheadState) { 53 | return { 54 | ...state, 55 | isFocused: true, 56 | showMenu: true, 57 | }; 58 | } 59 | 60 | export function hideMenu(state: TypeaheadState, props: Props) { 61 | const { activeIndex, activeItem, initialItem, shownResults } = 62 | getInitialState(props); 63 | 64 | return { 65 | ...state, 66 | activeIndex, 67 | activeItem, 68 | initialItem, 69 | showMenu: false, 70 | shownResults, 71 | }; 72 | } 73 | 74 | export function toggleMenu(state: TypeaheadState, props: Props) { 75 | return state.showMenu ? hideMenu(state, props) : { ...state, showMenu: true }; 76 | } 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { default as AsyncTypeahead } from './components/AsyncTypeahead'; 3 | export { default as ClearButton } from './components/ClearButton'; 4 | export { default as Highlighter } from './components/Highlighter'; 5 | export { default as Hint, useHint } from './components/Hint'; 6 | export { default as Input } from './components/Input'; 7 | export { default as Loader } from './components/Loader'; 8 | export { default as Menu } from './components/Menu'; 9 | export * from './components/Menu'; 10 | export { default as MenuItem } from './components/MenuItem'; 11 | export * from './components/MenuItem'; 12 | export { default as Token } from './components/Token'; 13 | export * from './components/Token'; 14 | export { default as Typeahead } from './components/Typeahead'; 15 | export * from './components/Typeahead'; 16 | export { default as TypeaheadInputMulti } from './components/TypeaheadInputMulti'; 17 | export { default as TypeaheadInputSingle } from './components/TypeaheadInputSingle'; 18 | export { default as TypeaheadMenu } from './components/TypeaheadMenu'; 19 | export * from './components/TypeaheadMenu'; 20 | 21 | // HOCs + Hooks 22 | export * from './behaviors/async'; 23 | export * from './behaviors/item'; 24 | export * from './behaviors/token'; 25 | 26 | // Types 27 | export { default as TypeaheadRef } from './core/Typeahead'; 28 | -------------------------------------------------------------------------------- /src/tests/props.ts: -------------------------------------------------------------------------------- 1 | export const defaultProps = {}; 2 | 3 | export const defaultState = {}; 4 | -------------------------------------------------------------------------------- /src/utils/addCustomOption.test.ts: -------------------------------------------------------------------------------- 1 | import addCustomOption from './addCustomOption'; 2 | import options, { defaultProps, defaultState } from '../tests/data'; 3 | 4 | // const labelKey = 'name'; 5 | 6 | const defaultMerged = { 7 | ...defaultProps, 8 | ...defaultState, 9 | allowNew: true, 10 | labelKey: 'name', 11 | text: 'zzz', 12 | }; 13 | 14 | describe('addCustomOption', () => { 15 | it('does not add a custom option when `allowNew` is false', () => { 16 | const props = { 17 | ...defaultMerged, 18 | allowNew: false, 19 | }; 20 | expect(addCustomOption(options, props)).toBe(false); 21 | }); 22 | 23 | it('does not add a custom option when no text is entered', () => { 24 | const props = { 25 | ...defaultMerged, 26 | text: '', 27 | }; 28 | expect(addCustomOption(options, props)).toBe(false); 29 | }); 30 | 31 | it('adds a custom option if no matches are found', () => { 32 | expect(addCustomOption(options, defaultMerged)).toBe(true); 33 | }); 34 | 35 | it('adds a custom option when `labelKey` is a function', () => { 36 | const props = { 37 | ...defaultMerged, 38 | labelKey: (o) => o.name, 39 | }; 40 | expect(addCustomOption(options, props)).toBe(true); 41 | }); 42 | 43 | it('adds a custom option when no exact matches are found', () => { 44 | const props = { ...defaultMerged, text: 'Ala' }; 45 | expect(addCustomOption(options, props)).toBe(true); 46 | }); 47 | 48 | it('does not add a custom option when an exact match is found', () => { 49 | const props = { ...defaultMerged, text: 'Wyoming' }; 50 | expect(addCustomOption(options, props)).toBe(false); 51 | }); 52 | 53 | it('adds a custom option when `allowNew` returns true', () => { 54 | const props = { 55 | ...defaultMerged, 56 | allowNew: () => true, 57 | text: 'North Carolina', // Would otherwise return false 58 | }; 59 | expect(addCustomOption(options, props)).toBe(true); 60 | }); 61 | 62 | it('does not add a custom option when `allowNew` returns false', () => { 63 | const props = { 64 | ...defaultMerged, 65 | allowNew: () => false, 66 | text: 'xxx', // Would otherwise return true 67 | }; 68 | expect(addCustomOption(options, props)).toBe(false); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/utils/addCustomOption.ts: -------------------------------------------------------------------------------- 1 | import getOptionLabel from './getOptionLabel'; 2 | import { isFunction } from './nodash'; 3 | 4 | import { Option, TypeaheadPropsAndState } from '../types'; 5 | 6 | function addCustomOption( 7 | results: Option[], 8 | props: TypeaheadPropsAndState 9 | ): boolean { 10 | const { allowNew, labelKey, text } = props; 11 | 12 | if (!allowNew || !text.trim()) { 13 | return false; 14 | } 15 | 16 | // If the consumer has provided a callback, use that to determine whether or 17 | // not to add the custom option. 18 | if (isFunction(allowNew)) { 19 | return allowNew(results, props); 20 | } 21 | 22 | // By default, don't add the custom option if there is an exact text match 23 | // with an existing option. 24 | return !results.some((o) => getOptionLabel(o, labelKey) === text); 25 | } 26 | 27 | export default addCustomOption; 28 | -------------------------------------------------------------------------------- /src/utils/defaultFilterBy.ts: -------------------------------------------------------------------------------- 1 | import isEqual from 'fast-deep-equal'; 2 | 3 | import getOptionProperty from './getOptionProperty'; 4 | import { isFunction, isString } from './nodash'; 5 | import stripDiacritics from './stripDiacritics'; 6 | import warn from './warn'; 7 | 8 | import type { LabelKey, Option } from '../types'; 9 | 10 | interface Props { 11 | caseSensitive: boolean; 12 | filterBy: string[]; 13 | ignoreDiacritics: boolean; 14 | labelKey: LabelKey; 15 | multiple: boolean; 16 | selected: Option[]; 17 | text: string; 18 | } 19 | 20 | function isMatch(input: string, string: string, props: Props): boolean { 21 | let searchStr = input; 22 | let str = string; 23 | 24 | if (!props.caseSensitive) { 25 | searchStr = searchStr.toLowerCase(); 26 | str = str.toLowerCase(); 27 | } 28 | 29 | if (props.ignoreDiacritics) { 30 | searchStr = stripDiacritics(searchStr); 31 | str = stripDiacritics(str); 32 | } 33 | 34 | return str.indexOf(searchStr) !== -1; 35 | } 36 | 37 | /** 38 | * Default algorithm for filtering results. 39 | */ 40 | export default function defaultFilterBy(option: Option, props: Props): boolean { 41 | const { filterBy, labelKey, multiple, selected, text } = props; 42 | 43 | // Don't show selected options in the menu for the multi-select case. 44 | if (multiple && selected.some((o) => isEqual(o, option))) { 45 | return false; 46 | } 47 | 48 | if (isFunction(labelKey)) { 49 | return isMatch(text, labelKey(option), props); 50 | } 51 | 52 | const fields: string[] = filterBy.slice(); 53 | 54 | if (isString(labelKey)) { 55 | // Add the `labelKey` field to the list of fields if it isn't already there. 56 | if (fields.indexOf(labelKey) === -1) { 57 | fields.unshift(labelKey); 58 | } 59 | } 60 | 61 | if (isString(option)) { 62 | warn( 63 | fields.length <= 1, 64 | 'You cannot filter by properties when `option` is a string.' 65 | ); 66 | 67 | return isMatch(text, option, props); 68 | } 69 | 70 | return fields.some((field: string) => { 71 | let value = getOptionProperty(option, field); 72 | 73 | if (!isString(value)) { 74 | warn( 75 | false, 76 | 'Fields passed to `filterBy` should have string values. Value will ' + 77 | 'be converted to a string; results may be unexpected.' 78 | ); 79 | 80 | value = String(value); 81 | } 82 | 83 | return isMatch(text, value as string, props); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/defaultSelectHint.test.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent } from 'react'; 2 | 3 | import defaultSelectHint from './defaultSelectHint'; 4 | import { noop } from '../tests/helpers'; 5 | 6 | const defaultEvent = { 7 | currentTarget: { 8 | value: 'Cali', 9 | }, 10 | key: 'Tab', 11 | preventDefault: noop, 12 | } as KeyboardEvent; 13 | 14 | describe('defaultSelectHint', () => { 15 | beforeEach(() => { 16 | defaultEvent.preventDefault = jest.fn(); 17 | }); 18 | 19 | it('returns true when tab is pressed', () => { 20 | expect(defaultSelectHint(defaultEvent)).toBe(true); 21 | expect(defaultEvent.preventDefault).toHaveBeenCalledTimes(1); 22 | }); 23 | 24 | it('checks hinting behavior when the right arrow key is pressed', () => { 25 | const event = { ...defaultEvent, key: 'ArrowRight' }; 26 | 27 | event.currentTarget.selectionStart = 3; 28 | expect(defaultSelectHint(event)).toBe(false); 29 | 30 | event.currentTarget.selectionStart = 4; 31 | expect(defaultSelectHint(event)).toBe(true); 32 | 33 | event.currentTarget.selectionStart = null; 34 | expect(defaultSelectHint(event)).toBe(true); 35 | }); 36 | 37 | it('returns false for other keys', () => { 38 | // Build up a set of valid keys. 39 | const keys: string[] = [ 40 | ...['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'], 41 | ...'0123456789'.split(''), 42 | ...'abcdefghijqlmnopqrstuvwxyz'.split(''), 43 | ...['Backspace', ' ', 'Escape', 'Enter', 'Tab'], 44 | ...';=,-./`'.split(''), 45 | ..."[\\]'".split(''), 46 | ]; 47 | 48 | keys 49 | .filter((key) => key !== 'Enter' && key !== 'ArrowRight' && key !== 'Tab') 50 | .forEach((key) => { 51 | defaultEvent.key = key; 52 | expect(defaultSelectHint(defaultEvent)).toBe(false); 53 | }); 54 | }); 55 | 56 | it('accepts a callback for custom behaviors', () => { 57 | const event = { ...defaultEvent, key: 'Enter' }; 58 | const selectHint = ( 59 | shouldSelectHint: boolean, 60 | e: KeyboardEvent 61 | ) => e.key === 'Enter' || shouldSelectHint; 62 | 63 | expect(defaultSelectHint(event, selectHint)).toBe(true); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/defaultSelectHint.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent } from 'react'; 2 | 3 | import isSelectable from './isSelectable'; 4 | 5 | import type { SelectHint } from '../types'; 6 | 7 | export default function defaultSelectHint( 8 | e: KeyboardEvent, 9 | selectHint?: SelectHint 10 | ): boolean { 11 | let shouldSelectHint = false; 12 | 13 | if (e.key === 'ArrowRight') { 14 | // For selectable input types ("text", "search"), only select the hint if 15 | // it's at the end of the input value. For non-selectable types ("email", 16 | // "number"), always select the hint. 17 | shouldSelectHint = isSelectable(e.currentTarget) 18 | ? e.currentTarget.selectionStart === e.currentTarget.value.length 19 | : true; 20 | } 21 | 22 | if (e.key === 'Tab') { 23 | // Prevent input from blurring on TAB. 24 | e.preventDefault(); 25 | shouldSelectHint = true; 26 | } 27 | 28 | return selectHint ? selectHint(shouldSelectHint, e) : shouldSelectHint; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/getDisplayName.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, FC } from 'react'; 2 | import getDisplayName from './getDisplayName'; 3 | 4 | const displayName = 'AnotherName'; 5 | const anonymize = (Component: ComponentType) => () => ; 6 | const NamedComponent = () => ; 7 | 8 | describe('getDisplayName', () => { 9 | it('returns the displayName of the component', () => { 10 | expect(getDisplayName(NamedComponent)).toBe('NamedComponent'); 11 | 12 | (NamedComponent as FC).displayName = displayName; 13 | expect(getDisplayName(NamedComponent)).toBe(displayName); 14 | 15 | // HOCs will obscure the name and displayName. 16 | expect(getDisplayName(anonymize(NamedComponent))).toBe('Component'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/getDisplayName.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export default function getDisplayName(Component: ComponentType): string { 5 | return Component.displayName || Component.name || 'Component'; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getHintText.test.ts: -------------------------------------------------------------------------------- 1 | import getHintText from './getHintText'; 2 | import states from '../tests/data'; 3 | 4 | const props = { 5 | activeIndex: -1, 6 | activeItem: null, 7 | initialItem: { name: 'Alabama' }, 8 | isFocused: true, 9 | isMenuShown: true, 10 | labelKey: 'name', 11 | minLength: 0, 12 | multiple: false, 13 | selected: [], 14 | text: 'alA', 15 | }; 16 | 17 | describe('getHintText', () => { 18 | it('returns a case-sensitive hint string', () => { 19 | const hintText = getHintText(props); 20 | expect(hintText).toBe('alAbama'); 21 | }); 22 | 23 | it('returns an empty string when the text is empty', () => { 24 | const hintText = getHintText({ ...props, text: '' }); 25 | expect(hintText).toBe(''); 26 | }); 27 | 28 | it('returns an empty string when the menu is not focused', () => { 29 | const hintText = getHintText({ ...props, isFocused: false }); 30 | expect(hintText).toBe(''); 31 | }); 32 | 33 | it('returns an empty string when a menu item is active', () => { 34 | const hintText = getHintText({ ...props, activeIndex: 0 }); 35 | expect(hintText).toBe(''); 36 | }); 37 | 38 | it('returns an empty string when there is a selection', () => { 39 | const hintText = getHintText({ ...props, selected: [states[0]] }); 40 | expect(hintText).toBe(''); 41 | }); 42 | 43 | it('returns an empty string when the menu is hidden', () => { 44 | const hintText = getHintText({ ...props, isMenuShown: false }); 45 | expect(hintText).toBe(''); 46 | }); 47 | 48 | it( 49 | 'returns an empty string when the initial item does not begin with the ' + 50 | 'input string', 51 | () => { 52 | const hintText = getHintText({ ...props, text: 'Cal' }); 53 | expect(hintText).toBe(''); 54 | } 55 | ); 56 | 57 | it('returns an empty string when the initial item is a custom option', () => { 58 | const initialItem = { ...props.initialItem, customOption: true }; 59 | const hintText = getHintText({ ...props, initialItem }); 60 | expect(hintText).toBe(''); 61 | }); 62 | 63 | it('returns an empty string when the initial item is disabled', () => { 64 | const initialItem = { ...props.initialItem, disabled: true }; 65 | const hintText = getHintText({ ...props, initialItem }); 66 | expect(hintText).toBe(''); 67 | }); 68 | 69 | it('returns the hint string when the initial item is not disabled', () => { 70 | const initialItem = { ...props.initialItem, disabled: false }; 71 | const hintText = getHintText({ ...props, initialItem }); 72 | expect(hintText).toBe('alAbama'); 73 | }); 74 | 75 | it('handles string with composed diacritical marks', () => { 76 | const hintText = getHintText({ 77 | ...props, 78 | initialItem: 'Schön ist, was schön lässt.', 79 | text: 'schon is', 80 | }); 81 | expect(hintText).toBe('schon ist, was schön lässt.'); 82 | }); 83 | 84 | it('handles string with combined diacritical marks', () => { 85 | const hintText = getHintText({ 86 | ...props, 87 | initialItem: 'Scho\u0308n ist, was scho\u0308n la\u0308sst.', 88 | text: 'schon is', 89 | }); 90 | expect(hintText).toBe('schon ist, was scho\u0308n la\u0308sst.'); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/utils/getHintText.ts: -------------------------------------------------------------------------------- 1 | import getMatchBounds from './getMatchBounds'; 2 | import getOptionLabel from './getOptionLabel'; 3 | import hasOwnProperty from './hasOwnProperty'; 4 | import { isString } from './nodash'; 5 | 6 | import { LabelKey, Option } from '../types'; 7 | 8 | interface HintProps { 9 | activeIndex: number; 10 | initialItem?: Option; 11 | isFocused: boolean; 12 | isMenuShown: boolean; 13 | labelKey: LabelKey; 14 | multiple: boolean; 15 | selected: Option[]; 16 | text: string; 17 | } 18 | 19 | function getHintText({ 20 | activeIndex, 21 | initialItem, 22 | isFocused, 23 | isMenuShown, 24 | labelKey, 25 | multiple, 26 | selected, 27 | text, 28 | }: HintProps) { 29 | // Don't display a hint under the following conditions: 30 | if ( 31 | // No text entered. 32 | !text || 33 | // The input is not focused. 34 | !isFocused || 35 | // The menu is hidden. 36 | !isMenuShown || 37 | // No item in the menu. 38 | !initialItem || 39 | // The initial item is a custom option. 40 | (!isString(initialItem) && hasOwnProperty(initialItem, 'customOption')) || 41 | // The initial item is disabled 42 | (!isString(initialItem) && initialItem.disabled) || 43 | // One of the menu items is active. 44 | activeIndex > -1 || 45 | // There's already a selection in single-select mode. 46 | (!!selected.length && !multiple) 47 | ) { 48 | return ''; 49 | } 50 | 51 | const initialItemStr = getOptionLabel(initialItem, labelKey); 52 | const bounds = getMatchBounds( 53 | initialItemStr.toLowerCase(), 54 | text.toLowerCase() 55 | ); 56 | 57 | if (!(bounds && bounds.start === 0)) { 58 | return ''; 59 | } 60 | 61 | // Text matching is case- and accent-insensitive, so to display the hint 62 | // correctly, splice the input string with the hint string. 63 | return text + initialItemStr.slice(bounds.end, initialItemStr.length); 64 | } 65 | 66 | export default getHintText; 67 | -------------------------------------------------------------------------------- /src/utils/getInputProps.test.ts: -------------------------------------------------------------------------------- 1 | import getInputProps from './getInputProps'; 2 | import { noop } from '../tests/helpers'; 3 | 4 | const baseProps = { 5 | activeIndex: -1, 6 | id: 'id', 7 | isFocused: false, 8 | isMenuShown: false, 9 | multiple: false, 10 | onClick: noop, 11 | onFocus: noop, 12 | }; 13 | 14 | const baseMultiProps = { 15 | ...baseProps, 16 | multiple: true, 17 | onRemove: noop, 18 | }; 19 | 20 | const baseReceivedProps = { 21 | 'aria-activedescendant': undefined, 22 | 'aria-autocomplete': 'both', 23 | 'aria-expanded': false, 24 | 'aria-haspopup': 'listbox', 25 | 'aria-multiselectable': undefined, 26 | 'aria-owns': undefined, 27 | autoComplete: 'off', 28 | className: '', 29 | inputClassName: undefined, 30 | onClick: noop, 31 | onFocus: noop, 32 | placeholder: undefined, 33 | role: 'combobox', 34 | type: 'text', 35 | }; 36 | 37 | const baseReceivedMultiProps = { 38 | ...baseReceivedProps, 39 | 'aria-multiselectable': true, 40 | onRemove: noop, 41 | }; 42 | 43 | describe('getInputProps', () => { 44 | it('receives single-select input props', () => { 45 | let inputProps = getInputProps(baseProps)(); 46 | 47 | expect(inputProps).toEqual(baseReceivedProps); 48 | 49 | inputProps = getInputProps({ 50 | ...baseProps, 51 | activeIndex: 0, 52 | isFocused: true, 53 | isMenuShown: true, 54 | })({ 55 | className: 'foo', 56 | }); 57 | 58 | expect(inputProps).toEqual({ 59 | ...baseReceivedProps, 60 | 'aria-activedescendant': 'id-item-0', 61 | 'aria-expanded': true, 62 | 'aria-owns': 'id', 63 | className: 'foo focus', 64 | }); 65 | }); 66 | 67 | it('receives multi-select input props', () => { 68 | let inputProps = getInputProps(baseMultiProps)(); 69 | 70 | expect(inputProps).toEqual(baseReceivedMultiProps); 71 | 72 | inputProps = getInputProps({ 73 | ...baseMultiProps, 74 | isFocused: true, 75 | })({ 76 | className: 'foo', 77 | }); 78 | 79 | expect(inputProps).toEqual({ 80 | ...baseReceivedMultiProps, 81 | className: 'focus', 82 | inputClassName: 'foo', 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/utils/getInputProps.ts: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | 3 | import getMenuItemId from './getMenuItemId'; 4 | import hasOwnProperty from './hasOwnProperty'; 5 | import { TypeaheadManagerProps } from '../types'; 6 | 7 | type Args = Pick< 8 | TypeaheadManagerProps, 9 | | 'activeIndex' 10 | | 'id' 11 | | 'isFocused' 12 | | 'isMenuShown' 13 | | 'multiple' 14 | | 'onClick' 15 | | 'onFocus' 16 | | 'placeholder' 17 | >; 18 | 19 | const getInputProps = 20 | ({ 21 | activeIndex, 22 | id, 23 | isFocused, 24 | isMenuShown, 25 | multiple, 26 | onClick, 27 | onFocus, 28 | placeholder, 29 | ...props 30 | }: Args) => 31 | (inputProps = {}) => { 32 | const className = hasOwnProperty(inputProps, 'className') 33 | ? String(inputProps.className) 34 | : undefined; 35 | 36 | return { 37 | // These props can be overridden by values in `inputProps`. 38 | autoComplete: 'off', 39 | placeholder, 40 | type: 'text', 41 | 42 | ...inputProps, 43 | ...props, 44 | 'aria-activedescendant': 45 | activeIndex >= 0 ? getMenuItemId(id, activeIndex) : undefined, 46 | 'aria-autocomplete': 'both', 47 | 'aria-expanded': isMenuShown, 48 | 'aria-haspopup': 'listbox', 49 | 'aria-multiselectable': multiple || undefined, 50 | 'aria-owns': isMenuShown ? id : undefined, 51 | className: cx({ 52 | [className || '']: !multiple, 53 | focus: isFocused, 54 | }), 55 | ...(multiple && { inputClassName: className }), 56 | onClick, 57 | onFocus, 58 | role: 'combobox', 59 | }; 60 | }; 61 | 62 | export default getInputProps; 63 | -------------------------------------------------------------------------------- /src/utils/getInputText.test.ts: -------------------------------------------------------------------------------- 1 | import getInputText from './getInputText'; 2 | import options from '../tests/data'; 3 | 4 | const labelKey = 'name'; 5 | const baseArgs = { 6 | activeItem: undefined, 7 | labelKey, 8 | multiple: false, 9 | selected: [], 10 | text: '', 11 | }; 12 | 13 | describe('getInputText', () => { 14 | it('returns an empty string when no text is entered', () => { 15 | const inputText = getInputText(baseArgs); 16 | expect(inputText).toBe(''); 17 | }); 18 | 19 | it('returns the input text in multiple mode', () => { 20 | const text = 'Cali'; 21 | const inputText = getInputText({ 22 | ...baseArgs, 23 | multiple: true, 24 | text, 25 | }); 26 | 27 | expect(inputText).toBe(text); 28 | }); 29 | 30 | it('returns the active option label in single-select mode', () => { 31 | const name = 'California'; 32 | const inputText = getInputText({ 33 | ...baseArgs, 34 | activeItem: { name }, 35 | }); 36 | 37 | expect(inputText).toBe(name); 38 | }); 39 | 40 | it('returns the active option label in multi-select mode', () => { 41 | const name = 'California'; 42 | const inputText = getInputText({ 43 | ...baseArgs, 44 | activeItem: { name }, 45 | multiple: true, 46 | }); 47 | 48 | expect(inputText).toBe(name); 49 | }); 50 | 51 | it('returns the selected item label in single-select mode', () => { 52 | const selected = options.slice(0, 1); 53 | const inputText = getInputText({ ...baseArgs, selected }); 54 | expect(inputText).toBe(selected[0][labelKey]); 55 | }); 56 | 57 | it('does not return the selected item label in multi-select mode', () => { 58 | const inputText = getInputText({ 59 | ...baseArgs, 60 | multiple: true, 61 | selected: options.slice(0, 1), 62 | }); 63 | 64 | expect(inputText).toBe(''); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/utils/getInputText.ts: -------------------------------------------------------------------------------- 1 | import getOptionLabel from './getOptionLabel'; 2 | import { LabelKey, Option } from '../types'; 3 | 4 | interface Props { 5 | activeItem?: Option; 6 | labelKey: LabelKey; 7 | multiple: boolean; 8 | selected: Option[]; 9 | text: string; 10 | } 11 | 12 | function getInputText(props: Props): string { 13 | const { activeItem, labelKey, multiple, selected, text } = props; 14 | 15 | if (activeItem) { 16 | // Display the input value if the pagination item is active. 17 | return getOptionLabel(activeItem, labelKey); 18 | } 19 | 20 | if (!multiple && selected.length && selected[0]) { 21 | return getOptionLabel(selected[0], labelKey); 22 | } 23 | 24 | return text; 25 | } 26 | 27 | export default getInputText; 28 | -------------------------------------------------------------------------------- /src/utils/getIsOnlyResult.test.ts: -------------------------------------------------------------------------------- 1 | import getIsOnlyResult from './getIsOnlyResult'; 2 | 3 | const props = { 4 | allowNew: false, 5 | highlightOnlyResult: true, 6 | results: ['The only result!'], 7 | }; 8 | 9 | describe('getIsOnlyResult', () => { 10 | it('returns true when there is only one result', () => { 11 | expect(getIsOnlyResult(props)).toBe(true); 12 | }); 13 | 14 | it('returns false when `highlightOnlyResult` is disabled', () => { 15 | props.highlightOnlyResult = false; 16 | expect(getIsOnlyResult(props)).toBe(false); 17 | }); 18 | 19 | it('returns false when custom options are allowed', () => { 20 | props.allowNew = true; 21 | expect(getIsOnlyResult(props)).toBe(false); 22 | }); 23 | 24 | it('returns false when there are more or less than one result', () => { 25 | props.results = ['One', 'Two']; 26 | expect(getIsOnlyResult(props)).toBe(false); 27 | 28 | props.results = []; 29 | expect(getIsOnlyResult(props)).toBe(false); 30 | }); 31 | 32 | it('returns false when the only result is disabled', () => { 33 | props.results = [{ disabled: true }]; 34 | expect(getIsOnlyResult(props)).toBe(false); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/utils/getIsOnlyResult.ts: -------------------------------------------------------------------------------- 1 | import getOptionProperty from './getOptionProperty'; 2 | 3 | import { AllowNew, Option } from '../types'; 4 | 5 | interface Props { 6 | allowNew: AllowNew; 7 | highlightOnlyResult: boolean; 8 | results: Option[]; 9 | } 10 | 11 | function getIsOnlyResult(props: Props): boolean { 12 | const { allowNew, highlightOnlyResult, results } = props; 13 | 14 | if (!highlightOnlyResult || allowNew) { 15 | return false; 16 | } 17 | 18 | return results.length === 1 && !getOptionProperty(results[0], 'disabled'); 19 | } 20 | 21 | export default getIsOnlyResult; 22 | -------------------------------------------------------------------------------- /src/utils/getMatchBounds.test.ts: -------------------------------------------------------------------------------- 1 | import getMatchBounds, { escapeStringRegexp } from './getMatchBounds'; 2 | 3 | describe('getMatchBounds', () => { 4 | it('handles a normal string', () => { 5 | const bounds = getMatchBounds('This is a string.', 'This is'); 6 | 7 | expect(bounds?.start).toBe(0); 8 | expect(bounds?.end).toBe(7); 9 | }); 10 | 11 | it('returns null when there is no match', () => { 12 | expect(getMatchBounds('foo', 'bar')).toBe(null); 13 | }); 14 | 15 | it('is case-insensitive', () => { 16 | const bounds = getMatchBounds('This String Has Caps.', 'string has'); 17 | 18 | expect(bounds?.start).toBe(5); 19 | expect(bounds?.end).toBe(15); 20 | }); 21 | 22 | it('handles diacritical marks in the search string', () => { 23 | const bounds = getMatchBounds('Schön ist, was schön lässt.', 'schö'); 24 | 25 | expect(bounds?.start).toBe(0); 26 | expect(bounds?.end).toBe(4); 27 | }); 28 | 29 | it('matches composed diacritical marks', () => { 30 | const bounds = getMatchBounds('Schön ist, was schön lässt.', 'was schon'); 31 | 32 | expect(bounds?.start).toBe(11); 33 | expect(bounds?.end).toBe(20); 34 | }); 35 | 36 | it('matches combined diacritical marks', () => { 37 | const bounds = getMatchBounds( 38 | 'Scho\u0308n ist, was scho\u0308n la\u0308sst.', 39 | 'was schon' 40 | ); 41 | 42 | expect(bounds?.start).toBe(12); 43 | expect(bounds?.end).toBe(22); 44 | }); 45 | }); 46 | 47 | describe('escapeStringRegexp', () => { 48 | it('tests string escaping', () => { 49 | expect(escapeStringRegexp('\\ ^ $ * + ? . ( ) | { } [ ]')).toBe( 50 | '\\\\ \\^ \\$ \\* \\+ \\? \\. \\( \\) \\| \\{ \\} \\[ \\]' 51 | ); 52 | }); 53 | 54 | it('escapes `-` in a way compatible with PCRE', () => { 55 | expect(escapeStringRegexp('foo - bar')).toBe('foo \\x2d bar'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/utils/getMatchBounds.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import stripDiacritics from './stripDiacritics'; 3 | 4 | const CASE_INSENSITIVE = 'i'; 5 | const COMBINING_MARKS = /[\u0300-\u036F]/; 6 | 7 | interface MatchBounds { 8 | end: number; 9 | start: number; 10 | } 11 | 12 | // Export for testing. 13 | export function escapeStringRegexp(str: string): string { 14 | invariant(typeof str === 'string', '`escapeStringRegexp` expected a string.'); 15 | 16 | // Escape characters with special meaning either inside or outside character 17 | // sets. Use a simple backslash escape when it’s always valid, and a \unnnn 18 | // escape when the simpler form would be disallowed by Unicode patterns’ 19 | // stricter grammar. 20 | return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); 21 | } 22 | 23 | export default function getMatchBounds( 24 | subject: string, 25 | str: string 26 | ): MatchBounds | null { 27 | const search = new RegExp( 28 | escapeStringRegexp(stripDiacritics(str)), 29 | CASE_INSENSITIVE 30 | ); 31 | 32 | const matches = search.exec(stripDiacritics(subject)); 33 | 34 | if (!matches) { 35 | return null; 36 | } 37 | 38 | let start = matches.index; 39 | let matchLength = matches[0].length; 40 | 41 | // Account for combining marks, which changes the indices. 42 | if (COMBINING_MARKS.test(subject)) { 43 | // Starting at the beginning of the subject string, check for the number of 44 | // combining marks and increment the start index whenever one is found. 45 | for (let ii = 0; ii <= start; ii++) { 46 | if (COMBINING_MARKS.test(subject[ii])) { 47 | start += 1; 48 | } 49 | } 50 | 51 | // Similarly, increment the length of the match string if it contains a 52 | // combining mark. 53 | for (let ii = start; ii <= start + matchLength; ii++) { 54 | if (COMBINING_MARKS.test(subject[ii])) { 55 | matchLength += 1; 56 | } 57 | } 58 | } 59 | 60 | return { 61 | end: start + matchLength, 62 | start, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/getMenuItemId.test.ts: -------------------------------------------------------------------------------- 1 | import getMenuItemId from './getMenuItemId'; 2 | 3 | describe('getMenuItemId', () => { 4 | it('generates an id', () => { 5 | expect(getMenuItemId('menu-id', 0)).toBe('menu-id-item-0'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/utils/getMenuItemId.ts: -------------------------------------------------------------------------------- 1 | import { Id } from '../types'; 2 | 3 | export default function getMenuItemId(id: Id = '', position: number): string { 4 | return `${id}-item-${position}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/getOptionLabel.test.ts: -------------------------------------------------------------------------------- 1 | import getOptionLabel from './getOptionLabel'; 2 | import getStringLabelKey from './getStringLabelKey'; 3 | 4 | import { Option } from '../types'; 5 | 6 | const name = 'California'; 7 | const option = { name }; 8 | const labelKeyFn = (o: Option) => o.name; 9 | 10 | describe('getOptionLabel', () => { 11 | it('returns a string when it receives a string `option` value', () => { 12 | const optionLabel = getOptionLabel(name, ''); 13 | expect(optionLabel).toBe(name); 14 | }); 15 | 16 | it('returns a string when it receives a `labelKey` function', () => { 17 | const optionLabel = getOptionLabel(option, labelKeyFn); 18 | expect(optionLabel).toBe(name); 19 | }); 20 | 21 | it('returns a string when it receives a `labelKey` string', () => { 22 | const optionLabel = getOptionLabel(option, 'name'); 23 | expect(optionLabel).toBe(name); 24 | }); 25 | 26 | it('handles custom and pagination options', () => { 27 | const customOption = { 28 | [getStringLabelKey(labelKeyFn)]: 'foo', 29 | customOption: true, 30 | }; 31 | 32 | const paginationOption = { 33 | [getStringLabelKey(labelKeyFn)]: 'bar', 34 | paginationOption: true, 35 | }; 36 | 37 | expect(getOptionLabel(customOption, labelKeyFn)).toBe('foo'); 38 | expect(getOptionLabel(paginationOption, labelKeyFn)).toBe('bar'); 39 | }); 40 | 41 | it('gives precedence to `labelKey` when it is a function', () => { 42 | const customLabel = 'Custom Label'; 43 | const optionLabel = getOptionLabel(name, () => customLabel); 44 | expect(optionLabel).toBe(customLabel); 45 | }); 46 | 47 | it('throws an error when an invalid option is encountered', () => { 48 | const willThrow = () => getOptionLabel([], 'name'); 49 | expect(willThrow).toThrowError(Error); 50 | }); 51 | 52 | it('throws an error when `option` is an object and no labelkey is specified', () => { 53 | // @ts-expect-error 54 | const willThrow = () => getOptionLabel(option); 55 | expect(willThrow).toThrowError(Error); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/utils/getOptionLabel.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | 3 | import getStringLabelKey from './getStringLabelKey'; 4 | import hasOwnProperty from './hasOwnProperty'; 5 | import { isFunction, isString } from './nodash'; 6 | 7 | import type { LabelKey, Option } from '../types'; 8 | 9 | /** 10 | * Retrieves the display string from an option. Options can be the string 11 | * themselves, or an object with a defined display string. Anything else throws 12 | * an error. 13 | */ 14 | function getOptionLabel(option: Option, labelKey: LabelKey): string { 15 | // Handle internally created options first. 16 | if ( 17 | !isString(option) && 18 | (hasOwnProperty(option, 'paginationOption') || 19 | hasOwnProperty(option, 'customOption')) 20 | ) { 21 | return option[getStringLabelKey(labelKey)] as string; 22 | } 23 | 24 | let optionLabel: string; 25 | 26 | if (isFunction(labelKey)) { 27 | optionLabel = labelKey(option); 28 | } else if (isString(option)) { 29 | optionLabel = option; 30 | } else { 31 | // `option` is an object and `labelKey` is a string. 32 | optionLabel = option[labelKey]; 33 | } 34 | 35 | invariant( 36 | isString(optionLabel), 37 | 'One or more options does not have a valid label string. Check the ' + 38 | '`labelKey` prop to ensure that it matches the correct option key and ' + 39 | 'provides a string for filtering and display.' 40 | ); 41 | 42 | return optionLabel; 43 | } 44 | 45 | export default getOptionLabel; 46 | -------------------------------------------------------------------------------- /src/utils/getOptionProperty.test.ts: -------------------------------------------------------------------------------- 1 | import getOptionProperty from './getOptionProperty'; 2 | 3 | describe('getOptionProperty', () => { 4 | it('retrieves the property from the option', () => { 5 | expect(getOptionProperty({ foo: 'bar' }, 'foo')).toBe('bar'); 6 | expect(getOptionProperty({}, 'foo')).toBeUndefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/getOptionProperty.ts: -------------------------------------------------------------------------------- 1 | import { isString } from './nodash'; 2 | import { Option } from '../types'; 3 | 4 | export default function getOptionProperty(option: Option, key: string) { 5 | if (isString(option)) { 6 | return undefined; 7 | } 8 | 9 | return option[key]; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/getStringLabelKey.test.ts: -------------------------------------------------------------------------------- 1 | import getStringLabelKey from './getStringLabelKey'; 2 | 3 | describe('getStringLabelKey', () => { 4 | it('returns the specified string labelKey', () => { 5 | expect(getStringLabelKey('name')).toBe('name'); 6 | }); 7 | 8 | it('returns the default labelKey when `labelKey` is a function', () => { 9 | expect(getStringLabelKey((o) => o.name)).toBe('label'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/getStringLabelKey.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LABELKEY } from '../constants'; 2 | import type { LabelKey } from '../types'; 3 | 4 | export default function getStringLabelKey(labelKey: LabelKey): string { 5 | return typeof labelKey === 'string' ? labelKey : DEFAULT_LABELKEY; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getTruncatedOptions.test.ts: -------------------------------------------------------------------------------- 1 | import getTruncatedOptions from './getTruncatedOptions'; 2 | import options from '../tests/data'; 3 | 4 | describe('getTruncatedOptions', () => { 5 | it('truncates the results', () => { 6 | const maxResults = 10; 7 | const truncatedResults = getTruncatedOptions(options, maxResults); 8 | 9 | expect(truncatedResults.length).toBe(maxResults); 10 | }); 11 | 12 | it('does not truncate the results if the threshold is not met', () => { 13 | const maxResults = 100; 14 | const truncatedResults = getTruncatedOptions(options, maxResults); 15 | 16 | expect(truncatedResults.length).toBe(options.length); 17 | }); 18 | 19 | it('does not truncate the results if `maxResults=0`', () => { 20 | const maxResults = 0; 21 | const truncatedResults = getTruncatedOptions(options, maxResults); 22 | 23 | expect(truncatedResults.length).toBe(options.length); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/utils/getTruncatedOptions.ts: -------------------------------------------------------------------------------- 1 | import { Option } from '../types'; 2 | 3 | /** 4 | * Truncates the result set based on `maxResults` and returns the new set. 5 | */ 6 | function getTruncatedOptions(options: Option[], maxResults: number): Option[] { 7 | if (!maxResults || maxResults >= options.length) { 8 | return options; 9 | } 10 | 11 | return options.slice(0, maxResults); 12 | } 13 | 14 | export default getTruncatedOptions; 15 | -------------------------------------------------------------------------------- /src/utils/getUpdatedActiveIndex.test.ts: -------------------------------------------------------------------------------- 1 | import getUpdatedActiveIndex, { 2 | isDisabledOption, 3 | skipDisabledOptions, 4 | } from './getUpdatedActiveIndex'; 5 | 6 | const options = [ 7 | { name: 'foo' }, 8 | { disabled: true, name: 'bar' }, 9 | { disabled: true, name: 'boo' }, 10 | { disabled: false, name: 'baz' }, 11 | { disabled: undefined, name: 'bja' }, 12 | ]; 13 | 14 | const stringOptions = ['foo', 'bar', 'baz']; 15 | 16 | test('getUpdatedActiveIndex', () => { 17 | expect(getUpdatedActiveIndex(-1, 'ArrowDown', options)).toBe(0); 18 | expect(getUpdatedActiveIndex(0, 'ArrowDown', options)).toBe(3); 19 | expect(getUpdatedActiveIndex(3, 'ArrowDown', options)).toBe(4); 20 | expect(getUpdatedActiveIndex(4, 'ArrowDown', options)).toBe(-1); 21 | 22 | expect(getUpdatedActiveIndex(-1, 'ArrowUp', options)).toBe(4); 23 | expect(getUpdatedActiveIndex(4, 'ArrowUp', options)).toBe(3); 24 | expect(getUpdatedActiveIndex(3, 'ArrowUp', options)).toBe(0); 25 | expect(getUpdatedActiveIndex(0, 'ArrowUp', options)).toBe(-1); 26 | }); 27 | 28 | test('skipDisabledOptions', () => { 29 | expect(skipDisabledOptions(0, 'ArrowDown', options)).toBe(0); 30 | expect(skipDisabledOptions(0, 'ArrowUp', options)).toBe(0); 31 | 32 | expect(skipDisabledOptions(1, 'ArrowDown', options)).toBe(3); 33 | expect(skipDisabledOptions(1, 'ArrowUp', options)).toBe(0); 34 | 35 | expect(skipDisabledOptions(1, 'ArrowDown', stringOptions)).toBe(1); 36 | expect(skipDisabledOptions(1, 'ArrowUp', stringOptions)).toBe(1); 37 | }); 38 | 39 | test('isDisabledOption', () => { 40 | expect(isDisabledOption(0, options)).toBe(false); 41 | expect(isDisabledOption(1, options)).toBe(true); 42 | expect(isDisabledOption(3, options)).toBe(false); 43 | expect(isDisabledOption(4, options)).toBe(false); 44 | expect(isDisabledOption(6, options)).toBe(false); 45 | expect(isDisabledOption(0, stringOptions)).toBe(false); 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/getUpdatedActiveIndex.ts: -------------------------------------------------------------------------------- 1 | import type { Option } from '../types'; 2 | import getOptionProperty from './getOptionProperty'; 3 | 4 | type Key = 'ArrowDown' | 'ArrowUp'; 5 | 6 | export function isDisabledOption(index: number, items: Option[]): boolean { 7 | const option = items[index]; 8 | return !!option && !!getOptionProperty(option, 'disabled'); 9 | } 10 | 11 | export function skipDisabledOptions( 12 | currentIndex: number, 13 | key: Key, 14 | items: Option[] 15 | ): number { 16 | let newIndex = currentIndex; 17 | 18 | while (isDisabledOption(newIndex, items)) { 19 | newIndex += key === 'ArrowUp' ? -1 : 1; 20 | } 21 | 22 | return newIndex; 23 | } 24 | 25 | export default function getUpdatedActiveIndex( 26 | currentIndex: number, 27 | key: Key, 28 | items: Option[] 29 | ): number { 30 | let newIndex = currentIndex; 31 | 32 | // Increment or decrement index based on user keystroke. 33 | newIndex += key === 'ArrowUp' ? -1 : 1; 34 | 35 | // Skip over any disabled options. 36 | newIndex = skipDisabledOptions(newIndex, key, items); 37 | 38 | // If we've reached the end, go back to the beginning or vice-versa. 39 | if (newIndex === items.length) { 40 | newIndex = -1; 41 | } else if (newIndex === -2) { 42 | newIndex = items.length - 1; 43 | 44 | // Skip over any disabled options. 45 | newIndex = skipDisabledOptions(newIndex, key, items); 46 | } 47 | 48 | return newIndex; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/hasOwnProperty.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if an object has the given property in a type-safe way. 3 | */ 4 | export default function hasOwnProperty< 5 | X extends Record, 6 | Y extends PropertyKey, 7 | >(obj: X, prop: Y): obj is X & Record { 8 | return Object.prototype.hasOwnProperty.call(obj, prop); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as addCustomOption } from './addCustomOption'; 2 | export { default as defaultFilterBy } from './defaultFilterBy'; 3 | export { default as defaultSelectHint } from './defaultSelectHint'; 4 | export { default as getDisplayName } from './getDisplayName'; 5 | export { default as getHintText } from './getHintText'; 6 | export { default as getInputProps } from './getInputProps'; 7 | export { default as getInputText } from './getInputText'; 8 | export { default as getIsOnlyResult } from './getIsOnlyResult'; 9 | export { default as getMatchBounds } from './getMatchBounds'; 10 | export * from './getMatchBounds'; 11 | export { default as getMenuItemId } from './getMenuItemId'; 12 | export { default as getOptionLabel } from './getOptionLabel'; 13 | export { default as getOptionProperty } from './getOptionProperty'; 14 | export { default as getStringLabelKey } from './getStringLabelKey'; 15 | export { default as getTruncatedOptions } from './getTruncatedOptions'; 16 | export { default as getUpdatedActiveIndex } from './getUpdatedActiveIndex'; 17 | export { default as hasOwnProperty } from './hasOwnProperty'; 18 | export { default as isSelectable } from './isSelectable'; 19 | export { default as isShown } from './isShown'; 20 | export * from './nodash'; 21 | export { default as preventInputBlur } from './preventInputBlur'; 22 | export { default as propsWithBsClassName } from './propsWithBsClassName'; 23 | export * from './size'; 24 | export { default as stripDiacritics } from './stripDiacritics'; 25 | export { default as validateSelectedPropChange } from './validateSelectedPropChange'; 26 | export { default as warn } from './warn'; 27 | -------------------------------------------------------------------------------- /src/utils/isSelectable.test.ts: -------------------------------------------------------------------------------- 1 | import isSelectable from './isSelectable'; 2 | 3 | describe('isSelectable', () => { 4 | it('identifies selectable elements', () => { 5 | const input = document.createElement('input'); 6 | 7 | input.setAttribute('type', 'text'); 8 | expect(isSelectable(input)).toBe(true); 9 | 10 | input.setAttribute('type', 'search'); 11 | expect(isSelectable(input)).toBe(true); 12 | 13 | input.setAttribute('type', 'password'); 14 | expect(isSelectable(input)).toBe(true); 15 | 16 | input.setAttribute('type', 'tel'); 17 | expect(isSelectable(input)).toBe(true); 18 | 19 | input.setAttribute('type', 'url'); 20 | expect(isSelectable(input)).toBe(true); 21 | 22 | const textarea = document.createElement('textarea'); 23 | 24 | // Must explicitly set selection range for `selectionStart` to have a value. 25 | textarea.setSelectionRange(0, 0); 26 | 27 | expect(isSelectable(textarea)).toBe(true); 28 | }); 29 | 30 | it('identifies non-selectable inputs', () => { 31 | const input = document.createElement('input'); 32 | 33 | input.setAttribute('type', 'email'); 34 | expect(isSelectable(input)).toBe(false); 35 | 36 | input.setAttribute('type', 'number'); 37 | expect(isSelectable(input)).toBe(false); 38 | 39 | const div = document.createElement('div'); 40 | // @ts-expect-error 41 | expect(isSelectable(div)).toBe(false); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/isSelectable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if an input type is selectable, based on WHATWG spec. 3 | * 4 | * See: 5 | * - https://stackoverflow.com/questions/21177489/selectionstart-selectionend-on-input-type-number-no-longer-allowed-in-chrome/24175357 6 | * - https://html.spec.whatwg.org/multipage/input.html#do-not-apply 7 | */ 8 | export default function isSelectable( 9 | inputNode: HTMLInputElement | HTMLTextAreaElement 10 | ): boolean { 11 | return inputNode.selectionStart != null; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/isShown.test.ts: -------------------------------------------------------------------------------- 1 | import isShown from './isShown'; 2 | 3 | const baseProps = { 4 | minLength: 0, 5 | showMenu: false, 6 | text: '', 7 | }; 8 | 9 | describe('isShown', () => { 10 | it('shows the menu', () => { 11 | expect(isShown({ ...baseProps, showMenu: true })).toBe(true); 12 | }); 13 | 14 | it('shows the menu when `open` is true', () => { 15 | expect(isShown({ ...baseProps, open: true })).toBe(true); 16 | }); 17 | 18 | it('hides the menu when `open` is false', () => { 19 | const props = { 20 | ...baseProps, 21 | open: false, 22 | showMenu: true, 23 | }; 24 | 25 | expect(isShown(props)).toBe(false); 26 | }); 27 | 28 | it('hides the menu when `showMenu` is false', () => { 29 | expect(isShown(baseProps)).toBe(false); 30 | }); 31 | 32 | it('hides the menu when the input value is less than `minLength`', () => { 33 | const props = { 34 | ...baseProps, 35 | minLength: 1, 36 | showMenu: true, 37 | }; 38 | 39 | expect(isShown(props)).toBe(false); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/isShown.ts: -------------------------------------------------------------------------------- 1 | interface Props { 2 | open?: boolean; 3 | minLength: number; 4 | showMenu: boolean; 5 | text: string; 6 | } 7 | 8 | export default function isShown({ 9 | open, 10 | minLength, 11 | showMenu, 12 | text, 13 | }: Props): boolean { 14 | // If menu visibility is controlled via props, that value takes precedence. 15 | if (open || open === false) { 16 | return open; 17 | } 18 | 19 | if (text.length < minLength) { 20 | return false; 21 | } 22 | 23 | return showMenu; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/nodash.test.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isString, noop, pick, uniqueId } from './nodash'; 2 | 3 | const arr: string[] = []; 4 | const fn = noop; 5 | const obj = {}; 6 | const str = 'foo'; 7 | 8 | describe('nodash', () => { 9 | test('isFunction', () => { 10 | expect(isFunction(fn)).toBe(true); 11 | [arr, obj, str, null, undefined, NaN].forEach((arg) => { 12 | expect(isFunction(arg)).toBe(false); 13 | }); 14 | }); 15 | 16 | test('isString', () => { 17 | expect(isString(str)).toBe(true); 18 | [arr, obj, fn, null, undefined, NaN].forEach((arg) => { 19 | expect(isString(arg)).toBe(false); 20 | }); 21 | }); 22 | 23 | test('pick', () => { 24 | const object = { 25 | bar: 'one', 26 | foo: 'two', 27 | }; 28 | 29 | expect(pick(object, ['bar'])).toEqual({ bar: 'one' }); 30 | // @ts-expect-error 31 | expect(pick(object, ['baz'])).toEqual({}); 32 | }); 33 | 34 | test('uniqueId', () => { 35 | expect(uniqueId()).toBe('1'); 36 | expect(uniqueId('foo-')).toBe('foo-2'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/nodash.ts: -------------------------------------------------------------------------------- 1 | let idCounter = 0; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-types 4 | export function isFunction(value: unknown): value is Function { 5 | return typeof value === 'function'; 6 | } 7 | 8 | export function isString(value: unknown): value is string { 9 | return typeof value === 'string'; 10 | } 11 | 12 | export function noop(): void {} 13 | 14 | export function pick(obj: T, keys: K[]) { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | const result: any = {}; 17 | keys.forEach((key) => { 18 | result[key] = obj[key]; 19 | }); 20 | return result; 21 | } 22 | 23 | export function uniqueId(prefix?: string): string { 24 | idCounter += 1; 25 | return (prefix == null ? '' : String(prefix)) + idCounter; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/preventInputBlur.test.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | import preventInputBlur from './preventInputBlur'; 3 | 4 | const mouseEvent = { 5 | preventDefault: jest.fn(), 6 | } as unknown as MouseEvent; 7 | 8 | describe('preventInputBlur', () => { 9 | it('calls `preventDefault` on the event', () => { 10 | preventInputBlur(mouseEvent); 11 | expect(mouseEvent.preventDefault).toHaveBeenCalled(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/preventInputBlur.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | 3 | /** 4 | * Prevent the main input from blurring when a menu item or the clear button is 5 | * clicked. (#226 & #310) 6 | */ 7 | export default function preventInputBlur(e: MouseEvent): void { 8 | e.preventDefault(); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/propsWithBsClassName.test.ts: -------------------------------------------------------------------------------- 1 | import propsWithBsClassName from './propsWithBsClassName'; 2 | 3 | describe('propsWithBsClassName', () => { 4 | it('returns a basic classname', () => { 5 | const { className } = propsWithBsClassName({}); 6 | expect(className).toBe('form-control rbt-input'); 7 | }); 8 | 9 | it('includes a classname for a large input', () => { 10 | const { className } = propsWithBsClassName({ size: 'lg' }); 11 | expect(className).toContain('form-control-lg'); 12 | }); 13 | 14 | it('includes a classname for a small input', () => { 15 | const { className } = propsWithBsClassName({ size: 'sm' }); 16 | expect(className).toContain('form-control-sm'); 17 | }); 18 | 19 | it('includes a classname for an invalid input', () => { 20 | const { className } = propsWithBsClassName({ isInvalid: true }); 21 | expect(className).toContain('is-invalid'); 22 | }); 23 | 24 | it('includes a classname for a valid input', () => { 25 | const { className } = propsWithBsClassName({ isValid: true }); 26 | expect(className).toContain('is-valid'); 27 | }); 28 | 29 | it('includes an arbitrary classname', () => { 30 | const { className } = propsWithBsClassName({ className: 'foo' }); 31 | expect(className).toContain('foo'); 32 | }); 33 | 34 | it('returns pass-through props', () => { 35 | const { foo } = propsWithBsClassName({ foo: 'bar' }); 36 | expect(foo).toBe('bar'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/propsWithBsClassName.ts: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | 3 | import { isSizeLarge, isSizeSmall } from './size'; 4 | import type { Size } from '../types'; 5 | 6 | interface Props { 7 | className?: string; 8 | isInvalid?: boolean; 9 | isValid?: boolean; 10 | size?: Size; 11 | } 12 | 13 | /** 14 | * Returns Bootstrap classnames from `size` and validation props, along 15 | * with pass-through props. 16 | */ 17 | export default function propsWithBsClassName({ 18 | className, 19 | isInvalid, 20 | isValid, 21 | size, 22 | ...props 23 | }: Props & T) { 24 | return { 25 | ...props, 26 | className: cx( 27 | 'form-control', 28 | 'rbt-input', 29 | { 30 | 'form-control-lg': isSizeLarge(size), 31 | 'form-control-sm': isSizeSmall(size), 32 | 'is-invalid': isInvalid, 33 | 'is-valid': isValid, 34 | }, 35 | className 36 | ), 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/size.test.ts: -------------------------------------------------------------------------------- 1 | import { isSizeLarge, isSizeSmall } from './size'; 2 | 3 | describe('size', () => { 4 | it('tests `isSizeLarge` behavior', () => { 5 | expect(isSizeLarge('lg')).toBe(true); 6 | 7 | expect(isSizeLarge()).toBe(false); 8 | // @ts-expect-error 9 | expect(isSizeLarge(null)).toBe(false); 10 | // @ts-expect-error 11 | expect(isSizeLarge('lrg')).toBe(false); 12 | }); 13 | 14 | it('tests `isSizeSmall` behavior', () => { 15 | expect(isSizeSmall('sm')).toBe(true); 16 | 17 | expect(isSizeSmall()).toBe(false); 18 | // @ts-expect-error 19 | expect(isSizeSmall(null)).toBe(false); 20 | // @ts-expect-error 21 | expect(isSizeSmall('sml')).toBe(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/size.ts: -------------------------------------------------------------------------------- 1 | import type { Size } from '../types'; 2 | 3 | export function isSizeLarge(size?: Size): boolean { 4 | return size === 'lg'; 5 | } 6 | 7 | export function isSizeSmall(size?: Size): boolean { 8 | return size === 'sm'; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/stripDiacritics.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { range } from 'lodash'; 3 | 4 | import stripDiacritics from './stripDiacritics'; 5 | 6 | describe('stripDiacritics', () => { 7 | it('removes accents and other diacritical marks from a string', () => { 8 | const string = 9 | 'ÆÐƎƐŒẞæǝɛœſߥƁÇĐƊĘĦĮƘŁØƠŞȘŢȚŦŲƯƳąɓçđɗęħįƙłøơşșţțŧųưƴÁÀÂÄǍĂĀÃÅǺĄÆǼǢƁĆĊĈČÇĎḌĐƊÐÉÈĖÊËĚĔĒĘẸƎƐĠĜǦĞĢáàâäǎăāãåǻąæǽǣɓćċĉčçďḍđɗéèėêëěĕēęẹġĝǧğģĤḤĦIÍÌİÎÏǏĬĪĨĮỊĴĶƘĹĻŁĽĿNŃŇÑŅÓÒÔÖǑŎŌÕŐỌØǾƠŒĥḥħıíìiîïǐĭīĩįịĵķƙĸĺļłľŀʼnńňñņóòôöǒŏōõőọøǿơœŔŘŖŚŜŠŞȘṢẞŤŢṬŦÚÙÛÜǓŬŪŨŰŮŲỤƯẂẀŴẄÝỲŶŸȲỸƳŹŻŽẒŕřŗſśŝšşșṣßťţṭŧúùûüǔŭūũűůųụưẃẁŵẅýỳŷÿȳỹƴźżžẓ'; 10 | const result = 11 | 'AEDEEOESaeeeoelsABCDDEHIKLOOSSTTTUUYabcddehikloosstttuuyAAAAAAAAAAAAEAEAEBCCCCCDDDDDEEEEEEEEEEEEGGGGGaaaaaaaaaaaaeaeaebcccccddddeeeeeeeeeegggggHHHIIIIIIIIIIIIJKKLLLLLNNNNNOOOOOOOOOOOOOOEhhhiiiiiiiiiiiijkkĸlllllnnnnnooooooooooooooeRRRSSSSSSSTTTTUUUUUUUUUUUUUWWWWYYYYYYYZZZZrrrlsssssssttttuuuuuuuuuuuuuwwwwyyyyyyyzzzz'; 12 | 13 | expect(stripDiacritics(string)).toBe(result); 14 | }); 15 | 16 | it('works for non-latin alphabets', () => { 17 | const string = 'ΆΈΉΊΪΌΎΫΏάέίϊΐόύϋΰ'; 18 | const result = 'ΑΕΗΙΙΟΥΥΩαειιιουυυ'; 19 | 20 | expect(stripDiacritics(string)).toBe(result); 21 | }); 22 | 23 | it('removes combining diacritical marks from a string', () => { 24 | const alphaRange = ['a', 'b', 'c', 'd', 'e', 'f']; 25 | const numRange = range(30, 37); 26 | 27 | const arr: string[] = []; 28 | 29 | numRange.forEach((n) => { 30 | alphaRange.forEach((a) => { 31 | arr.push(n + a); 32 | }); 33 | }); 34 | 35 | // Build up a string of every unicode combining mark (\u0300-\u036F). 36 | const str = arr 37 | .concat(range(300, 370).map((num) => num.toString())) 38 | .map((n) => String.fromCharCode(Number(`0x0${n}`))) 39 | .join(''); 40 | 41 | expect(str.length).toBe(112); 42 | expect(stripDiacritics(str)).toBe(''); 43 | }); 44 | 45 | it('removes combining marks from Japanese characters', () => { 46 | expect(stripDiacritics('ネバダ州')).toBe('ネハタ州'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/utils/validateSelectedPropChange.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import validateSelectedPropChange from './validateSelectedPropChange'; 4 | 5 | describe('validateSelectedPropChange', () => { 6 | let str: string; 7 | 8 | beforeEach(() => { 9 | console.error = jest.fn((msg) => (str = msg)); 10 | }); 11 | 12 | it('does not trigger a warning', () => { 13 | validateSelectedPropChange([], ['foo']); 14 | expect(console.error).toHaveBeenCalledTimes(0); 15 | }); 16 | 17 | it('warns about uncontrolled to controlled change', () => { 18 | validateSelectedPropChange(undefined, []); 19 | const msg = 20 | 'You are changing an uncontrolled typeahead to be controlled. Input ' + 21 | 'elements should not switch from uncontrolled to controlled (or vice ' + 22 | 'versa). Decide between using a controlled or uncontrolled element for ' + 23 | 'the lifetime of the component.'; 24 | 25 | expect(console.error).toHaveBeenCalledTimes(1); 26 | expect(str).toMatch(msg); 27 | }); 28 | 29 | it('warns about controlled to uncontrolled change', () => { 30 | validateSelectedPropChange([], undefined); 31 | const msg = 32 | 'You are changing a controlled typeahead to be uncontrolled. Input ' + 33 | 'elements should not switch from controlled to uncontrolled (or vice ' + 34 | 'versa). Decide between using a controlled or uncontrolled element for ' + 35 | 'the lifetime of the component.'; 36 | 37 | expect(console.error).toHaveBeenCalledTimes(1); 38 | expect(str).toMatch(msg); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/validateSelectedPropChange.ts: -------------------------------------------------------------------------------- 1 | import warn from './warn'; 2 | 3 | import type { Option } from '../types'; 4 | 5 | export default function validateSelectedPropChange( 6 | prevSelected?: Option[], 7 | selected?: Option[] 8 | ): void { 9 | const uncontrolledToControlled = !prevSelected && selected; 10 | const controlledToUncontrolled = prevSelected && !selected; 11 | 12 | let from, to, precedent; 13 | 14 | if (uncontrolledToControlled) { 15 | from = 'uncontrolled'; 16 | to = 'controlled'; 17 | precedent = 'an'; 18 | } else { 19 | from = 'controlled'; 20 | to = 'uncontrolled'; 21 | precedent = 'a'; 22 | } 23 | 24 | const message = 25 | `You are changing ${precedent} ${from} typeahead to be ${to}. ` + 26 | `Input elements should not switch from ${from} to ${to} (or vice versa). ` + 27 | 'Decide between using a controlled or uncontrolled element for the ' + 28 | 'lifetime of the component.'; 29 | 30 | warn(!(uncontrolledToControlled || controlledToUncontrolled), message); 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/warn.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import warn, { resetWarned } from './warn'; 4 | 5 | describe('warn', () => { 6 | beforeEach(() => { 7 | console.error = jest.fn(); 8 | }); 9 | 10 | afterEach(() => { 11 | resetWarned(); 12 | }); 13 | 14 | it('does not trigger a warning for truthy values', () => { 15 | warn(true, 'Does not get called'); 16 | expect(console.error).toHaveBeenCalledTimes(0); 17 | }); 18 | 19 | it('triggers a warning for falsy values', () => { 20 | warn(false, 'Does get called'); 21 | expect(console.error).toHaveBeenCalledTimes(1); 22 | }); 23 | 24 | it('calls deprecation warnings only once', () => { 25 | warn(false, 'Feature `x` is deprecated'); 26 | expect(console.error).toHaveBeenCalledTimes(1); 27 | 28 | warn(false, 'Feature `x` is deprecated'); 29 | expect(console.error).toHaveBeenCalledTimes(1); 30 | 31 | warn(false, 'Feature `y` is deprecated'); 32 | expect(console.error).toHaveBeenCalledTimes(2); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/utils/warn.ts: -------------------------------------------------------------------------------- 1 | import warning from 'warning'; 2 | 3 | let warned: Record = {}; 4 | 5 | /** 6 | * Copied from: https://github.com/ReactTraining/react-router/blob/master/modules/routerWarning.js 7 | */ 8 | export default function warn( 9 | falseToWarn: boolean, 10 | message: string, 11 | ...args: unknown[] 12 | ): void { 13 | // Only issue deprecation warnings once. 14 | if (!falseToWarn && message.indexOf('deprecated') !== -1) { 15 | if (warned[message]) { 16 | return; 17 | } 18 | warned[message] = true; 19 | } 20 | 21 | warning(falseToWarn, `[react-bootstrap-typeahead] ${message}`, ...args); 22 | } 23 | 24 | export function resetWarned(): void { 25 | warned = {}; 26 | } 27 | -------------------------------------------------------------------------------- /styles/Typeahead.bs5.scss: -------------------------------------------------------------------------------- 1 | .rbt-close { 2 | font-size: 1rem; 3 | 4 | &-sm { 5 | font-size: 0.75rem; 6 | } 7 | 8 | &-content { 9 | display: none; 10 | } 11 | } 12 | 13 | .rbt-aux { 14 | width: 2.5rem; 15 | 16 | &-lg { 17 | width: 3rem; 18 | } 19 | 20 | & .rbt-close { 21 | // Overrides `margin-top: -4px` in BS4 styles 22 | margin-top: 0; 23 | } 24 | } 25 | 26 | .rbt .form-floating { 27 | flex: 1; 28 | } 29 | 30 | .form-floating > .rbt-input-multi { 31 | &:not(:placeholder-shown) ~ label { 32 | opacity: inherit; 33 | transform: inherit; 34 | } 35 | 36 | // Hide the placeholder 37 | .rbt-input-main::placeholder { 38 | color: transparent; 39 | } 40 | 41 | // Shrink the label when multi-selector is focused or there are selections. 42 | &.focus ~ label { 43 | opacity: 0.65; 44 | transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "src", 5 | "checkJs": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "isolatedModules": true, 11 | "jsx": "react", 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noEmit": false, 15 | "outDir": "types", 16 | "removeComments": true, 17 | "rootDir": "src", 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "esnext" 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules", "**/__tests__/*", "**/*.test.*"] 24 | } 25 | --------------------------------------------------------------------------------
29 | {getExampleCode(children)} 30 |