Showing preview only (217K chars total). Download the full file or copy to clipboard to get everything.
Repository: based-ghost/react-functional-select
Branch: master
Commit: 8929ba9b8db3
Files: 90
Total size: 196.5 KB
Directory structure:
gitextract_i9kdersq/
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github/
│ └── workflows/
│ └── chromatic.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .storybook/
│ ├── global-style/
│ │ ├── global-style.ts
│ │ ├── index.ts
│ │ └── react-toastify-override.ts
│ ├── main.ts
│ ├── manager-head.html
│ ├── manager.ts
│ └── preview.tsx
├── .test/
│ ├── custom-test-env.ts
│ └── setup-tests.ts
├── .travis.yml
├── LICENSE
├── README.md
├── __stories__/
│ ├── helpers/
│ │ ├── components/
│ │ │ ├── Checkbox.tsx
│ │ │ ├── CodeMarkup.tsx
│ │ │ ├── OptionsCountButton.tsx
│ │ │ ├── PackageLink.tsx
│ │ │ └── index.ts
│ │ ├── constants/
│ │ │ ├── index.ts
│ │ │ ├── markup.ts
│ │ │ ├── npm-package.ts
│ │ │ ├── options-data.ts
│ │ │ ├── react-toastify.ts
│ │ │ ├── svg-props.ts
│ │ │ └── theme.ts
│ │ ├── hooks/
│ │ │ ├── index.ts
│ │ │ └── useCallbackState.ts
│ │ ├── index.ts
│ │ ├── styled/
│ │ │ └── index.ts
│ │ └── utils/
│ │ └── index.ts
│ ├── index.stories.tsx
│ └── types/
│ └── index.d.ts
├── __tests__/
│ ├── AriaLiveRegion.test.tsx
│ ├── AutosizeInput.test.tsx
│ ├── IndicatorIcons.test.tsx
│ ├── LoadingDots.test.tsx
│ ├── MenuList.test.tsx
│ ├── MultiValue.test.tsx
│ ├── Option.test.tsx
│ ├── ReactSSR.test.tsx
│ ├── Select.test.tsx
│ ├── Value.test.tsx
│ └── helpers/
│ ├── ThemeWrapper.tsx
│ ├── index.ts
│ └── utils.ts
├── babel.config.js
├── jest.config.js
├── package.json
├── rollup.config.js
├── src/
│ ├── Select.tsx
│ ├── components/
│ │ ├── AriaLiveRegion/
│ │ │ └── index.tsx
│ │ ├── AutosizeInput/
│ │ │ └── index.tsx
│ │ ├── IndicatorIcons/
│ │ │ ├── ClearIcon.tsx
│ │ │ ├── LoadingDots.tsx
│ │ │ └── index.tsx
│ │ ├── Menu/
│ │ │ ├── MenuList.tsx
│ │ │ ├── Option.tsx
│ │ │ └── index.tsx
│ │ ├── Value/
│ │ │ ├── MultiValue.tsx
│ │ │ └── index.tsx
│ │ └── index.ts
│ ├── constants/
│ │ ├── defaults.ts
│ │ ├── dom.ts
│ │ ├── enums.ts
│ │ ├── index.ts
│ │ ├── styled.ts
│ │ └── theme.ts
│ ├── globals.d.ts
│ ├── hooks/
│ │ ├── index.ts
│ │ ├── useCallbackRef.ts
│ │ ├── useDebounce.ts
│ │ ├── useLatestRef.ts
│ │ ├── useMenuOptions.ts
│ │ ├── useMenuPosition.ts
│ │ ├── useMountEffect.ts
│ │ └── useUpdateEffect.ts
│ ├── index.ts
│ ├── types.ts
│ └── utils/
│ ├── common.ts
│ ├── device.ts
│ ├── index.ts
│ └── menu.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_size = 2
end_of_line = lf
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
[COMMIT_EDITMSG]
max_line_length = 0
================================================
FILE: .eslintignore
================================================
dist
node_modules
================================================
FILE: .eslintrc
================================================
{
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"react",
"@typescript-eslint",
"prettier"
],
"env": {
"browser": true,
"node": true,
"es6": true,
"jest": true
},
"rules": {
"react/prop-types": 0,
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"semi": "off",
"sort-keys": "off",
"global-require": "off",
"spaced-comment": "off",
"capitalized-comments": "off",
"padding-line-between-statements": "off",
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-non-null-assertion": 0
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2020,
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
}
}
================================================
FILE: .github/workflows/chromatic.yml
================================================
# .github/workflows/chromatic.yml
# Workflow name
name: 'Chromatic'
# Event for the workflow
on: push
jobs:
chromatic-deployment:
# Operating System
runs-on: ubuntu-latest
# Job steps
steps:
# 👇 Adds Chromatic as a step in the workflow
- name: Publish to Chromatic
uses: chromaui/action@v1
# Options required to the GitHub chromatic action
with:
# 👇 Chromatic projectToken, refer to the manage page to obtain it.
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
autoAcceptChanges: true
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Editor directories and files
.idea
.vs
.vscode
.cache
# dependencies
node_modules
# testing
coverage
# production
lib
dist
build
# Log files
logs
*.log
# misc
.DS_Store
storybook-static
package-lock.json
chromatic-diagnostics.json
================================================
FILE: .npmrc
================================================
registry = "https://registry.npmjs.com/"
================================================
FILE: .prettierignore
================================================
dist
node_modules
================================================
FILE: .prettierrc
================================================
{
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "es5",
"tabWidth": 2,
"singleQuote": true,
"endOfLine": "auto"
}
================================================
FILE: .storybook/global-style/global-style.ts
================================================
import { createGlobalStyle } from 'styled-components';
import ReactToastifyOverride from './react-toastify-override';
const GlobalStyle = createGlobalStyle`
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
line-height: 1.15;
text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
body {
flex: 1;
margin: 0;
display: flex;
color: #262626;
font-size: 1rem;
font-weight: 400;
text-align: left;
line-height: 1.5;
min-height: 120vh;
flex-direction: column;
background-color: #fff;
padding: 1rem 0 !important;
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;
}
em {
font-weight: 600;
}
strong {
font-weight: 600;
font-size: 1.025em;
}
code {
font-size: 90%;
color: #476582;
line-height: 1.7;
border-radius: 4px;
padding: .175em .475em;
word-break: break-word;
background-color: #f1f1f1;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
}
${ReactToastifyOverride}
`;
export default GlobalStyle;
================================================
FILE: .storybook/global-style/index.ts
================================================
export { default as GlobalStyle } from './global-style';
================================================
FILE: .storybook/global-style/react-toastify-override.ts
================================================
import { css, keyframes } from 'styled-components';
const TOASTIFY_BOUNCE_OUT = keyframes`
20% {
transform: scale3d(0.9, 0.9, 0.9);
} 50%,
55% {
opacity: 1;
transform: scale3d(1.1, 1.1, 1.1);
} to {
opacity: 0;
transform: scale3d(0.3, 0.3, 0.3);
}
`;
const TOASTIFY_BOUNCE_IN = keyframes`
from,
20%,
40%,
60%,
80%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
0% {
opacity: 0;
transform: scale3d(0.3, 0.3, 0.3);
} 20% {
transform: scale3d(1.1, 1.1, 1.1);
} 40% {
transform: scale3d(0.9, 0.9, 0.9);
} 60% {
opacity: 1;
transform: scale3d(1.03, 1.03, 1.03);
} 80% {
transform: scale3d(0.97, 0.97, 0.97);
} to {
opacity: 1;
transform: scale3d(1, 1, 1);
}
`;
export default css`
.Toastify__animate__bounceIn {
animation: ${TOASTIFY_BOUNCE_IN} 1s both;
}
.Toastify__animate__bounceOut {
animation: ${TOASTIFY_BOUNCE_OUT} 0.85s both;
}
.Toastify__toast-container {
.Toastify__toast {
background: #292d3e;
&-body {
color: #C3C9E6;
}
&-icon > svg {
fill: #85ADFF;
}
}
.Toastify__close-button {
color: #fff;
}
.Toastify__progress-bar {
background-color: #85ADFF;
}
}
`;
================================================
FILE: .storybook/main.ts
================================================
import type { StorybookConfig } from '@storybook/react/types';
const config: StorybookConfig = {
framework: '@storybook/react',
addons: ['@storybook/addon-storysource'],
stories: ['../__stories__/**/*.stories.@(js|tsx|mdx)'],
core: {
builder: 'webpack5',
disableTelemetry: true,
enableCrashReports: false
}
};
export default config;
================================================
FILE: .storybook/manager-head.html
================================================
<style lang="css">
#panel-tab-content {
background: #292d3e !important;
}
#storybook-panel-root .os-content > pre {
line-height: 20px;
}
#storybook-panel-root .os-content > pre > div {
tab-size: 4;
hyphens: none;
text-align: left;
overflow-wrap: normal;
font-size: 14px;
line-height: 20px;
font-weight: 400;
white-space: pre;
word-spacing: normal;
word-break: normal;
}
#storybook-panel-root .os-content > pre > div > span > div {
background: inherit !important;
border-radius: inherit !important;
}
#storybook-panel-root .os-content > pre > div,
#storybook-panel-root .os-content > pre > div > span > span,
#storybook-panel-root .os-content > pre > div > span > a,
#storybook-panel-root .os-content > pre > div:last-of-type .token.script,
#storybook-panel-root .os-content > pre > div:last-of-type .token.parameter {
color: #A9AFD0;
}
#storybook-panel-root .os-content > pre > div,
#storybook-panel-root .os-content > pre > div:last-of-type .comment,
#storybook-panel-root .os-content > pre > div:last-of-type .token {
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.comment,
#storybook-panel-root .os-content > pre > div:last-of-type .comment.linenumber {
color: #6C739A;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.tag {
color:#F07178;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.keyword.module,
#storybook-panel-root .os-content > pre > div:last-of-type .token.entity,
#storybook-panel-root .os-content > pre > div:last-of-type .token.url,
#storybook-panel-root .os-content > pre > div:last-of-type .token.operator,
#storybook-panel-root .os-content > pre > div:last-of-type .token.variable,
#storybook-panel-root .os-content > pre > div:last-of-type .token.script.operator,
#storybook-panel-root .os-content > pre > div:last-of-type .token.punctuation,
#storybook-panel-root .os-content > pre > div:last-of-type .token.attr-value.punctuation,
#storybook-panel-root .os-content > pre > div:last-of-type .token.script.punctuation {
color: #89DDFF;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.arrow.operator,
#storybook-panel-root .os-content > pre > div:last-of-type .token.keyword,
#storybook-panel-root .os-content > pre > div:last-of-type .token.selector,
#storybook-panel-root .os-content > pre > div:last-of-type .token.builtin,
#storybook-panel-root .os-content > pre > div:last-of-type .token.keyword.nil,
#storybook-panel-root .os-content > pre > div:last-of-type .token.keyword.null,
#storybook-panel-root .os-content > pre > div:last-of-type .token.important,
#storybook-panel-root .os-content > pre > div:last-of-type .token.atrule,
#storybook-panel-root .os-content > pre > div:last-of-type .token.attr-name,
#storybook-panel-root .os-content > pre > div:last-of-type .token.tag.attr-name {
color: #c792ea;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.property,
#storybook-panel-root .os-content > pre > div:last-of-type .token.deleted,
#storybook-panel-root .os-content > pre > div:last-of-type .token.function-name,
#storybook-panel-root .os-content > pre > div:last-of-type .token.number,
#storybook-panel-root .os-content > pre > div:last-of-type .token.constant,
#storybook-panel-root .os-content > pre > div:last-of-type .token.symbol {
color: #F78C6C;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.boolean {
color: #FF9CAC;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.class-name,
#storybook-panel-root .os-content > pre > div:last-of-type .token.atrule,
#storybook-panel-root .os-content > pre > div:last-of-type .token.namespace,
#storybook-panel-root .os-content > pre > div:last-of-type .token.maybe-class-name,
#storybook-panel-root .os-content > pre > div:last-of-type .token.known-class-name,
#storybook-panel-root .os-content > pre > div:last-of-type .token.hexdiv {
color: #FFCB6B;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.function {
color: #85ADFF;
}
#storybook-panel-root .os-content > pre > div:last-of-type .token.string,
#storybook-panel-root .os-content > pre > div:last-of-type .token.char,
#storybook-panel-root .os-content > pre > div:last-of-type .token.regex,
#storybook-panel-root .os-content > pre > div:last-of-type .token.attr-value,
#storybook-panel-root .os-content > pre > div:last-of-type .token.unit {
color: #C3E88D;
}
</style>
================================================
FILE: .storybook/manager.ts
================================================
import { addons } from '@storybook/addons';
import { create } from '@storybook/theming';
const theme = create({
base: 'light',
appBg: '#E6E6E6',
barBg: '#E0E0E0',
barTextColor: '#7F7F7F',
colorSecondary: '#1ea7fd',
appBorderColor: '#D3D3D3',
brandUrl: 'https://master--625676b6922472003af898b4.chromatic.com'
});
addons.setConfig({
theme,
showNav: true,
showPanel: true
});
================================================
FILE: .storybook/preview.tsx
================================================
import React, { Fragment } from 'react';
import { GlobalStyle } from './global-style';
import type { DecoratorFn } from '@storybook/react';
// import react-toastify CSS files (overrides in react-toastify-override.ts)
import 'react-toastify/dist/ReactToastify.css';
const withGlobalStyle: DecoratorFn = (Story) => (
<Fragment>
<GlobalStyle />
<Story />
</Fragment>
);
export const decorators = [withGlobalStyle];
================================================
FILE: .test/custom-test-env.ts
================================================
import Environment from 'jest-environment-jsdom';
/**
* A custom environment to set the TextEncoder that is required by react-dom/server
*/
module.exports = class CustomTestEnvironment extends Environment {
async setup() {
await super.setup();
if (typeof this.global.TextEncoder === 'undefined') {
const { TextEncoder } = await import('util');
this.global.TextEncoder = TextEncoder;
}
}
}
================================================
FILE: .test/setup-tests.ts
================================================
import '@testing-library/jest-dom';
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- node
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Matthew Areddia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[](https://www.npmjs.com/package/react-functional-select)
[](https://www.npmjs.com/package/react-functional-select)
[](https://github.com/based-ghost/react-functional-select/issues)
[](LICENSE)
[](https://github.com/styled-components/styled-components)
# react-functional-select
> Micro-sized & micro-optimized select component for React.js
See the accompanying [Interactive Storybook UI Site](https://master--625676b6922472003af898b4.chromatic.com) for live demos and detailed docs.
<strong>Key features:</strong>
- Extremely lightweight: ~6 kB (gzipped)!
- Advanced features like async mode, portal support, animations, and option virtualization
- Fully-featured & customizable: API comparable to [`react-select`](https://github.com/JedWatson/react-select)
- Engineered for ultimate performance: effortlessly scroll, filter, and key through datasets numbering in the tens of thousands using [`react-window`](https://github.com/bvaughn/react-window) + performance-first code. [Demo of handling 50,000 options here!](https://master--625676b6922472003af898b4.chromatic.com/?path=/story/react-functional-select-demos--virtualization)
- Extensible styling API with [`styled-components`](https://github.com/styled-components/styled-components)
- Accessible
<strong>Peer dependencies:</strong>
- [`styled-components`](https://github.com/styled-components/styled-components) for dynamic styling/theming via CSS-in-JS
- [`react-window`](https://github.com/bvaughn/react-window) for integrated menu option data virtualization
## Overview
Essentially, this is a focused subset of [`react-select`](https://github.com/JedWatson/react-select)'s API that is engineered for ultimate performance and minimal bundle size. It is built entirely with the `React Hooks` API (no legacy class components). The primary design principal revolves around weighing the cost/benefits of adding a feature against the impact to performance and/or number of lines of code its addition would have.
Any expected features not in the current API is likely due to the reason that such features would have added significant overhead to the package. In addition, if we expose the right public methods and/or callback properties, this feature should be trivial to add to wrapping components - proper decoupling and abstraction of code is key to keeping such channels open for similar customizations that can be kept out of this package. Please, feel free to offer enhancement ideas with/without technical solutions.
## Installation
```
$ npm i react-window styled-components react-functional-select
$ yarn add react-window styled-components react-functional-select
```
> <strong><em>Note that you need to be on a react version that supports hooks (>= 16.8.6)</em></strong>
## Usage
- [Demo](https://master--625676b6922472003af898b4.chromatic.com)
- [Stories source code](./__stories__)
```jsx
import { Select } from 'react-functional-select';
import React, { useState, useEffect, useCallback, type ComponentProps } from 'react';
import { Card, CardHeader, CardBody, Container, SelectContainer } from '../shared/components';
type SelectProps = ComponentProps<typeof Select>;
type Option = Readonly<{
id: number;
city: string;
state: string;
}>;
const CITY_OPTIONS: Option[] = [
{ id: 1, city: 'Austin', state: 'TX' },
{ id: 2, city: 'Denver', state: 'CO' },
{ id: 3, city: 'Chicago', state: 'IL' },
{ id: 4, city: 'Phoenix', state: 'AZ' },
{ id: 5, city: 'Houston', state: 'TX' }
];
const SingleSelect: React.FC<SelectProps> = ({ isDisabled }) => {
const [isInvalid, setIsInvalid] = useState<boolean>(false);
const [selectedOption, setSelectedOption] = useState<Option | null>(null);
const getOptionValue = useCallback((opt: Option): number => opt.id, []);
const onOptionChange = useCallback((opt: Option | null): void => setSelectedOption(opt), []);
const getOptionLabel = useCallback((opt: Option): string => `${opt.city}, ${opt.state}`, []);
useEffect(() => {
if (isDisabled) {
setIsInvalid(false);
}
}, [isDisabled]);
return (
<Container>
<Card>
<CardHeader>
{`Selected Option: ${JSON.stringify(selectedOption || {})}`}
</CardHeader>
<CardBody>
<SelectContainer>
<Select
isClearable
isInvalid={isInvalid}
options={CITY_OPTIONS}
isDisabled={isDisabled}
onOptionChange={onOptionChange}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export default SingleSelect;
```
## Properties
All properties are technically optional (with a few having default values). Very similar to [`react-select`](https://github.com/JedWatson/react-select)'s API.
> <strong><em>Note that the following non-primitive properties should be properly memoized if defined:</em></strong><br>`clearIcon`, `caretIcon`, `options`, `renderOptionLabel`, `onMenuOpen`, `onOptionChange`, `onKeyDown`, `getOptionLabel`, `getOptionLabel`, `getOptionValue`, `onInputBlur`, `onInputFocus`, `onInputChange`, `onSearchChange`, `getIsOptionDisabled`, `getFilterOptionString`, `themeConfig`
| Property | Type | Default | Description
:---|:---|:---|:---
| `inputId`| string | `undefined` | The id of the autosize search input control
|`selectId`| string | `undefined` | The id of the parent select container element
|`menuId`| string | `undefined` | The id of the menu container element
|`ariaLabel`| string | `undefined` | Aria label (for assistive tech)
|`isMulti`| bool | `false` | Does the control allow for multiple selections (defaults to single-value mode)
|`async`| bool | `false` | Is the component in 'async' mode - when in 'async' mode, updates to the input search value will NOT cause the effect `useMenuOptions` to execute (this effect parses `options` into stateful value `menuOptions`)
|`autoFocus`| bool | `false` | Focus the control following initial mount of component
|`lazyLoadMenu`| bool | `false` | If `true`, the menu (wrapper & virtualized list components) will rendered in DOM only when `menuOpen` state is `true`
|`isLoading`| bool | `false` | Is the select in a state of loading - shows loading dots animation
|`isInvalid`| bool | `false` | Is the current value invalid - control recieves invalid styling
|`inputDelay`| number | `undefined` | The debounce delay in for the input search (milliseconds)
|`pageSize`| number | `5` | Number of options to jump in menu when page{up|down} keys are used
|`isDisabled`| bool | `false` | Is the select control disabled - recieves disabled styling
|`required`| bool | `false` | Is the select control required - applied to the `input` element. When `true`, the optionally specified CSS from the `themeConfig.input.cssRequired` field will be applied to the `input` element.
|`placeholder`| string | `Select option..` | Placeholder text for the select value
|`menuWidth`| string | number | `100%` | Width of the menu
|`menuItemSize`| number | `35` | The height of each option in the menu (px)
|`isClearable`| bool | `false` | Is the select value clearable
|`noOptionsMsg`| string | `No options` | The text displayed in the menu when there are no options available (to hide menu when search returns no items, set to `null` or `''`)
|`loadingMsg`| string | `Loading..` | The text displayed in the menu when `isLoading` === `true`
|`clearIcon`| ReactNode OR ((state: any) => ReactNode) | `undefined` | Custom clear icon node - `state` forwarded to a function is `{ menuOpen, isLoading, isInvalid, isDisabled }`
|`caretIcon`| ReactNode OR ((state: any) => ReactNode) | `undefined` | Custom caret icon node - `state` forwarded to a function is `{ menuOpen, isLoading, isInvalid, isDisabled }`
|`loadingNode`| ReactNode | `undefined` | Custom loading node
|`options`| array | `[]` | The menu options
|`isSearchable`| bool | `true` | Whether to enable search functionality or not
|`hideSelectedOptions`| bool | `false` | Hide the selected option from the menu (if undefined and isMulti = true, then defaults to true)
|`openMenuOnClick`| bool | `true` | If true, the menu can be toggled by clicking anywhere on the select control; if false, the menu can only be toggled by clicking the 'caret' icon on the far right of the control
|`menuMaxHeight`| number | `300` | Max height of the menu element - this effects how many options `react-window` will render
|`menuOverscanCount`| number | `1` | correlates to `react-window` property `overscanCount`: The number of items (options) to render outside of the visible area. Increasing the number can impact performance, but is useful if the option label is complex and the `renderOptionLabel` prop is defined
|`itemKeySelector`| string | number | `undefined` | If defined, will use the property in your original options as each option's key, rather than the parsed stateful value `menuOptions` index (this needs to be a unique property - so properties such as `id` or `value`). This relates to the `itemKey` property in dependency `react-window` - [more info here](https://react-window.now.sh/#/api/FixedSizeList)
|`menuScrollDuration`| number | `300` | Duration of scroll menu into view animation
|`menuItemDirection`| 'ltr' OR 'rtl' | `'ltr'` | The direction of text for each menu option and position of the menu's scroll bar (`react-window`'s `direction` prop)
|`ariaLabelledBy`| string | `undefined` | HTML ID of an element that should be used as the label (for assistive tech)
|`ariaLive`| 'off' OR 'polite' OR 'assertive' | `'polite'` | Used to set the priority with which screen reader should treat updates to live regions (translates to `aria-live` attribute)
|`openMenuOnFocus`| bool | `false` | Open the menu when the select control recieves focus
|`initialValue`| any | `undefined` | Initial select value
|`tabSelectsOption`| bool | `true` | Select the currently focused option when the user presses tab
|`blurInputOnSelect`| bool | `false` | Remove focus from the input when the user selects an option (useful for dismissing the keyboard on touch devices)
|`closeMenuOnSelect`| bool | `true` | Close the select menu when the user selects an option
|`isAriaLiveEnabled`| bool | `false` | Enables visually hidden div that reports stateful information (for assistive tech)
|`scrollMenuIntoView`| bool | `true` | Performs animated scroll to show menu in view when menu is opened (if there is room to do so)
|`backspaceClearsValue`| bool | `true` | Remove the currently focused option when the user presses backspace
|`filterMatchFrom`| 'any' OR 'start' | `'any'` | Position in stringified option to match search input
|`menuPosition`| 'top' OR 'auto' OR 'bottom' | `'bottom'` | Determines where menu will be placed in relation to the control - `'auto'` will first check if menu has space to open below the control, otherwise it will open above the control.
|`filterIgnoreCase`| bool | `true` | Search input ignores case of characters when comparing
|`filterIgnoreAccents`| bool | `false` | Search input will strip diacritics from string before comparing
|`onMenuOpen`| (...args: any[]) => void | `undefined` | Callback function executed after the menu is opened
|`onMenuClose`| (...args: any[]) => void | `undefined` | Callback function executed after the menu is closed
|`onOptionChange`| (data: any) => void | `undefined` | Callback function executed after a new option is selected
|`onKeyDown`| (e: KeyboardEvent, input?: string, focusedOption?: FocusedOption) => void | `undefined` | Callback function executed `onKeyDown` event
|`getOptionLabel`| (data: any) => string | number | `undefined` | Resolves option data to string | number to be displayed as the label by components (by default will use option.label)
|`getOptionValue`| (data: any) => string | number | `undefined` | Resolves option data to string | number to compare option values (by default will use option.value)
|`onInputBlur`| (e: FocusEvent) => void | `undefined` | Handle blur events on the search input
|`onInputFocus`| (e: FocusEvent) => void | `undefined` | Handle focus events on the search input
|`onInputChange`| (value: string) => void | `undefined` | Handle change events on the search input
|`onSearchChange`| (value: string) => void | `undefined` | Callback executed after the debounced search input value is persisted to the component's state - if no debounce is defined via the `inputDelay` property, it probably makes more sense to use `onInputChange` instead.
|`renderOptionLabel`| (data: any) => ReactNode | `undefined` | Formats option labels in the menu and control as JSX.Elements or React Components (by default will use `getOptionLabel`)
|`renderMultiOptions`| (params: any) => ReactNode | `undefined` | Allows for customization as to how multi-select options should be formatted. The `MultiParams` contains the array of selected options `{ selected: Array<{ data: any, value: string | number, label: string | number}>, renderOptionLabel: (data: any): ReactNode }`. Left and right arrow key navigation will also be disabled when this property is defined.
|`getIsOptionDisabled`| (data: any) => boolean | `undefined` | When defined will evaluate each option to determine whether it is disabled or not (if not specified, each option will be evaluated as to whether or not it contains a property of `isDisabled` with a value of `true`)
|`getFilterOptionString`| (option: any) => string | `undefined` | When defined will take each option and generate a string used in the filtering process (by default, will use option.label)
|`themeConfig`| Partial\<DefaultTheme\> | `undefined` | Object that takes specified property key-value pairs and merges them into the theme object
|`menuPortalTarget`| Element | `undefined` | Whether the menu should use a portal, and where it should attach
|`memoOptions`| bool | `false` | Whether to memoize each `Option` component
## Inspiration
This project was inspired by [`react-select`](https://github.com/JedWatson/react-select).
## License
MIT licensed. Copyright (c) [Matt Areddia](https://github.com/based-ghost) 2022.
================================================
FILE: __stories__/helpers/components/Checkbox.tsx
================================================
import React from 'react';
import { hexToRgba } from '../utils';
import styled, { css } from 'styled-components';
type CheckboxProps = Readonly<{
label?: string;
checked: boolean;
readOnly?: boolean;
onCheck: (checked: boolean) => void;
}>;
const CHECK_COLOR = '#149DF3';
const CHECK_BORDER_COLOR = hexToRgba(CHECK_COLOR, 0.83);
const Label = styled.span`
user-select: none;
margin-left: 1.4rem;
`;
const Input = styled.input`
z-index: 3;
opacity: 0;
width: 1em;
height: 1em;
cursor: pointer;
position: absolute;
:checked ~ i {
border-color: ${CHECK_BORDER_COLOR};
:after,
:before {
opacity: 1;
transition: height 0.34s ease;
}
:after {
height: 0.5rem;
}
:before {
height: 1.16rem;
transition-delay: 0.11s;
}
}
`;
const CheckboxWrapper = styled.label<{ isReadOnly?: boolean }>`
user-select: none;
position: relative;
margin-top: 0.5rem;
align-items: center;
display: inline-flex;
${({ isReadOnly }) =>
isReadOnly
&& css`
cursor: default;
pointer-events: none;
> i {
opacity: 0.5;
}
`}
`;
const CheckIcon = styled.i`
z-index: 0;
width: 1rem;
height: 1rem;
position: absolute;
border-style: solid;
border-width: 1.5px;
box-sizing: border-box;
border-radius: 0.0625rem;
background-color: transparent;
border-color: rgba(0, 0, 0, 0.5);
transition: border-color 0.34s ease;
:after,
:before {
height: 0;
opacity: 0;
content: "";
width: 0.2rem;
display: block;
position: absolute;
border-radius: 3px;
transform-origin: left top;
background-color: ${CHECK_COLOR};
transition: opacity 0.34s ease, height 0s linear 0.34s;
}
:after {
top: 0.33rem;
left: 0.01rem;
transform: rotate(-45deg);
}
:before {
top: 0.68rem;
left: 0.39rem;
transform: rotate(-135deg);
}
`;
const Checkbox: React.FC<CheckboxProps> = ({
label,
onCheck,
checked,
readOnly
}) => (
<CheckboxWrapper isReadOnly={readOnly}>
<Input
type='checkbox'
checked={checked}
onChange={(e) => onCheck(e.target.checked)}
/>
<CheckIcon />
{label && <Label>{label}</Label>}
</CheckboxWrapper>
);
export default Checkbox;
================================================
FILE: __stories__/helpers/components/CodeMarkup.tsx
================================================
import React, { memo } from 'react';
import styled from 'styled-components';
import { MEDIA_QUERY_IS_MOBILE } from '../styled';
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
// Register light build of react-syntax-highlighter and register only what is needed
const dark = require('react-syntax-highlighter/dist/esm/styles/prism/dark').default;
const markup = require('react-syntax-highlighter/dist/esm/languages/prism/markup').default;
const javascript = require('react-syntax-highlighter/dist/esm/languages/prism/javascript').default;
SyntaxHighlighter.registerLanguage('markup', markup);
SyntaxHighlighter.registerLanguage('javascript', javascript);
type CodeMarkupProps = Readonly<{
data: any;
header: string;
language: string;
formatFn?: (data: any) => string;
}>;
const CodeMarkupContainer = styled.div`
overflow: hidden;
border-radius: 5px;
margin: 1rem 1.5rem;
background: #292d3e;
${MEDIA_QUERY_IS_MOBILE} {
margin: 1rem 0;
}
`;
const Header = styled.div`
font-size: 14px;
padding: 0 .9rem;
font-weight: 700;
line-height: 2.95;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(235, 235, 235, 0.45);
background-color: rgba(0, 0, 0, 0.2);
`;
const PreContainer = styled.div`
width: 100%;
height: 100%;
overflow: auto;
border-radius: 0;
min-height: 365px !important;
max-height: 365px !important;
pre {
line-height: 20px;
margin: 1rem !important;
> code {
padding: 0;
color: #A9AFD0 !important;
font-size: 14px !important;
font-weight: 400 !important;
line-height: 20px !important;
text-shadow: none !important;
.boolean {
color: #FF9CAC;
}
.function {
color: #85ADFF;
}
.tag,
.property {
color: #F07178;
}
.number,
.attr-name {
color: #c792ea;
}
.string,
.tag.attr-value {
color: #C3E88D;
}
.operator,
.token.punctuation,
.tag.punctuation,
.tag.attr-value.punctuation {
color: #89DDFF;
}
}
}
`;
const CodeMarkup = memo<CodeMarkupProps>(({
data,
header,
language,
formatFn
}) => (
<CodeMarkupContainer>
<Header>{header}</Header>
<PreContainer>
<SyntaxHighlighter
wrapLines
style={dark}
language={language}
useInlineStyles={false}
>
{formatFn ? formatFn(data) : data}
</SyntaxHighlighter>
</PreContainer>
</CodeMarkupContainer>
));
CodeMarkup.displayName = 'CodeMarkup';
export default CodeMarkup;
================================================
FILE: __stories__/helpers/components/OptionsCountButton.tsx
================================================
import React from 'react';
import { Button } from '../styled';
import { numberWithCommas } from '../utils';
import styled, { css } from 'styled-components';
type OptionsCountButtonProps = Readonly<{
count: number;
optionsCount: number;
setOptionsCount: (count: number) => void;
}>;
const StyledButton = styled(Button)<{ isActive?: boolean }>`
width: 6rem;
transition: none;
min-width: 6rem !important;
${({ isActive }) =>
isActive
&& css`
color: #fff;
background-color: #149DF3;
:hover {
background-color: #0A93E9;
}
`}
:focus {
color: #fff !important;
background-color: #149DF3 !important;
}
`;
const OptionsCountButton: React.FC<OptionsCountButtonProps> = ({
count,
optionsCount,
setOptionsCount
}) => {
const isActive = count === optionsCount;
const onClick = () => !isActive && setOptionsCount(count);
return (
<StyledButton
onClick={onClick}
isActive={isActive}
>
{numberWithCommas(count)}
</StyledButton>
);
};
export default OptionsCountButton;
================================================
FILE: __stories__/helpers/components/PackageLink.tsx
================================================
import React from 'react';
import styled from 'styled-components';
type PackageLinkProps = Readonly<{
name: string;
href: string;
}>;
const Link = styled.a`
color: #1EA7FD;
cursor: pointer;
font-weight: 600;
line-height: 1.5;
text-decoration: none;
:hover {
text-decoration: underline;
}
`;
const PackageLink: React.FC<PackageLinkProps> = ({ name, href }) => (
<Link
href={href}
target='_blank'
aria-label={name}
rel='noopener noreferrer'
>
{name}
</Link>
);
export default PackageLink;
================================================
FILE: __stories__/helpers/components/index.ts
================================================
export { default as Checkbox } from './Checkbox';
export { default as CodeMarkup } from './CodeMarkup';
export { default as PackageLink } from './PackageLink';
export { default as OptionsCountButton } from './OptionsCountButton';
================================================
FILE: __stories__/helpers/constants/index.ts
================================================
export * from './theme';
export * from './markup';
export * from './svg-props';
export * from './npm-package';
export * from './options-data';
export * from './react-toastify';
================================================
FILE: __stories__/helpers/constants/markup.ts
================================================
import {
OPTION_CLS,
OPTION_FOCUSED_CLS,
OPTION_DISABLED_CLS,
OPTION_SELECTED_CLS,
CARET_ICON_CLS,
CLEAR_ICON_CLS,
AUTOSIZE_INPUT_CLS,
MENU_CONTAINER_CLS,
SELECT_CONTAINER_CLS,
CONTROL_CONTAINER_CLS,
PLACEHOLDER_DEFAULT
} from '../../../src/constants';
export const CLASS_NAME_HTML =
`<div class="${SELECT_CONTAINER_CLS}">
<div class="${CONTROL_CONTAINER_CLS}">
<div>
<div>${PLACEHOLDER_DEFAULT}</div>
<div>
<input
value=""
type="text"
class="${AUTOSIZE_INPUT_CLS}"
/>
::after
</div>
</div>
<div>
<div>
<svg
aria-hidden="true"
viewBox="0 0 14 16"
class="${CLEAR_ICON_CLS}"
>
<path
fillRule="evenodd"
d="M7.71 8.23l3.75 3.75-1.48..."
/>
</svg>
</div>
<span></span>
<div>
<div
aria-hidden="true"
class="${CARET_ICON_CLS}"
/>
</div>
</div>
</div>
<div class="${MENU_CONTAINER_CLS}">
<div>
<div>
<div class="${OPTION_CLS}">
Option 1
</div>
<div class="${OPTION_CLS} ${OPTION_FOCUSED_CLS}">
Option 2
</div>
<div class="${OPTION_CLS} ${OPTION_SELECTED_CLS}">
Option 3
</div>
<div class="${OPTION_CLS} ${OPTION_DISABLED_CLS}">
Option 4
</div>
</div>
</div>
</div>
</div>`;
================================================
FILE: __stories__/helpers/constants/npm-package.ts
================================================
export const STYLED_COMPONENTS_PACKAGE = {
name: 'styled-components',
href: 'https://www.styled-components.com'
} as const;
export const REACT_WINDOW_PACKAGE = {
name: 'react-window',
href: 'https://github.com/bvaughn/react-window'
} as const;
================================================
FILE: __stories__/helpers/constants/options-data.ts
================================================
import type { CityOption, PackageOption } from '../../types';
export const PACKAGE_OPTIONS: PackageOption[] = [
{ id: 1, name: 'react' },
{ id: 2, name: 'react-dom' },
{ id: 3, name: 'reactstrap' },
{ id: 4, name: 'react-scripts' },
{ id: 5, name: 'react-window' }
];
export const CITY_OPTIONS: CityOption[] = [
{ id: 1, city: 'Boston', state: 'MA' },
{ id: 2, city: 'Austin', state: 'TX' },
{ id: 3, city: 'Denver', state: 'CO' },
{ id: 4, city: 'Chicago', state: 'IL' },
{ id: 5, city: 'Phoenix', state: 'AZ' },
{ id: 6, city: 'Houston', state: 'TX' },
{ id: 7, city: 'Orlando', state: 'FL' },
{ id: 8, city: 'Portland', state: 'OR' },
{ id: 9, city: 'Milwaukee', state: 'WI' },
{ id: 10, city: 'Louisville', state: 'KY' }
];
================================================
FILE: __stories__/helpers/constants/react-toastify.ts
================================================
import { cssTransition, type ToastContainerProps } from 'react-toastify';
// CSS transition config => 'transition' property
const transition = cssTransition({
enter: 'Toastify__animate__bounceIn',
exit: 'Toastify__animate__bounceOut'
});
// ToastContainerProps passed to the toast.configure() method
export const TOAST_CONTAINER_PROPS: ToastContainerProps = {
transition,
autoClose: 2500,
draggable: false,
newestOnTop: true,
position: 'top-right'
} as const;
================================================
FILE: __stories__/helpers/constants/svg-props.ts
================================================
import type { SVGProps } from 'react';
export const CHEVRON_SVG_PROPS = {
'aria-hidden': true,
viewBox: '0 0 448 512'
} as const;
export const CHEVRON_DOWN_PATH_PROPS: SVGProps<SVGPathElement> = {
d: 'M207.029 381.476L12.686 187.132c-9.373-9.373-9.373-24.569 0-33.941l22.667-22.667c9.357-9.357 24.522-9.375 33.901-.04L224 284.505l154.745-154.021c9.379-9.335 24.544-9.317 33.901.04l22.667 22.667c9.373 9.373 9.373 24.569 0 33.941L240.971 381.476c-9.373 9.372-24.569 9.372-33.942 0z'
} as const;
export const REACT_SVG_PROPS = {
'aria-hidden': true,
viewBox: '0 0 841.9 595.3'
} as const;
export const REACT_SVG_PATH_PROPS: SVGProps<SVGPathElement> = {
d: 'M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z'
} as const;
export const REACT_SVG_CIRCLE_PROPS: SVGProps<SVGCircleElement> = {
r: '45.7',
cx: '420.9',
cy: '296.5'
} as const;
================================================
FILE: __stories__/helpers/constants/theme.ts
================================================
import type { Theme } from '../../../src';
import { createThemeOptions } from '../utils';
import { mergeDeep } from '../../../src/utils';
import { DEFAULT_THEME } from '../../../src/constants';
// Normalize animation props as be default they are type of styled-component's "FlattenSimpleInterpolation"
const FADE_IN_KEYFRAMES_STR = 'FADE_IN_KEYFRAMES 0.25s ease-in-out';
const BOUNCE_KEYFRAMES_STR = 'BOUNCE_KEYFRAMES 1.19s ease-in-out infinite';
const THEME_ANIMATIONS: Theme = {
loader: {
animation: BOUNCE_KEYFRAMES_STR
},
menu: {
animation: FADE_IN_KEYFRAMES_STR
},
multiValue: {
animation: FADE_IN_KEYFRAMES_STR
},
icon: {
clear: {
animation: FADE_IN_KEYFRAMES_STR
}
},
} as const;
export const ThemeEnum = {
DEFAULT: 'Default',
LARGE_TEXT: 'Large text',
DARK_COLORS: 'Dark colors',
ZERO_BORDER_RADIUS: 'No border-radius'
} as const;
export const THEME_CONFIG: Theme = {
menu: {
option: {
selectedColor: '#515151',
focusedBgColor: '#F5F5F5',
selectedBgColor: '#F5F5F5'
}
}
} as const;
export const ThemeConfigMap: Theme = {
[ThemeEnum.DEFAULT]: undefined as any,
[ThemeEnum.DARK_COLORS]: {
color: {
border: '#A8AEB4',
primary: '#555555'
},
select: {
css: 'color: #000;'
},
control: {
boxShadowColor: 'rgba(85, 85, 85, 0.25)',
focusedBorderColor: 'rgba(85, 85, 85, 0.75)'
},
icon: {
color: '#A6A6A6'
},
menu: {
option: {
selectedColor: '#fff',
selectedBgColor: '#555555',
focusedBgColor: 'rgba(85, 85, 85, 0.225)'
}
}
},
[ThemeEnum.LARGE_TEXT]: {
select: {
css: 'font-size: 1.25rem;'
}
},
[ThemeEnum.ZERO_BORDER_RADIUS]: {
control: {
borderRadius: '0'
},
menu: {
borderRadius: '0'
}
}
} as const;
export const THEME_OPTIONS = createThemeOptions(ThemeEnum);
export const THEME_DEFAULTS = mergeDeep(DEFAULT_THEME, THEME_ANIMATIONS);
================================================
FILE: __stories__/helpers/hooks/index.ts
================================================
export { default as useCallbackState } from './useCallbackState';
================================================
FILE: __stories__/helpers/hooks/useCallbackState.ts
================================================
import { useCallback, useState } from 'react';
const useCallbackState = <T>(initState: T): [T, (newState: T) => void] => {
const [state, setState] = useState<T>(initState);
const setStateCallback = useCallback((newState: T): void => setState(newState), []);
return [state, setStateCallback];
};
export default useCallbackState;
================================================
FILE: __stories__/helpers/index.ts
================================================
export * from './utils';
export * from './hooks';
export * from './styled';
export * from './constants';
export * from './components';
================================================
FILE: __stories__/helpers/styled/index.ts
================================================
import styled, { css, keyframes } from 'styled-components';
export const MEDIA_QUERY_IS_MOBILE = '@media only screen and (max-width: 768px)';
export const MEDIA_QUERY_IS_MOBILE_XS = '@media only screen and (max-width: 525px)';
export const MEDIA_QUERY_IS_TABLET_OR_DESKTOP = '@media only screen and (min-width: 992px)';
export const MEDIA_QUERY_IS_TABLET = '@media only screen and (max-width: 991px) and (min-width: 769px)';
// Need to implement a div version of Paragraph since PrettyPrintJson contains an <pre> element
// ...which cannot be a child of a <p> element
const PARAGRAPH_BASE_STYLE = css`
margin-top: 0;
display: block;
margin-bottom: 1rem;
margin-block-end: 1em;
margin-inline-end: 0px;
margin-block-start: 1em;
margin-inline-start: 0px;
`;
export const Content = styled.p`
${PARAGRAPH_BASE_STYLE}
`;
export const Paragraph = styled.p`
${PARAGRAPH_BASE_STYLE}
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
max-width: 85%;
}
`;
export const Container = styled.div`
width: 100%;
display: block;
margin-left: auto;
margin-right: auto;
padding: 0.25rem 1.75rem;
${MEDIA_QUERY_IS_MOBILE} {
font-size: 0.96em;
padding: 0.25rem 1.25rem;
}
`;
export const SelectContainer = styled.div`
width: 60%;
margin-top: 1rem;
${MEDIA_QUERY_IS_TABLET} {
width: 75%;
}
${MEDIA_QUERY_IS_MOBILE} {
width: 100%;
}
`;
export const Hr = styled.hr`
border: 0;
margin-top: 1rem;
margin-bottom: 1rem;
padding-bottom: .225rem;
border-top: 1px solid #ddd;
`;
export const Columns = styled.div`
width: 100%;
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
display: flex;
}
`;
export const Column = styled.div<{widthPercent?: number}>`
flex-grow: 1;
flex-basis: 0;
flex-shrink: 1;
display: block;
padding: 0.25rem;
${MEDIA_QUERY_IS_MOBILE} {
padding: 0.25rem 0;
width: 100% !important;
}
${({widthPercent}) =>
widthPercent &&
css`
flex: none;
width: ${widthPercent}%;
`}
`;
export const ListWrapper = styled.div`
${PARAGRAPH_BASE_STYLE}
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
max-width: 85%;
}
&.is-class-list {
max-width: 100% !important;
ul {
li + li {
margin-top: 0.55em !important;
}
}
}
`;
export const List = styled.ul`
display: block;
padding-left: 1.75rem;
margin-block-end: 1em;
list-style-type: disc;
margin-inline-end: 0px;
margin-block-start: 1em;
margin-inline-start: 0px;
padding-inline-start: 20px;
li + li {
margin-top: 0.6em;
}
`;
export const Li = styled.li`
display: list-item;
text-align: match-parent;
`;
export const TextHeader = styled.span`
color: #476582;
font-size: 90%;
line-height: 1.7;
border-radius: 4px;
padding: .175em .475em;
word-break: break-word;
background-color: #f1f1f1;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
`;
export const Title = styled.h2`
font-size: 2rem;
font-weight: 700;
line-height: 1.167;
margin-top: 0.5rem;
margin-bottom: .5rem;
`;
export const SubTitle = styled.h4`
font-weight: 700;
line-height: 1.167;
font-size: 1.65rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
letter-spacing: 0.00735em;
`;
export const Button = styled.button`
border: 0;
color: #262626;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
line-height: 1.5;
overflow: visible;
user-select: none;
text-align: center;
border-radius: 3px;
display: inline-block;
vertical-align: middle;
background-color: #eaebec;
padding: 0.375rem 0.75rem;
-webkit-appearance: button;
transition: color 0.2s ease-out, background-color 0.2s ease-out;
:focus {
outline: 0;
}
:hover, :focus {
background-color: #DDDEDF;
}
${MEDIA_QUERY_IS_MOBILE} {
display: block;
width: 100% !important;
}
${MEDIA_QUERY_IS_MOBILE_XS} {
font-size: 0.9em;
}
`;
export const Buttons = styled.div`
> button {
min-width: 6.25rem;
margin-top: 0.5rem;
:not(:last-of-type) {
margin-right: 0.5rem;
}
}
`;
export const Label = styled.label`
width: 100%;
font-weight: 600;
text-align: left;
user-select: none;
display: inline-block;
vertical-align: middle;
color: rgba(0, 0, 0, 0.45);
margin: 0.5rem auto 0.25rem 0;
${MEDIA_QUERY_IS_MOBILE} {
margin: 0 auto 0.15rem 0;
}
`;
export const Checkboxes = styled.div`
font-size: 1rem;
> label {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
:not(:last-of-type) {
margin-right: 1.35rem;
}
}
${MEDIA_QUERY_IS_MOBILE} {
text-align: left;
> label {
width: 100%;
margin-left: auto;
margin-top: 0.425rem;
margin-bottom: 0.425rem;
}
}
`;
export const Card = styled.div`
min-width: 0;
display: flex;
margin: 1.25rem 0;
position: relative;
border-radius: 3px;
word-wrap: break-word;
background-color: #fff;
flex-direction: column;
background-clip: border-box;
border: 1px solid rgba(0, 0, 0, 0.125);
box-shadow: rgb(0 0 0 / 10%) 0px 1px 3px 0px, rgb(0 0 0 / 5%) 0px 5px 15px 0px;
${MEDIA_QUERY_IS_MOBILE} {
border: none;
border-radius: 0;
box-shadow: none;
margin: 0;
}
`;
export const CardHeader = styled.div`
display: flex;
font-size: 1.15rem;
flex-flow: row wrap;
background-color: #fff;
padding: 0.75rem 1.25rem;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
${MEDIA_QUERY_IS_MOBILE} {
font-size: 1.1rem;
text-align: center;
display: inline-block;
padding: 0 1.15rem 1rem;
}
`;
export const CardBody = styled.div<{ multiComponents?: boolean }>`
flex: 1 1 auto;
min-height: 32rem;
padding: 0.75rem 1.25rem;
${({ multiComponents }) =>
multiComponents &&
css`
> div {
margin-bottom: 3rem;
:first-of-type > label {
margin-top: 0;
}
> label {
font-size: 18px;
margin-bottom: 0.5rem;
}
}
`}
${MEDIA_QUERY_IS_MOBILE} {
padding: 0.5rem 0;
}
`;
export const OtherSpan = styled.span`
opacity: 0.75;
font-size: 0.75em;
margin-top: 0.075em;
margin-left: 0.45em;
`;
export const MenuPortalElement = styled.div<{ menuOpen: boolean; }>`
width: 100%;
margin: 0.5rem 0;
min-height: 115px;
position: relative;
border-radius: 3px;
transition: background-color 0.2s ease-out;
background-color: ${({ menuOpen }) => menuOpen ? 'white' : 'whitesmoke'};
span {
font-weight: 700;
font-size: 1.5em;
text-align: center;
padding: 1.25em 1em;
color: rgba(0,0,0,0.6);
display: ${({ menuOpen }) => menuOpen ? 'none' : 'block'};
}
`;
// =======================================
// Advanced story specific
// =======================================
const SPIN_KEYFRAMES = keyframes`
from {
transform: rotate(0deg);
} to {
transform: rotate(360deg);
}
`;
const SPIN_ANIMATION_CSS = css`animation: ${SPIN_KEYFRAMES} infinite 8s linear;`;
export const ReactSvg = styled.svg<{ isDisabled?: boolean }>`
width: 30px;
height: 30px;
color: #1ea7fd;
fill: currentColor;
display: inline-block;
${({ isDisabled }) => !isDisabled && SPIN_ANIMATION_CSS}
`;
export const ChevronDownSvg = styled.svg<{ menuOpen: boolean }>`
width: 14px;
height: 14px;
fill: currentColor;
transition: transform 0.25s ease-in-out;
${({ menuOpen }) => menuOpen && css`transform: rotate(180deg);`}
`;
export const OptionContainer = styled.div`
height: 100%;
display: flex;
align-items: center;
flex-direction: row;
`;
export const OptionName = styled.span`
color: #515151;
font-weight: 600;
margin-left: 1px;
`;
================================================
FILE: __stories__/helpers/utils/index.ts
================================================
import type { Option } from '../../types';
export const numberWithCommas = (value: number): string => {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export const getRandomInt = (min: number, max: number): number => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
export const stringifyJavaScriptObj = (data: any = {}): string => {
return JSON.stringify(data, null, 2).replace(/"(\w+)"\s*:/g, '$1:');
};
export const mockHttpRequest = async (delay: number = 500): Promise<void> => {
await new Promise(resolve => setTimeout(resolve, delay));
};
export const createOptions = (count: number): Option[] => {
const options: Option[] = [];
for (let i = 0; i < count; i++) {
const value = i + 1;
options.push({
value,
label: `Option ${value}`
});
}
return options;
};
export const createThemeOptions = (themeEnum: any): Option[] => {
return Object.keys(themeEnum).map((key) => ({
value: themeEnum[key],
label: themeEnum[key]
}));
};
export const createAsyncOptions = (count: number, lblSuffix: string = ''): Option[] => {
const options = createOptions(count);
return options.map(({ value, label }: Option) => ({
value,
label: `${label}${lblSuffix ? (' - ' + lblSuffix) : ''}`
}));
};
export const hexToRgba = (hex: string, alpha: number = 1): string => {
const hexReplacer: string = hex.replace(
/^#?([a-f\d])([a-f\d])([a-f\d])$/i,
(_m, r, g, b) => `#${r}${r}${g}${g}${b}${b}`
);
const alphaValid: number = Math.min(1, Math.max(0, alpha));
const rgbParts: number[] = hexReplacer.substring(1).match(/.{2}/g)!.map((x) => parseInt(x, 16));
const rgbaParts = [...rgbParts, alphaValid].join(',');
return `rgba(${rgbaParts})`;
};
================================================
FILE: __stories__/index.stories.tsx
================================================
import { Select } from '../src';
import { useUpdateEffect } from '../src/hooks';
import type { SelectedOption } from '../src/types';
import { toast, ToastContainer } from 'react-toastify';
import type { CityOption, Option, PackageOption } from './types';
import type { MultiParams, MenuOption, SelectRef, Theme } from '../src';
import React, { useMemo, useRef, useState, useEffect, useCallback, Fragment } from 'react';
import {
OPTION_CLS,
OPTION_FOCUSED_CLS,
OPTION_DISABLED_CLS,
OPTION_SELECTED_CLS,
CARET_ICON_CLS,
CLEAR_ICON_CLS,
LOADING_DOTS_CLS,
AUTOSIZE_INPUT_CLS,
MENU_CONTAINER_CLS,
SELECT_CONTAINER_CLS,
CONTROL_CONTAINER_CLS,
LOADING_MSG_DEFAULT
} from '../src/constants';
import {
Button,
Buttons,
Hr,
Title,
SubTitle,
Label,
Columns,
Column,
Content,
Container,
List,
Li,
ListWrapper,
SelectContainer,
Paragraph,
TextHeader,
Checkboxes,
Card,
CardHeader,
CardBody,
OtherSpan,
OptionContainer,
OptionName,
ReactSvg,
ChevronDownSvg,
MenuPortalElement,
ThemeEnum,
ThemeConfigMap,
Checkbox,
CodeMarkup,
PackageLink,
OptionsCountButton,
mockHttpRequest,
getRandomInt,
useCallbackState,
createAsyncOptions,
createOptions,
stringifyJavaScriptObj,
THEME_DEFAULTS,
THEME_OPTIONS,
THEME_CONFIG,
CITY_OPTIONS,
PACKAGE_OPTIONS,
CLASS_NAME_HTML,
REACT_WINDOW_PACKAGE,
TOAST_CONTAINER_PROPS,
STYLED_COMPONENTS_PACKAGE,
REACT_SVG_PROPS,
REACT_SVG_CIRCLE_PROPS,
REACT_SVG_PATH_PROPS,
CHEVRON_SVG_PROPS,
CHEVRON_DOWN_PATH_PROPS
} from './helpers';
export default {
title: 'React Functional Select/Demos'
};
export const SingleSelect = () => {
const [isInvalid, setIsInvalid] = useCallbackState(false);
const [isLoading, setIsLoading] = useCallbackState(false);
const [isDisabled, setIsDisabled] = useCallbackState(false);
const [isClearable, setIsClearable] = useCallbackState(true);
const [isSearchable, setIsSearchable] = useCallbackState(true);
const getOptionValue = useCallback((option: CityOption): number => option.id, []);
const getOptionLabel = useCallback((option: CityOption): string => `${option.city}, ${option.state}`, []);
useEffect(() => {
isDisabled && setIsInvalid(false);
}, [isDisabled, setIsInvalid]);
return (
<Container>
<Title>Single-Select</Title>
<Hr />
<Paragraph>
In this story's source code, notice that the callback function
properties <code>getOptionValue</code> and <code>getOptionLabel</code> are
wrapped in a <code>useCallback</code>. While not required, <em>strongly prefer </em>
memoization of any callback function property whenever possible. This will boost
performance and reduce the amount of renders as these properties are referenced
in the dependency arrays of <code>useCallbacks</code>, <code>useEffects</code>,
and <code>useMemos</code>. When defined in a functional component, wrap in
a <code>useCallback</code>; when defined in a legacy class component, ensure proper
binding to <em>this</em>. Alternatively, if there is no dependency on any state,
you can opt to hoist functions outside of the component entirely.
</Paragraph>
<Paragraph>
The <code>options</code> property should also be memoized. Either consume
it directly from a state management store, or make sure it is stable by
avoiding inline or render-based mutations.
</Paragraph>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Checkboxes>
<Checkbox
label='Searchable'
checked={isSearchable}
onCheck={setIsSearchable}
/>
<Checkbox
label='Clearable'
checked={isClearable}
onCheck={setIsClearable}
/>
<Checkbox
label='Disabled'
checked={isDisabled}
onCheck={setIsDisabled}
/>
<Checkbox
label='Invalid'
checked={isInvalid}
readOnly={isDisabled}
onCheck={setIsInvalid}
/>
<Checkbox
label='Loading'
checked={isLoading}
onCheck={setIsLoading}
/>
</Checkboxes>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
isLoading={isLoading}
isInvalid={isInvalid}
options={CITY_OPTIONS}
isDisabled={isDisabled}
isClearable={isClearable}
isSearchable={isSearchable}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const MultiSelect = () => {
const [openMenuOnClick, setOpenMenuOnClick] = useCallbackState(true);
const [closeMenuOnSelect, setCloseMenuOnSelect] = useCallbackState(true);
const [blurInputOnSelect, setBlurInputOnSelect] = useCallbackState(false);
const [hideSelectedOptions, setHideSelectedOptions] = useCallbackState(true);
const getOptionValue = useCallback(({ id }: CityOption): number => id, []);
const getOptionLabel = useCallback(({ city, state }: CityOption): string => `${city}, ${state}`, []);
// example "renderMultiOptions" property that can be used to further customize labeling for multi-option scenarios
const renderMultiOptions = useCallback(
({ selected, renderOptionLabel }: MultiParams) => (
<Fragment>
{selected.length && renderOptionLabel(selected[0].data)}
{selected.length > 1 && (
<OtherSpan>
{`(+${selected.length - 1} other${selected.length > 2 ? 's' : ''})`}
</OtherSpan>
)}
</Fragment>
),
[]
);
return (
<Container>
<Title>Multi-Select</Title>
<Hr />
<ListWrapper>
Add the <code>isMulti</code> property to allow for multiple selections.
While in multi-select mode, some properties are now applicable and
others become more pertinent.
<List>
<Li>
<TextHeader>hideSelectedOptions?: boolean</TextHeader> - Hide the
selected option from the menu. Default value is <em>false</em>, however,
if undefined and <code>isMulti</code> is <em>true</em>, then its value
defaults to <em>true</em>.
</Li>
<Li>
<TextHeader>closeMenuOnSelect?: boolean</TextHeader> - Close the
menu of options when the user selects an option. Default value is
false, however, it may be benefical to set this property to true for
convenience in multi-select scenarios.
</Li>
<Li>
<TextHeader>renderMultiOptions(params: MultiParams) {'=>'} ReactNode</TextHeader> -
Optional callback function that can be used to further customize the selection
label in multi-select scenarios. <code>params</code> is an object that contains
the <code>selected</code> and <code>renderOptionLabel</code> properties (array
of selected options and function used to render individual option labels,
respectively). When this function is defined, left and right arrow navigation
of individual options is disabled. When using this property, it may be be a good
idea to set the property <code>backspaceClearsValue</code> to <em>false</em> in
order to avoid accidentally clearing all selections when searching.
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Checkboxes>
<Checkbox
label='closeMenuOnSelect'
checked={closeMenuOnSelect}
onCheck={setCloseMenuOnSelect}
/>
<Checkbox
label='hideSelectedOptions'
checked={hideSelectedOptions}
onCheck={setHideSelectedOptions}
/>
<Checkbox
label='blurInputOnSelect'
checked={blurInputOnSelect}
onCheck={setBlurInputOnSelect}
/>
<Checkbox
label='openMenuOnClick (click caret if false)'
checked={openMenuOnClick}
onCheck={setOpenMenuOnClick}
/>
</Checkboxes>
</CardHeader>
<CardBody multiComponents>
<SelectContainer>
<Label>Default</Label>
<Select
isMulti
isClearable
isSearchable
backspaceClearsValue
options={CITY_OPTIONS}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
openMenuOnClick={openMenuOnClick}
blurInputOnSelect={blurInputOnSelect}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
/>
</SelectContainer>
<SelectContainer>
<Label>Custom "renderMultiOptions"</Label>
<Select
isMulti
isClearable
isSearchable
options={CITY_OPTIONS}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
openMenuOnClick={openMenuOnClick}
blurInputOnSelect={blurInputOnSelect}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
renderMultiOptions={renderMultiOptions}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Styling = () => {
const [themeConfig, setThemeConfig] = useState<Theme | undefined>(undefined);
const [selectedOption, setSelectedOption] = useCallbackState<SelectedOption | null>(null);
const memoizedMarkupNode = useMemo(() => (
<CodeMarkup
language='markup'
header='Class Markup'
data={CLASS_NAME_HTML}
/>
), []);
useEffect(() => {
if (selectedOption) {
const { value } = selectedOption;
setThemeConfig(ThemeConfigMap[value!]);
}
}, [selectedOption]);
const noteCodeStyle = { fontWeight: 500 };
const selectWrapperStyle = { marginTop: '1rem' };
const noteStyle = { fontSize: 'inherit', fontWeight: 700 };
const menuItemSize = selectedOption?.value === ThemeEnum.LARGE_TEXT ? 44 : 35;
return (
<Container>
<Title>Styling</Title>
<Hr />
<SubTitle>Theming</SubTitle>
<Columns>
<Column widthPercent={40}>
<Content>
react-functional-select uses <PackageLink {...STYLED_COMPONENTS_PACKAGE} /> to
handle its styling. The root node is wrapped in
styled-component's <code>ThemeProvider</code> wrapper component which gives all
child styled-components access to the provided theme via React's context API.
To override react-functional-select's default theme, pass an object to
the <code>themeConfig</code> property - any matching properties will replace
those in the default theme.
</Content>
<Content>
Starting in <strong>v2.0.0</strong>, some of the nested objects in
the <code>themeConfig</code> object contain a <code>css</code> property
of type <code>string | FlattenSimpleInterpolation | undefined</code> (default value
is undefined). This property can be used to pass raw CSS styles as a string or wrapped
in <PackageLink {...STYLED_COMPONENTS_PACKAGE} /> exported <code>css</code> function.
Those objects are: select, control, icon, menu, noOptions, multiValue, and input.
</Content>
<Content>
Starting in <strong>v2.7.0</strong>, the control object in <code>themeConfig</code> has
the property <code>focusedCss</code> - which is similar to the <code>css</code> property,
except that it is only applied when the select control is focused (and removed when blurred).
</Content>
</Column>
<Column widthPercent={60}>
<CodeMarkup
language='javascript'
data={THEME_DEFAULTS}
header='Theme Defaults'
formatFn={stringifyJavaScriptObj}
/>
</Column>
</Columns>
<SubTitle>Using Classes</SubTitle>
<Columns>
<Column widthPercent={40}>
<Content>
There is also the option to handle styling via CSS classes.
These are the classes that are available:
</Content>
<ListWrapper className='is-class-list'>
<List>
<Li>{SELECT_CONTAINER_CLS}</Li>
<Li>{CONTROL_CONTAINER_CLS}</Li>
<Li>{MENU_CONTAINER_CLS}</Li>
<Li>{AUTOSIZE_INPUT_CLS}</Li>
<Li>{CARET_ICON_CLS}</Li>
<Li>{CLEAR_ICON_CLS}</Li>
<Li>{LOADING_DOTS_CLS}</Li>
<Li>{OPTION_CLS}, {OPTION_FOCUSED_CLS}, {OPTION_SELECTED_CLS}, {OPTION_DISABLED_CLS}</Li>
</List>
</ListWrapper>
</Column>
<Column widthPercent={60}>
{memoizedMarkupNode}
</Column>
</Columns>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>
<em style={noteStyle}>Note: </em>the <code style={noteCodeStyle}>themeConfig</code> property
value provided shoud be properly memoized!
</Label>
</CardHeader>
<CardBody>
<Columns>
<Column widthPercent={40}>
<div style={selectWrapperStyle}>
<Select
isClearable={false}
isSearchable={false}
options={THEME_OPTIONS}
themeConfig={themeConfig}
menuItemSize={menuItemSize}
initialValue={THEME_OPTIONS[0]}
onOptionChange={setSelectedOption}
/>
</div>
</Column>
<Column widthPercent={60}>
<CodeMarkup
data={themeConfig}
language='javascript'
header='theme-config'
formatFn={stringifyJavaScriptObj}
/>
</Column>
</Columns>
</CardBody>
</Card>
</Container>
);
};
export const Events = () => {
const options = useMemo<Option[]>(() => createOptions(5), []);
const [addOnKeyDown, setAddOnKeyDown] = useCallbackState(false);
const [addOnMenuOpen, setAddOnMenuOpen] = useCallbackState(true);
const [addOnMenuClose, setAddOnMenuClose] = useCallbackState(false);
const [addOnInputBlur, setAddOnInputBlur] = useCallbackState(false);
const [addOnInputFocus, setAddOnInputFocus] = useCallbackState(false);
const [addOnOptionChange, setAddOnOptionChange] = useCallbackState(true);
const onMenuOpen = useCallback(() => toast.info('Menu opened'), []);
const onMenuClose = useCallback(() => toast.info('Menu closed'), []);
const onInputBlur = useCallback(() => toast.info('Control blurred'), []);
const onInputFocus = useCallback(() => toast.info('Control focused'), []);
const onKeyDown = useCallback(() => toast.info('keydown event executed'), []);
const onOptionChange = useCallback((option: Option) => toast.info(`Selected option: "${option?.value}"`), []);
return (
<Fragment>
<ToastContainer {...TOAST_CONTAINER_PROPS} />
<Container>
<Title>Events</Title>
<Hr />
<ListWrapper>
There are various callback function properties that are executed following
their associated events:
<List>
<Li>
<TextHeader>onOptionChange(data: any) {'=>'} void</TextHeader> -
executed after an option is selected or removed
</Li>
<Li>
<TextHeader>onMenuOpen(...args: any[]) {'=>'} void</TextHeader> -
executed after the menu is opened
</Li>
<Li>
<TextHeader>onMenuClose(...args: any[]) {'=>'} void</TextHeader> -
executed after the menu is closed
</Li>
<Li>
<TextHeader>onInputChange(value: string) {'=>'} void</TextHeader> -
executed after the input control's value changes
</Li>
<Li>
<TextHeader>onInputBlur(e: FocusEvent{'<'}HTMLInputElement{'>'}) {'=>'} void</TextHeader> -
executed after the input control is blurred
</Li>
<Li>
<TextHeader>onInputFocus(e: FocusEvent{'<'}HTMLInputElement{'>'}) {'=>'} void</TextHeader> -
executed after the input control is focused
</Li>
<Li>
<TextHeader>
onKeyDown(e: KeyboardEvent{'<'}HTMLDivElement{'>'}, input?: string, focusedOption?: FocusedOption) {'=>'} void
</TextHeader> -
executed after the onKeyDown event
</Li>
<Li>
<TextHeader>onSearchChange(value: string) {'=>'} void</TextHeader> -
executed after the input value is persisted to state; this value also evaluates
the <code>inputDelay</code> property for debouncing - this callback is really
only useful when <code>inputDelay</code> is defined, and if not, it probably
makes more sense to use the <code>onInputChange</code> callback
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>Events trigger a toast notification (demo only)</Label>
<Checkboxes>
<Checkbox
label='onOptionChange'
checked={addOnOptionChange}
onCheck={setAddOnOptionChange}
/>
<Checkbox
label='onMenuOpen'
checked={addOnMenuOpen}
onCheck={setAddOnMenuOpen}
/>
<Checkbox
label='onMenuClose'
checked={addOnMenuClose}
onCheck={setAddOnMenuClose}
/>
<Checkbox
label='onInputBlur'
checked={addOnInputBlur}
onCheck={setAddOnInputBlur}
/>
<Checkbox
label='onInputFocus'
checked={addOnInputFocus}
onCheck={setAddOnInputFocus}
/>
<Checkbox
label='onKeyDown'
checked={addOnKeyDown}
onCheck={setAddOnKeyDown}
/>
</Checkboxes>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
options={options}
onKeyDown={addOnKeyDown ? onKeyDown : undefined}
onMenuOpen={addOnMenuOpen ? onMenuOpen : undefined}
onMenuClose={addOnMenuClose ? onMenuClose : undefined}
onInputBlur={addOnInputBlur ? onInputBlur : undefined}
onInputFocus={addOnInputFocus ? onInputFocus : undefined}
onOptionChange={addOnOptionChange ? onOptionChange : undefined}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
</Fragment>
);
};
export const Methods = () => {
const selectRef = useRef<SelectRef | null>(null);
const options = useMemo<Option[]>(() => createOptions(5), []);
const blurSelect = () => selectRef.current?.blur();
const focusSelect = () => selectRef.current?.focus();
const clearValue = () => selectRef.current?.clearValue();
const toggleMenuOpen = () => selectRef.current?.toggleMenu(true);
const updateSelectedOption = () => selectRef.current?.setValue(options[0]);
return (
<Container>
<Title>Methods & Properties</Title>
<Hr />
<ListWrapper>
<strong>5</strong> methods and <strong>1</strong> property are exposed to
wrapping components and are accessible via a forwarded <code>ref</code>.
<List>
<Li>
<TextHeader>blur() {'=>'} void</TextHeader> - blur the control
programatically
</Li>
<Li>
<TextHeader>focus() {'=>'} void</TextHeader> - focus the control
programatically
</Li>
<Li>
<TextHeader>toggleMenu(state?: boolean) {'=>'} void</TextHeader> -
toggle the menu programatically
</Li>
<Li>
<TextHeader>clearValue() {'=>'} void</TextHeader> - clear the current
value programatically <em>(if an option is selected)</em>
</Li>
<Li>
<TextHeader>setValue(option?: any) {'=>'} void</TextHeader> - set the
value programatically <em>(option will be validated)</em>
</Li>
<Li>
<TextHeader>menuOpen: boolean</TextHeader> - Open state of the menu
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>Methods</Label>
<Buttons>
<Button onClick={focusSelect}>Focus</Button>
<Button onClick={blurSelect}>Blur</Button>
<Button onClick={toggleMenuOpen}>Open Menu</Button>
<Button onClick={clearValue}>Clear Value</Button>
<Button onClick={updateSelectedOption}>Set Value</Button>
</Buttons>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
ref={selectRef}
options={options}
initialValue={options[0]}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Filtering = () => {
const [filterIgnoreCase, setFilterIgnoreCase] = useCallbackState(true);
const [useCustomFilterFunc, setUseCustomFilterFunc] = useCallbackState(false);
const [filterIgnoreAccents, setFilterIgnoreAccents] = useCallbackState(false);
const [filterMatchFromStart, setFilterMatchFromStart] = useCallbackState(false);
const getOptionValue = useCallback(({ id }: CityOption): number => id, []);
const getOptionLabel = useCallback(({ city, state }: CityOption): string => `${city}, ${state}`, []);
const getFilterOptionString = useCallback((menuOption: MenuOption): string => menuOption.data.state, []);
const options = useMemo<CityOption[]>(() => [
...CITY_OPTIONS,
{ id: 11, city: 'São Paulo', state: 'BR' }
], []);
return (
<Container>
<Title>Filter Customization</Title>
<Hr />
<ListWrapper>
The default filtering functionality can be customized via the following properties:
<List>
<Li>
<TextHeader>filterIgnoreCase?: boolean</TextHeader> - Filter ignores
case when matching strings. Default value is <em>true</em>.
</Li>
<Li>
<TextHeader>filterIgnoreAccents?: boolean</TextHeader> - Filter
ignores accents when matching strings. Default value is <em>false</em>.
</Li>
<Li>
<TextHeader>filterMatchFrom?: 'any' | 'start'</TextHeader> -
Position in source string to perform match. Default value is <em>'any'</em>.
</Li>
<Li>
<TextHeader>getFilterOptionString(option: MenuOption) {'=>'} string</TextHeader> -
When defined will take each option and generate a string used in
the filtering process. By default, the stringified version of what is
generated by <code>getOptionLabel</code>, if definded, or the option's label
as a fallback. The <code>MenuOption</code> typed parameter
that <code>getFilterOptionString</code> accepts contains a <code>data</code> property
that represents the objects that comprise your <code>options</code> property.
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Checkboxes>
<Checkbox
label='Ignore Case'
checked={filterIgnoreCase}
onCheck={setFilterIgnoreCase}
/>
<Checkbox
label='Ignore Accents'
checked={filterIgnoreAccents}
onCheck={setFilterIgnoreAccents}
/>
<Checkbox
label='Match from the start'
checked={filterMatchFromStart}
onCheck={setFilterMatchFromStart}
/>
<Checkbox
label='Use custom filter function (by state only)'
checked={useCustomFilterFunc}
onCheck={setUseCustomFilterFunc}
/>
</Checkboxes>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
isClearable
options={options}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
filterIgnoreCase={filterIgnoreCase}
filterIgnoreAccents={filterIgnoreAccents}
filterMatchFrom={filterMatchFromStart ? 'start' : 'any'}
getFilterOptionString={useCustomFilterFunc ? getFilterOptionString : undefined}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Virtualization = () => {
const selectRef = useRef<SelectRef | null>(null);
const optionCountList = useMemo(() => [100, 1000, 10000, 25000, 50000, 100000], []);
const [optionsCount, setOptionsCount] = useState(optionCountList[0]);
const options = useMemo<Option[]>(() => createOptions(optionsCount), [optionsCount]);
useUpdateEffect(() => {
selectRef.current?.clearValue();
}, [options]);
return (
<Container>
<Title>Menu List Virtualization</Title>
<Hr />
<ListWrapper>
Option list data is <em>virtualized</em> (or <em>windowed</em>) using
the <PackageLink {...REACT_WINDOW_PACKAGE} /> package. Aside from the
obvious benefits provided by only rendering a small subset of your
enumerable data (rather than bloating the DOM with an excessive amount
of nodes), list virtualization can also assist with:
<List>
<Li>
<strong>Efficient memory allocation</strong>. 'Windowing' naturally
lends itself to the dynamic generation of attributes/values as each
object comes into your renderer's scope (as opposed to allocating
this data upfront for each object in your list). This way you can
perform this work just when you absolutely need to and then can
immediately release it for the GC to cleanup. As an example I am
generating the <code>onClick</code>, <code>id</code>, and{' '}
<code>className</code> attributes for each <code>menuOption</code>{' '}
as they get passed to the <code>{'<'}Option /{'>'}</code> renderer
component.
</Li>
<Li>
<strong>Functional architecture</strong>. The flexibility provided
through only having to manage subsets of your list allows for a more
dynamic application. By breaking your code out into smaller, 'pure'
child components, you can write code that scales well and becomes
open to performance optimizations - most notably, memoization.
Simple components that rely on the props passed to it (rather than
its own managed state) to generate its JSX are likely candidates for
memoization (testing & debugging becomes much easier as well).
</Li>
</List>
<em>Note: </em>Potential performance degradation could be encountered during input
value mutations when the <code>options</code> count reaches the high tens of thousands.
To work around this, the <code>inputDelay</code> (in milliseconds) can be set to debounce
the input value. That way, the <code>menuOptions</code> will not be recalculated on every
keystroke. Although this is an extreme edge case, optimizations have been implemented to
handle such with ease. As proof, 50k and 100k option counts have been included in this
stress-test demo - but again, data sets this large should not be worked with in memory.
Instead, prefer to fetch subsets from a remote data store as needed. For example, using
the <code>async</code> functionality or custom logic in a parent component that accomplishes
something similar.
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>Options Count</Label>
<Buttons>
{optionCountList.map((count) => (
<OptionsCountButton
key={count}
count={count}
optionsCount={optionsCount}
setOptionsCount={setOptionsCount}
/>
))}
</Buttons>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
ref={selectRef}
options={options}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Advanced = () => {
const getOptionValue = useCallback(({ id }: PackageOption): number => id, []);
const getIsOptionDisabled = useCallback(({ name }: PackageOption): boolean => name === PACKAGE_OPTIONS[3].name, []);
const renderOptionLabel = useCallback(
(option: PackageOption) => (
<OptionContainer>
<ReactSvg
{...REACT_SVG_PROPS}
isDisabled={getIsOptionDisabled(option)}
>
<path {...REACT_SVG_PATH_PROPS} />
<circle {...REACT_SVG_CIRCLE_PROPS} />
</ReactSvg>
<OptionName>{option.name}</OptionName>
</OptionContainer>
),
[getIsOptionDisabled]
);
const customCaretIcon = useCallback(
({ menuOpen }) => (
<ChevronDownSvg
menuOpen={menuOpen}
{...CHEVRON_SVG_PROPS}
>
<path {...CHEVRON_DOWN_PATH_PROPS} />
</ChevronDownSvg>
),
[]
);
return (
<Container>
<Title>Advanced Customization</Title>
<Hr />
<ListWrapper>
Implementation using a couple of the more specialized properties.
<List>
<Li>
<TextHeader>renderOptionLabel(option: any) {'=>'} ReactNode</TextHeader> - Callback
function with a return type of <code>ReactNode</code>. Use this property in cases
where the standard <code>getOptionLabel</code> property will not meet your needs (for
instance, you want to render each option's label using custom JSX). More complex
option labels will likely equate to longer render durations - this can translate
into a flash of empty space when a user first starts scrolling. In order to prevent
this, the <code>menuOverscanCount</code> property can be increased to render additional
rows outside of the visible area. The default value for this property is 1 and it is
important to note that increasing this value can negatively impact performance.
</Li>
<Li>
<TextHeader>getIsOptionDisabled(option: any) {'=>'} boolean</TextHeader> - Callback
function with a return type of <code>Boolean</code>. When it evaluates to a value of
true, that option iteration will be rendered <em>disabled</em>. As an alternative, you
can also pass a property of <code>isDisabled</code> with each option. Use one of these two
options - they cannot both be specified.
</Li>
<Li>
<TextHeader>caretIcon: ReactNode | (...args: any[]) {'=>'} ReactNode</TextHeader> - A custom
node or a function that returns a node can used for the <code>caretIcon</code> property.
When using a function, an object containing stateful data is forwarded and can be used to style
your custom node accordingly. The state is <em>{'{ menuOpen, isLoading, isInvalid, isDisabled }'}</em> of
type <code>Record{'<'}string, boolean{'>'}</code>. The <code>clearIcon</code> property has an identical definition.
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>JSX labels, custom caret icon, and disabled option</Label>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
isSearchable={false}
options={PACKAGE_OPTIONS}
themeConfig={THEME_CONFIG}
caretIcon={customCaretIcon}
getOptionValue={getOptionValue}
renderOptionLabel={renderOptionLabel}
getIsOptionDisabled={getIsOptionDisabled}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Portaling = () => {
const portalId = 'menu-portal-test';
const options = useMemo<Option[]>(() => createOptions(3), []);
const [menuOpen, setMenuOpen] = useState(false);
const [menuPortalTarget, setMenuPortalTarget] = useState<HTMLElement | undefined>(undefined);
const onMenuOpen = useCallback(() => setMenuOpen(true), []);
const onMenuClose = useCallback(() => setMenuOpen(false), []);
useEffect(() => {
const portalEl = document.getElementById(portalId) as HTMLElement;
setMenuPortalTarget(portalEl);
}, [portalId]);
return (
<Container>
<Title>Portaling</Title>
<Hr />
<Paragraph>
react-functional-select exposes a <code>menuPortalTarget</code> prop, that
allows you to portal the menu component to a dom node of your choosing. Styling
should be simple enough via normal theme overriding on the menu object and style
application to the wrapping portal element.
</Paragraph>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>Menu component portaled to an element below this text.</Label>
<MenuPortalElement
id={portalId}
menuOpen={menuOpen}
>
<span>portal node</span>
</MenuPortalElement>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
options={options}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
scrollMenuIntoView={false}
menuPortalTarget={menuPortalTarget}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
export const Async = () => {
const delay = 500;
const selectRef = useRef<SelectRef | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [options, setOptions] = useState<Option[]>(() => createAsyncOptions(5));
const onInputChange = useCallback(() => setIsLoading(true), []);
const onSearchChange = useCallback(
async (value: string = '') => {
try {
await mockHttpRequest();
const nextOptions = createAsyncOptions(getRandomInt(1, 5), value && `'${value}'`);
selectRef.current?.clearValue();
setOptions(nextOptions);
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
},
[]
);
return (
<Container>
<Title>Async Mode</Title>
<Hr />
<ListWrapper>
Add the <code>async</code> property to enable async mode. There is one key
difference in core functionality with async mode - changes to search input
value will not cause the <code>useMenuOptions</code> effect to run. The rest
of hooking into async mode is achieved using some combination of the properties
found below. <em>Properties onInputChange and onSearchChange should be memoized.</em>
<List>
<Li>
<TextHeader>onInputChange(value: string) {'=>'} void</TextHeader> -
callback executed directly following the input control's <code>onChange</code> event.
This callback is not debounced, so it fires immediately. This is a good
place to set a stateful loading property in your parent component that is mapped to
react-functional-select's <code>isLoading</code> property.
</Li>
<Li>
<TextHeader>onSearchChange(value: string) {'=>'} void</TextHeader> -
callback executed following component state updates for
the <code>debouncedInputValue</code>. The debounce is set using
the <code>inputDelay</code> property. This callback is a good place for your
http fetch request and post-request logic (i.e. setting isLoading false).
</Li>
<Li>
<TextHeader>inputDelay?: number</TextHeader> - As mentioned above, this can be
set to a positive integer in order to debounce updates to the search input value
following input change events. This property directly maps to the <code>delay</code> in
milliseconds passed to the <code>setTimeout</code> method.
</Li>
<Li>
<TextHeader>isLoading?: boolean</TextHeader> - When true, a loading animation will
appear in the far-right of the control and take the place of the clear icon (if shown).
Additionally, it will hide options in the menu and instead, display a loading message.
The loading message text defaults to '{LOADING_MSG_DEFAULT}', but can be overriden via
the <code>loadingMsg</code> property.
</Li>
</List>
</ListWrapper>
<SubTitle>Demo</SubTitle>
<Hr />
<Card>
<CardHeader>
<Label>Search debounced 500ms and mock HTTP call resolves after {delay}ms</Label>
</CardHeader>
<CardBody>
<SelectContainer>
<Select
async
isClearable
ref={selectRef}
options={options}
inputDelay={delay}
isLoading={isLoading}
onInputChange={onInputChange}
onSearchChange={onSearchChange}
/>
</SelectContainer>
</CardBody>
</Card>
</Container>
);
};
================================================
FILE: __stories__/types/index.d.ts
================================================
export type Option = Readonly<{
label: string | number;
value: string | number;
}>;
export type CityOption = Readonly<{
id: number;
city: string;
state: string;
}>;
export type PackageOption = Readonly<{
id: number;
name: string;
}>;
================================================
FILE: __tests__/AriaLiveRegion.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import { render } from '@testing-library/react';
import AriaLiveRegion from '../src/components/AriaLiveRegion';
import { getSelectedOptionMulti, ThemeWrapper } from './helpers';
import { ARIA_LIVE_CONTEXT_ID, ARIA_LIVE_SELECTION_ID } from '../src/constants';
import type { AriaLiveAttribute, FocusedOption, SelectedOption } from '../src/types';
type AriaLiveRegionProps = ComponentProps<typeof AriaLiveRegion>;
// ============================================
// Helper functions for AriaLiveRegion component
// ============================================
const renderAriaLiveRegion = (props: AriaLiveRegionProps) => {
return render(
<ThemeWrapper>
<AriaLiveRegion {...props} />
</ThemeWrapper>
);
};
const selectedOption: SelectedOption[] = getSelectedOptionMulti();
const focusedOption: FocusedOption = { index: 0, ...selectedOption[0] };
const BASE_PROPS: AriaLiveRegionProps = {
focusedOption,
selectedOption,
menuOpen: true,
isFocused: true,
isSearchable: true,
ariaLive: 'polite',
inputValue: 'search query',
optionCount: selectedOption.length
} as const;
// ============================================
// Test cases
// ============================================
test('AriaLiveRegion component mounts and renders without error & can query childNodes by id attributes', () => {
const { container } = renderAriaLiveRegion(BASE_PROPS);
const childNodeIds = [ARIA_LIVE_CONTEXT_ID, ARIA_LIVE_SELECTION_ID];
childNodeIds.forEach((id) => {
const childNode = container.querySelector(`#${id}`);
expect(childNode).toBeInTheDocument();
});
});
test('"ariaLive" prop can be passed as one of the accepted aria-live values and the root A11yText span element reflects it accordingly', () => {
const ariaLive: AriaLiveAttribute = 'assertive';
const props = { ...BASE_PROPS, ariaLive };
const { container } = renderAriaLiveRegion(props);
const a11yTextRootSpanEl = container.firstChild;
expect(a11yTextRootSpanEl).toHaveAttribute('aria-live', ariaLive);
});
================================================
FILE: __tests__/AutosizeInput.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import { ThemeWrapper } from './helpers';
import userEvent from '@testing-library/user-event';
import { render, fireEvent } from '@testing-library/react';
import AutosizeInput from '../src/components/AutosizeInput';
import { AUTOSIZE_INPUT_CLS, AUTOSIZE_INPUT_TESTID } from '../src/constants';
type AutosizeInputProps = ComponentProps<typeof AutosizeInput>;
// ============================================
// Helper functions for AutosizeInput component
// ============================================
const renderAutosizeInput = (props: AutosizeInputProps) => {
return {
user: userEvent.setup(),
...render(
<ThemeWrapper>
<AutosizeInput {...props} />
</ThemeWrapper>
)
};
};
const onBlurSpy = jest.fn();
const onFocusSpy = jest.fn();
const onChangeSpy = jest.fn();
const BASE_PROPS: AutosizeInputProps = {
inputValue: '',
readOnly: false,
menuOpen: false,
onBlur: onBlurSpy,
onFocus: onFocusSpy,
onChange: onChangeSpy,
hasSelectedOptions: false
} as const;
// ============================================
// Test cases
// ============================================
test('input element has a static className (enables styling via classic CSS)', () => {
const { getByTestId } = renderAutosizeInput(BASE_PROPS);
expect(getByTestId(AUTOSIZE_INPUT_TESTID!)).toHaveClass(AUTOSIZE_INPUT_CLS);
});
test('input has functional, optional ARIA attributes', () => {
const props = {
...BASE_PROPS,
ariaLabel: 'test-label',
ariaLabelledBy: 'test-labelledby',
};
const { getByTestId } = renderAutosizeInput(props);
const ariaAttrs = ['aria-label', 'aria-labelledby', 'aria-autocomplete'];
ariaAttrs.forEach((attr: string) => {
expect(getByTestId(AUTOSIZE_INPUT_TESTID!)).toHaveAttribute(attr);
});
});
test('when "id" has a non-empty string value, input element should get an "id" attribute reflecting that value', () => {
const inputId = 'test-input-id';
const props = { ...BASE_PROPS, id: inputId };
const { getByTestId } = renderAutosizeInput(props);
expect(getByTestId(AUTOSIZE_INPUT_TESTID!)).toHaveAttribute('id', inputId);
});
test('when "readOnly" = true, the onChange event handler should not be attached to input and the "readonly" attribute is added', async () => {
const props = { ...BASE_PROPS, readOnly: true };
const { user, getByTestId } = renderAutosizeInput(props);
const inputElement = getByTestId(AUTOSIZE_INPUT_TESTID!);
await user.type(inputElement, 'no change');
expect(onChangeSpy).not.toBeCalled();
expect(inputElement).toHaveAttribute('readonly');
});
test('"blur" and "focus" events with callback handlers are attached to the input element', () => {
const { getByTestId } = renderAutosizeInput(BASE_PROPS);
const inputElement = getByTestId(AUTOSIZE_INPUT_TESTID!);
fireEvent.blur(inputElement);
fireEvent.focus(inputElement);
expect(onBlurSpy).toBeCalled();
expect(onFocusSpy).toBeCalled();
});
================================================
FILE: __tests__/IndicatorIcons.test.tsx
================================================
import React, { type ReactNode, type ComponentProps } from 'react';
import { ThemeWrapper } from './helpers';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import IndicatorIcons from '../src/components/IndicatorIcons';
import { CLEAR_ICON_CLS, CLEAR_ICON_TESTID, CARET_ICON_TESTID } from '../src/constants';
type IndicatorIconsProps = ComponentProps<typeof IndicatorIcons>;
// ============================================
// Helper functions for IndicatorIcons component
// ============================================
const renderIndicatorIcons = (props: IndicatorIconsProps) => {
return {
user: userEvent.setup(),
...render(
<ThemeWrapper>
<IndicatorIcons {...props} />
</ThemeWrapper>
)
};
};
const onClearMouseDownSpy = jest.fn();
const onCaretMouseDownSpy = jest.fn();
const BASE_PROPS: IndicatorIconsProps = {
menuOpen: false,
showClear: true,
onClearMouseDown: onClearMouseDownSpy,
onCaretMouseDown: onCaretMouseDownSpy
} as const;
const customIconFn = (props: Partial<IndicatorIconsProps>): ReactNode => {
const { menuOpen, isLoading, isInvalid, isDisabled } = props;
const testIdText = `${menuOpen}-${isLoading}-${isInvalid}-${isDisabled}`;
return (
<span data-testid={testIdText}>
custom_icon
</span>
);
};
// ============================================
// Test cases
// ============================================
test('clear icon has a static className (enables styling via classic CSS)', () => {
const { getByTestId } = renderIndicatorIcons(BASE_PROPS);
const firstChildOfClearIconElement = getByTestId(CLEAR_ICON_TESTID!).firstChild;
expect(firstChildOfClearIconElement).toHaveClass(CLEAR_ICON_CLS);
});
test('clear indicator has functioning "click" user interactions', async () => {
const { user, getByTestId } = renderIndicatorIcons(BASE_PROPS);
const clearIndicatorEl = getByTestId(CLEAR_ICON_TESTID!);
await user.click(clearIndicatorEl);
expect(onClearMouseDownSpy).toBeCalled();
});
test('caret indicator has functioning "click" user interactions', async () => {
const { user, getByTestId } = renderIndicatorIcons(BASE_PROPS);
const caretIndicatorEl = getByTestId(CARET_ICON_TESTID!);
await user.click(caretIndicatorEl);
expect(onCaretMouseDownSpy).toBeCalled();
});
test('clear icon is not rendered and loading animation is rendered when "isLoading" = true', () => {
const props = { ...BASE_PROPS, isLoading: true };
const { queryByTestId } = renderIndicatorIcons(props);
expect(queryByTestId(CLEAR_ICON_TESTID!)).toBeNull();
});
test('loading can render as a custom node (instead of default LoadingDots.tsx component)', () => {
const loadingNodeText = 'loading-node';
const loadingNode = <span>{loadingNodeText}</span>;
const props = {
...BASE_PROPS,
loadingNode,
isLoading: true,
};
const { getByText } = renderIndicatorIcons(props);
expect(getByText(loadingNodeText)).toBeInTheDocument();
});
test('clear icon can render as a ReactNode', () => {
const clearIconText = 'clear-icon-node';
const clearIcon = <span>{clearIconText}</span>;
const props = { ...BASE_PROPS, clearIcon };
const { getByText } = renderIndicatorIcons(props);
expect(getByText(clearIconText)).toBeInTheDocument();
});
test('clear icon can render as a callback function with return type of ReactNode - callback accepts forwarded state props from wrapping component.', () => {
const props = {
...BASE_PROPS,
menuOpen: true,
clearIcon: customIconFn
};
const { getByTestId } = renderIndicatorIcons(props);
// Build test-id from forwarded state javascript object payload
const { menuOpen, isLoading, isInvalid, isDisabled } = props;
const forwardedStateId = `${menuOpen}-${isLoading}-${isInvalid}-${isDisabled}`
expect(getByTestId(forwardedStateId)).toBeInTheDocument();
});
test('caret icon can render as a ReactNode', () => {
const caretIconText = 'caret-icon-node';
const caretIcon = <span>{caretIconText}</span>;
const props = { ...BASE_PROPS, caretIcon };
const { getByText } = renderIndicatorIcons(props);
expect(getByText(caretIconText)).toBeInTheDocument();
});
test('caret icon can render as a callback function with return type of ReactNode - callback accepts forwarded state props from wrapping component.', () => {
const props = { ...BASE_PROPS, menuOpen: true, caretIcon: customIconFn };
const { getByTestId } = renderIndicatorIcons(props);
// Build test-id from forwarded state javascript object payload
const { menuOpen, isLoading, isInvalid, isDisabled } = props;
const forwardedStateId = `${menuOpen}-${isLoading}-${isInvalid}-${isDisabled}`
expect(getByTestId(forwardedStateId)).toBeInTheDocument();
});
================================================
FILE: __tests__/LoadingDots.test.tsx
================================================
import React from 'react';
import { ThemeWrapper } from './helpers';
import { render } from '@testing-library/react';
import LoadingDots from '../src/components/IndicatorIcons/LoadingDots';
// ============================================
// Helper functions for AriaLiveRegion component
// ============================================
const renderAriaLiveRegion = () => {
return render(
<ThemeWrapper>
<LoadingDots />
</ThemeWrapper>
);
};
// ============================================
// Test cases
// ============================================
test('LoadingDots component mounts and renders without error', () => {
const { container } = renderAriaLiveRegion();
expect(container.hasChildNodes()).toBeTruthy();
});
================================================
FILE: __tests__/MenuList.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import { ThemeWrapper } from './helpers';
import type { MenuOption } from '../src';
import { render } from '@testing-library/react';
import MenuList from '../src/components/Menu/MenuList';
import { MENU_OPTIONS, renderOptionLabelMock } from './helpers/utils';
import {
MENU_ITEM_SIZE_DEFAULT,
MENU_MAX_HEIGHT_DEFAULT,
LOADING_MSG_DEFAULT,
NO_OPTIONS_MSG_DEFAULT,
FOCUSED_OPTION_DEFAULT
} from '../src/constants';
type MenuListProps = ComponentProps<typeof MenuList>;
// ============================================
// Helper functions for Menu component
// ============================================
const renderMenuList = (props: MenuListProps) => {
return render(
<ThemeWrapper>
<MenuList {...props} />
</ThemeWrapper>
);
};
const BASE_PROPS: MenuListProps = {
width: '100%',
memoOptions: false,
menuOptions: MENU_OPTIONS,
itemKeySelector: undefined,
fixedSizeListRef: undefined,
height: MENU_MAX_HEIGHT_DEFAULT,
loadingMsg: LOADING_MSG_DEFAULT,
itemSize: MENU_ITEM_SIZE_DEFAULT,
noOptionsMsg: NO_OPTIONS_MSG_DEFAULT,
selectOption: jest.fn(),
renderOptionLabel: renderOptionLabelMock,
focusedOptionIndex: FOCUSED_OPTION_DEFAULT.index
} as const;
// ============================================
// Test cases
// ============================================
test('MenuList component mounts and renders successfully when "menuOptions" array has items', () => {
const { getByText } = renderMenuList(BASE_PROPS);
// Assert react-window + Option.tsx renders each menuOption correctly
BASE_PROPS.menuOptions.forEach(({ label }: MenuOption) => {
expect(getByText(String(label))).toBeInTheDocument();
});
});
test('The "itemKeySelector" property is used in "react-window" function property "itemKey" to select unqiue key based on property value rather than using default index for each option', () => {
const props = { ...BASE_PROPS, itemKeySelector: 'value' };
const { getByText } = renderMenuList(props);
// Assert react-window + Option.tsx renders each menuOption correctly
props.menuOptions.forEach(({ label }: MenuOption) => {
expect(getByText(String(label))).toBeInTheDocument();
});
});
test('The "No Options" message element is NOT rendered when "menuOptions" length > 0', () => {
const { queryByText } = renderMenuList(BASE_PROPS);
expect(queryByText(BASE_PROPS.noOptionsMsg!)).toBeNull();
});
test('The "No Options" message element is rendered when "menuOptions" length === 0', () => {
const props = { ...BASE_PROPS, menuOptions: [] };
const { getByText } = renderMenuList(props);
expect(getByText(props.noOptionsMsg!)).toBeInTheDocument();
});
test('The "Loading" message element is NOT rendered when "isLoading" !== true', () => {
const { queryByText } = renderMenuList(BASE_PROPS);
expect(queryByText(BASE_PROPS.loadingMsg)).toBeNull();
});
test('The "Loading" message element is rendered when "isLoading" === true', () => {
const props = { ...BASE_PROPS, isLoading: true };
const { getByText } = renderMenuList(props);
expect(getByText(props.loadingMsg)).toBeInTheDocument();
});
================================================
FILE: __tests__/MultiValue.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CLEAR_ICON_MV_TESTID } from '../src/constants';
import MultiValue from '../src/components/Value/MultiValue';
import { renderOptionLabelMock, getOptionSingle, ThemeWrapper, type Option } from './helpers';
type MultiValueProps = ComponentProps<typeof MultiValue>;
// ============================================
// Helper functions for MultiValue component
// ============================================
const renderMultiValue = (props: MultiValueProps) => {
return {
user: userEvent.setup(),
...render(
<ThemeWrapper>
<MultiValue {...props} />
</ThemeWrapper>
)
};
};
const removeSelectedOptionSpy = jest.fn();
const renderOptionLabelSpy = renderOptionLabelMock;
const data: Option = getOptionSingle();
const BASE_PROPS: MultiValueProps = {
data,
isFocused: false,
value: data.value,
renderOptionLabel: renderOptionLabelSpy,
removeSelectedOption: removeSelectedOptionSpy
} as const;
// ============================================
// Test cases
// ============================================
test('"renderOptionLabel" callback should be executed and should render the selected option label text', () => {
const { getByText } = renderMultiValue(BASE_PROPS);
expect(renderOptionLabelSpy).toBeCalled();
expect(getByText(BASE_PROPS.data.label)).toBeInTheDocument();
});
test('clear indicator has functioning "click" user events', async () => {
const { user, getAllByTestId } = renderMultiValue(BASE_PROPS);
const firstClearIconEl = getAllByTestId(CLEAR_ICON_MV_TESTID!)[0];
await user.click(firstClearIconEl);
expect(removeSelectedOptionSpy).toBeCalled();
});
================================================
FILE: __tests__/Option.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import type { CSSProperties } from 'react';
import { render } from '@testing-library/react';
import Option from '../src/components/Menu/Option';
import userEvent from '@testing-library/user-event';
import { OPTION_DISABLED_CLS } from '../src/constants';
import { renderOptionLabelMock, stringifyCSSProperties, ThemeWrapper, MENU_OPTIONS } from './helpers';
type OptionProps = ComponentProps<typeof Option>;
// ============================================
// Helper functions & test data for Option.tsx component
// ============================================
const renderOption = (props: OptionProps) => {
return {
user: userEvent.setup(),
...render(
<ThemeWrapper>
<Option {...props} />
</ThemeWrapper>
)
};
};
const OPTION_STYLE: CSSProperties = {
top: '0px',
left: '0px',
width: '100%',
height: '35px',
position: 'absolute'
} as const;
const onClickSelectOptionSpy = jest.fn();
const renderOptionLabelSpy = renderOptionLabelMock;
const createOptionProps = (
index = 0,
focusedOptionIndex = 0,
memoOptions = false
) => {
const props: OptionProps = {
index,
style: OPTION_STYLE,
data: {
memoOptions,
focusedOptionIndex,
menuOptions: MENU_OPTIONS,
selectOption: onClickSelectOptionSpy,
renderOptionLabel: renderOptionLabelSpy
}
};
return {
props,
renderOptionLabelSpy,
onClickSelectOptionSpy
};
};
// ============================================
// Test cases
// ============================================
test('option parent element renders dynamic style attribute correctly', () => {
const { props } = createOptionProps();
const { container } = renderOption(props);
const optionParentEl = container.querySelector('div');
const optionCssProps = stringifyCSSProperties(OPTION_STYLE);
expect(optionParentEl).toHaveAttribute('style', optionCssProps);
});
test('"renderOptionLabel" callback should be executed and the result rendered to DOM', () => {
const { props, renderOptionLabelSpy } = createOptionProps();
const { label } = props.data.menuOptions[props.index];
const { getByText } = renderOption(props);
expect(renderOptionLabelSpy).toHaveBeenCalled();
expect(getByText(String(label))).toBeInTheDocument();
});
test(`option with "isDisabled" = TRUE should have an onClick handler and the ${OPTION_DISABLED_CLS} class added to its classList`, async () => {
const firstDisabledIdx = MENU_OPTIONS.findIndex((x) => x.isDisabled);
const { props, onClickSelectOptionSpy } = createOptionProps(firstDisabledIdx);
const { user, container } = renderOption(props);
const optionParentEl = container.querySelector('div') as HTMLDivElement;
await user.click(optionParentEl);
expect(onClickSelectOptionSpy).toBeCalled();
expect(optionParentEl).toHaveClass(OPTION_DISABLED_CLS);
});
================================================
FILE: __tests__/ReactSSR.test.tsx
================================================
import React from 'react';
import { Select } from '../src';
import { renderToString } from 'react-dom/server';
test('Select component can be rendered using react-dom/server', () => {
expect(() => renderToString(<Select />)).not.toThrowError();
});
================================================
FILE: __tests__/Select.test.tsx
================================================
import { Select } from '../src';
import React, { type ComponentProps } from 'react';
import userEvent from '@testing-library/user-event';
import { render, fireEvent } from '@testing-library/react';
import {
MENU_CONTAINER_CLS,
SELECT_CONTAINER_CLS,
CONTROL_CONTAINER_CLS,
MENU_CONTAINER_TESTID,
AUTOSIZE_INPUT_TESTID,
SELECT_CONTAINER_TESTID,
CONTROL_CONTAINER_TESTID
} from '../src/constants';
type SelectProps = ComponentProps<typeof Select>;
// ============================================
// Helper functions for Select component
// ============================================
const renderSelect = (props?: SelectProps) => ({
user: userEvent.setup(),
...render(<Select {...props} />)
});
// ============================================
// Test cases
// ============================================
test('container elements have static className value (enables styling via classic CSS)', () => {
const { getByTestId } = renderSelect();
expect(getByTestId(SELECT_CONTAINER_TESTID!)).toHaveClass(SELECT_CONTAINER_CLS);
expect(getByTestId(CONTROL_CONTAINER_TESTID!)).toHaveClass(CONTROL_CONTAINER_CLS);
expect(getByTestId(MENU_CONTAINER_TESTID!)).toHaveClass(MENU_CONTAINER_CLS);
});
test('id attributes are added to DOM if defined ("menuId", "selectId", "inputId" props)', () => {
const props = {
menuId: 'test-menu-id',
inputId: 'test-input-id',
selectId: 'test-select-id'
};
const { getByTestId } = renderSelect(props);
expect(getByTestId(MENU_CONTAINER_TESTID!)).toHaveAttribute('id', props.menuId);
expect(getByTestId(AUTOSIZE_INPUT_TESTID!)).toHaveAttribute('id', props.inputId);
expect(getByTestId(SELECT_CONTAINER_TESTID!)).toHaveAttribute('id', props.selectId);
});
test('"onInputFocus" callback should be fired when input is focused (if a defined function)', () => {
const onFocusSpy = jest.fn();
const props = { onInputFocus: onFocusSpy };
const { getByTestId } = renderSelect(props);
fireEvent.focus(getByTestId(AUTOSIZE_INPUT_TESTID!));
expect(onFocusSpy).toBeCalled();
});
test('"onInputBlur" callback should be fired on blur (if a defined function)', () => {
const onBlurSpy = jest.fn();
const props = { onInputBlur: onBlurSpy };
const { getByTestId } = renderSelect(props);
fireEvent.blur(getByTestId(AUTOSIZE_INPUT_TESTID!));
expect(onBlurSpy).toBeCalled();
});
test('toggling the menu to open/close fires corresponding callbacks "onMenuOpen" and "onMenuClose" (if they are defined functions)', async () => {
const onMenuOpenSpy = jest.fn();
const onMenuCloseSpy = jest.fn();
const props = {
onMenuOpen: onMenuOpenSpy,
onMenuClose: onMenuCloseSpy,
};
const { user, getByTestId } = renderSelect(props);
const controlWrapperEl = getByTestId(CONTROL_CONTAINER_TESTID!);
await user.click(controlWrapperEl);
await user.click(controlWrapperEl);
expect(onMenuOpenSpy).toBeCalled();
expect(onMenuCloseSpy).toBeCalled();
});
test('When "lazyLoadMenu" property = true, then menu components are only rendered in DOM when "menuOpen" state = true', () => {
const props = { lazyLoadMenu: true };
const { queryByTestId } = renderSelect(props);
expect(queryByTestId(MENU_CONTAINER_TESTID!)).toBeNull();
});
================================================
FILE: __tests__/Value.test.tsx
================================================
import React, { type ComponentProps } from 'react';
import Value from '../src/components/Value';
import { render } from '@testing-library/react';
import type { CallbackFn, SelectedOption } from '../src/types';
import { PLACEHOLDER_DEFAULT, EMPTY_ARRAY } from '../src/constants';
import { renderOptionLabelMock, renderMultiOptionsMock, getSelectedOptionSingle, ThemeWrapper } from './helpers';
type ValueProps = ComponentProps<typeof Value>;
// ============================================
// Helper functions for Value component
// ============================================
const renderValue = (props: ValueProps) => {
return render(
<ThemeWrapper>
<Value {...props} />
</ThemeWrapper>
);
};
const rerenderValue = (props: ValueProps, rerender: CallbackFn): void => {
rerender(
<ThemeWrapper>
<Value {...props} />
</ThemeWrapper>
);
};
const removeSelectedOptionSpy = jest.fn();
const renderOptionLabelSpy = renderOptionLabelMock;
const renderMultiOptionsSpy = renderMultiOptionsMock;
const BASE_PROPS: ValueProps = {
isMulti: false,
hasInput: false,
focusedMultiValue: null,
selectedOption: EMPTY_ARRAY,
renderMultiOptions: undefined,
placeholder: PLACEHOLDER_DEFAULT,
renderOptionLabel: renderOptionLabelSpy,
removeSelectedOption: removeSelectedOptionSpy
} as const;
// ============================================
// Test cases
// ============================================
test('"placeholder" text displays when no option is selected', () => {
const { getByText } = renderValue(BASE_PROPS);
expect(getByText(PLACEHOLDER_DEFAULT)).toBeInTheDocument();
});
test('component renders NULL if "hasInput" is true AND ("isMulti" !== true OR ("isMulti" === true AND "selectedOptions" is empty))', () => {
// Render with truthy "inputValue" and "isMulti" = false
const singleProps = { ...BASE_PROPS, hasInput: true };
const { container, rerender } = renderValue(singleProps);
expect(container.hasChildNodes()).toBeFalsy();
// Re-render with truthy "inputValue" and "isMulti" = true
const multiProps = { ...singleProps, isMulti: true };
rerenderValue(multiProps, rerender);
expect(container.hasChildNodes()).toBeFalsy();
});
test('"renderOptionLabel" callback should be executed when an option is selected and should render the selected option label text', () => {
const selectedOption = getSelectedOptionSingle();
const props = { ...BASE_PROPS, selectedOption };
const { getByText } = renderValue(props);
expect(renderOptionLabelSpy).toHaveBeenCalledTimes(1);
selectedOption.forEach(({ label }: SelectedOption) => {
expect(getByText(String(label))).toBeInTheDocument();
});
});
test('"renderMultiOptions" callback should be executed when "isMulti" = true and "renderMultiOptions" is a function and at least one option is selected', () => {
const props = {
...BASE_PROPS,
isMulti: true,
selectedOption: getSelectedOptionSingle(),
renderMultiOptions: renderMultiOptionsSpy
};
renderValue(props);
expect(renderMultiOptionsSpy).toHaveBeenCalledTimes(1);
});
================================================
FILE: __tests__/helpers/ThemeWrapper.tsx
================================================
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { DEFAULT_THEME } from '../../src/constants';
const ThemeWrapper = ({ children }) => (
<ThemeProvider theme={DEFAULT_THEME}>
{children}
</ThemeProvider>
);
export default ThemeWrapper;
================================================
FILE: __tests__/helpers/index.ts
================================================
export * from './utils';
export { default as ThemeWrapper } from './ThemeWrapper';
================================================
FILE: __tests__/helpers/utils.ts
================================================
import type { ReactNode, CSSProperties } from 'react';
import type { MultiParams, MenuOption } from '../../src';
import type { OptionData, SelectedOption } from '../../src/types';
// ============================================
// Basic "options" & "selectedOption" data
// ============================================
export type Option = Readonly<{
label: string | number;
value: string | number;
}>;
export const OPTIONS: Option[] = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' }
];
export const getSelectedOptionMulti = (): SelectedOption[] => [...OPTIONS];
export const getOptionSingle = (index: number = 0): Option => ({ ...OPTIONS[index] });
export const getSelectedOptionSingle = (): SelectedOption[] => {
const data = getOptionSingle();
const { value, label } = data;
return [{ data, value, label }];
};
// ============================================
// "menuOptions" data
// ============================================
export const MENU_OPTION_SELECTED: MenuOption = {
isSelected: true,
isDisabled: false,
value: 1,
label: 'Option 1',
data: {
value: 1,
label: 'Option 1'
}
} as const;
export const MENU_OPTION_DISABLED: MenuOption = {
isDisabled: true,
isSelected: false,
value: 2,
label: 'Option 2',
data: {
value: 2,
label: 'Option 2'
}
} as const;
export const MENU_OPTIONS: MenuOption[] = [MENU_OPTION_SELECTED, MENU_OPTION_DISABLED];
// ============================================
// Generic utils & data
// ============================================
export const stringifyCSSProperties = (obj: CSSProperties = {}): string => {
return Object.keys(obj)
.map((key) => `${key}: ${obj[key]};`)
.join(' ');
};
export const renderMultiOptionsMock = jest.fn(
({selected, renderOptionLabel}: MultiParams): ReactNode => {
return selected.map((x) => renderOptionLabel(x.data)).join(', ');
}
);
export const renderOptionLabelMock = jest.fn(({ label }: OptionData): ReactNode => label);
================================================
FILE: babel.config.js
================================================
module.exports = (api) => {
const isTest = api.env('test');
const targets = isTest ? { node: 'current' } : undefined;
const presets = [
['@babel/preset-env', {targets, loose: true}],
['@babel/preset-react', {runtime: 'automatic'}],
'@babel/preset-typescript',
];
const plugins = [
[
'babel-plugin-styled-components',
{
ssr: true,
pure: true,
fileName: false,
minify: !isTest,
displayName: !isTest,
transpileTemplateLiterals: true,
},
],
];
return {
presets,
plugins
};
};
================================================
FILE: jest.config.js
================================================
module.exports = {
transform: {'\\.[jt]sx?$': 'babel-jest'},
testEnvironment: '<rootDir>/.test/custom-test-env.ts',
setupFilesAfterEnv: ['<rootDir>/.test/setup-tests.ts'],
testMatch: ['<rootDir>/__tests__/*?(*.)test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/src/'],
};
================================================
FILE: package.json
================================================
{
"name": "react-functional-select",
"version": "5.0.0",
"description": "Micro-sized and micro-optimized select component for React.js",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"umd": "dist/index.umd.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"LICENSE",
"README.md"
],
"sideEffects": false,
"license": "MIT",
"author": {
"name": "Matt Areddia",
"email": "mareddia@proton.me"
},
"bugs": {
"url": "https://github.com/based-ghost/react-functional-select/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/based-ghost/react-functional-select.git"
},
"homepage": "https://master--625676b6922472003af898b4.chromatic.com",
"keywords": [
"react",
"react-components",
"react-functional-select",
"select",
"dropdown",
"styled-components",
"virtualization",
"windowing",
"multi-select",
"performance",
"functional"
],
"engines": {
"node": ">= 14"
},
"devDependencies": {
"@babel/cli": "^7.20.7",
"@babel/core": "^7.20.7",
"@babel/plugin-transform-runtime": "^7.19.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.2.1",
"@rollup/plugin-typescript": "^10.0.1",
"@storybook/addon-storysource": "^6.5.15",
"@storybook/addons": "^6.5.15",
"@storybook/builder-webpack5": "^6.5.15",
"@storybook/manager-webpack5": "^6.5.15",
"@storybook/react": "^6.5.15",
"@storybook/theming": "^6.5.15",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.2.4",
"@types/node": "^18.11.17",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-window": "^1.8.5",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"babel-jest": "^29.3.1",
"babel-loader": "^9.1.0",
"babel-plugin-styled-components": "^2.0.7",
"chromatic": "^6.14.0",
"cross-env": "^7.0.3",
"eslint": "^8.30.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-syntax-highlighter": "^15.5.0",
"react-toastify": "^9.1.1",
"react-window": "^1.8.8",
"rimraf": "^3.0.2",
"rollup": "^3.8.1",
"styled-components": "^5.3.6",
"typescript": "^4.9.4",
"webpack": "^5.75.0"
},
"peerDependencies": {
"react": ">=16.8.6",
"react-dom": ">=16.8.6",
"react-window": ">=1.8.5",
"styled-components": ">=4.4.0"
},
"scripts": {
"clean-build": "npm-run-all clean build",
"build": "tsc --outDir dist --declarationDir dist --declaration true --emitDeclarationOnly true && rollup --bundleConfigAsCjs -c",
"build-watch": "rollup -c -w",
"clean": "rimraf dist",
"typecheck": "tsc -p --noEmit",
"type-check:watch": "npm run type-check -- --watch",
"test": "cross-env NODE_ENV=test jest -c jest.config.js",
"test:watch": "cross-env NODE_ENV=test jest -c jest.config.js --watch",
"start": "run-s storybook",
"storybook": "start-storybook -c .storybook -p 9009 --no-manager-cache",
"build-storybook": "build-storybook -c .storybook -o storybook-static",
"lint": "eslint \"+(.storybook|__stories__|__tests__|config|src)/**/*.{ts,js}\"",
"chromatic": "chromatic --force-rebuild --auto-accept-changes --exit-zero-on-changes"
},
"dependencies": {
"@babel/runtime": "^7.20.7"
}
}
================================================
FILE: rollup.config.js
================================================
import path from 'path';
import babel from '@rollup/plugin-babel';
import terser from '@rollup/plugin-terser';
import replace from '@rollup/plugin-replace';
import { DEFAULT_EXTENSIONS } from '@babel/core';
import typescript from '@rollup/plugin-typescript';
import pkg from './package.json' assert { type: 'json' };
const globals = {
'react': 'React',
'react-dom': 'ReactDOM',
'styled-components': 'styled',
'react-window': 'ReactWindow'
};
const input = './src/index.ts';
const name = 'ReactFunctionalSelect';
const external = (id) => !id.startsWith('\0') && !id.startsWith('.') && !path.isAbsolute(id);
/**
* Replace Plugin config
*/
const replacePlugin = replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
});
// Remove data-testid attribute (since undefined in non-test environments)
// Perform as final step in transformed, bundled code (for esm and cjs builds)
const removeTestIdPlugin = replace({
preventAssignment: true,
',"data-testid":undefined': '',
delimiters: ['', '']
});
/**
* Babel Plugin config (prevents use of root babel.config.js with babelrc and configFile as false)
*/
const babelPlugin = (useESModules) => {
const targets = useESModules ? { esmodules: true } : undefined;
return babel({
babelrc: false,
configFile: false,
babelHelpers: 'runtime',
exclude: 'node_modules/**',
extensions: [...DEFAULT_EXTENSIONS, '.ts', '.tsx'],
presets: [['@babel/preset-env', {targets, loose: true}], '@babel/preset-react'],
plugins: [
['@babel/plugin-transform-runtime', {useESModules}],
[
'babel-plugin-styled-components',
{
ssr: true,
pure: true,
minify: true,
fileName: false,
displayName: true,
transpileTemplateLiterals: true,
},
],
],
});
};
/**
* Common plugins in each build
*/
const commonPlugins = (useESModules = true) => [
typescript(),
replacePlugin,
babelPlugin(useESModules),
terser(),
removeTestIdPlugin,
];
export default [
// COMMONJS
{
external,
input,
output: {
file: pkg.main,
format: 'cjs',
exports: 'named',
},
plugins: commonPlugins(false),
},
// MODULE
{
external,
input,
output: {
file: pkg.module,
format: 'esm',
},
plugins: commonPlugins(true),
},
// BROWSER/UMD
{
external: Object.keys(globals),
input,
output: {
file: pkg.umd,
format: 'umd',
globals,
name,
},
plugins: commonPlugins(true),
},
];
================================================
FILE: src/Select.tsx
================================================
import React, {
useRef,
useMemo,
useState,
useEffect,
forwardRef,
useCallback,
useImperativeHandle,
type Ref,
type ReactNode,
type FormEvent,
type FocusEvent,
type KeyboardEvent,
type SyntheticEvent,
type FocusEventHandler
} from 'react';
import {
FilterMatchEnum,
OptionIndexEnum,
MenuPositionEnum,
FUNCTIONS,
EMPTY_ARRAY,
DEFAULT_THEME,
PAGE_SIZE_DEFAULT,
PLACEHOLDER_DEFAULT,
LOADING_MSG_DEFAULT,
SELECT_CONTAINER_CLS,
CONTROL_CONTAINER_CLS,
FOCUSED_OPTION_DEFAULT,
NO_OPTIONS_MSG_DEFAULT,
MENU_ITEM_SIZE_DEFAULT,
MENU_MAX_HEIGHT_DEFAULT,
SELECT_CONTAINER_TESTID,
CONTROL_CONTAINER_TESTID
} from './constants';
import type {
Theme,
SelectRef,
OptionData,
MenuOption,
MultiParams,
IconRenderer,
FocusedOption,
SelectedOption,
CallbackFn,
MouseOrTouchEvent,
AriaLiveAttribute,
OptionLabelCallback,
OptionValueCallback,
RenderLabelCallback
} from './types';
import type { FixedSizeList } from 'react-window';
import styled, { css, ThemeProvider, type DefaultTheme } from 'styled-components';
import { Menu, Value, AriaLiveRegion, AutosizeInput, IndicatorIcons } from './components';
import { useDebounce, useLatestRef, useCallbackRef, useMenuOptions, useMountEffect, useUpdateEffect, useMenuPosition } from './hooks';
import { isBoolean, isFunction, isPlainObject, mergeDeep, suppressEvent, normalizeValue, isTouchDevice, isArrayWithLength } from './utils';
type SelectProps = Readonly<{
async?: boolean;
menuId?: string;
inputId?: string;
selectId?: string;
pageSize?: number;
isMulti?: boolean;
ariaLabel?: string;
required?: boolean;
loadingMsg?: string;
autoFocus?: boolean;
isLoading?: boolean;
isInvalid?: boolean;
inputDelay?: number;
themeConfig?: Theme;
isDisabled?: boolean;
placeholder?: string;
menuItemSize?: number;
isClearable?: boolean;
memoOptions?: boolean;
lazyLoadMenu?: boolean;
options?: OptionData[];
isSearchable?: boolean;
menuMaxHeight?: number;
loadingNode?: ReactNode;
ariaLabelledBy?: string;
clearIcon?: IconRenderer;
caretIcon?: IconRenderer;
openMenuOnClick?: boolean;
openMenuOnFocus?: boolean;
menuPortalTarget?: Element;
menuOverscanCount?: number;
tabSelectsOption?: boolean;
filterIgnoreCase?: boolean;
menuScrollDuration?: number;
blurInputOnSelect?: boolean;
closeMenuOnSelect?: boolean;
isAriaLiveEnabled?: boolean;
menuWidth?: string | number;
scrollMenuIntoView?: boolean;
noOptionsMsg?: string | null;
ariaLive?: AriaLiveAttribute;
hideSelectedOptions?: boolean;
filterIgnoreAccents?: boolean;
onMenuOpen?: CallbackFn;
onMenuClose?: CallbackFn;
backspaceClearsValue?: boolean;
menuPosition?: MenuPositionEnum;
filterMatchFrom?: FilterMatchEnum;
menuItemDirection?: 'ltr' | 'rtl';
itemKeySelector?: string | number;
getOptionLabel?: OptionLabelCallback;
getOptionValue?: OptionValueCallback;
initialValue?: OptionData | OptionData[];
onInputChange?: (value?: string) => unknown;
onSearchChange?: (value?: string) => unknown;
onOptionChange?: (data: OptionData) => unknown;
onInputBlur?: FocusEventHandler<HTMLInputElement>;
onInputFocus?: FocusEventHandler<HTMLInputElement>;
renderOptionLabel?: (data: OptionData) => ReactNode;
getIsOptionDisabled?: (data: OptionData) => boolean;
getFilterOptionString?: (option: MenuOption) => string;
renderMultiOptions?: (params: MultiParams) => ReactNode;
onKeyDown?: (e: KeyboardEvent<Element>, input?: string, focusedOption?: FocusedOption) => unknown;
}>;
type ValueWrapperProps = Readonly<{
flex: boolean;
}>;
interface ControlWrapperProps extends Pick<SelectProps, 'isInvalid' | 'isDisabled'> {
readonly isFocused: boolean;
}
const SelectWrapper = styled.div`
position: relative;
box-sizing: border-box;
${({ theme }) => theme.select.css}
`;
const ValueWrapper = styled.div<ValueWrapperProps>`
flex: 1 1 0%;
flex-wrap: wrap;
overflow: hidden;
position: relative;
align-items: center;
box-sizing: border-box;
display: ${({ flex }) => flex ? 'flex' : 'grid'};
padding: ${({ theme }) => theme.control.padding};
`;
const ControlWrapper = styled.div<ControlWrapperProps>`
outline: 0;
display: flex;
flex-wrap: wrap;
cursor: default;
position: relative;
align-items: center;
box-sizing: border-box;
justify-content: space-between;
${({ isDisabled, isFocused, isInvalid, theme: { control, color } }) => css`
transition: ${control.transition};
border-style: ${control.borderStyle};
border-width: ${control.borderWidth};
border-radius: ${control.borderRadius};
min-height: ${control.height || control.minHeight};
border-color: ${isInvalid
? color.danger
: isFocused
? control.focusedBorderColor
: color.border};
${control.height && `height: ${control.height};`}
${isDisabled && 'pointer-events:none;user-select:none;'}
${(control.backgroundColor || isDisabled) && `background-color: ${isDisabled ? color.disabled : control.backgroundColor};`}
${isFocused && `box-shadow: ${control.boxShadow} ${isInvalid ? color.dangerLight : control.boxShadowColor};`}
`}
${({ theme }) => theme.control.css}
${({ isFocused, theme }) => isFocused && theme.control.focusedCss}
`;
const Select = forwardRef<SelectRef, SelectProps>((
{
async,
menuId,
isMulti,
inputId,
selectId,
required,
ariaLive,
autoFocus,
isLoading,
onKeyDown,
clearIcon,
caretIcon,
isInvalid,
ariaLabel,
menuWidth,
isDisabled,
inputDelay,
onMenuOpen,
onMenuClose,
onInputBlur,
isClearable,
themeConfig,
loadingNode,
initialValue,
onInputFocus,
onInputChange,
ariaLabelledBy,
onOptionChange,
onSearchChange,
getOptionLabel,
getOptionValue,
itemKeySelector,
openMenuOnFocus,
menuPortalTarget,
isAriaLiveEnabled,
menuOverscanCount,
blurInputOnSelect,
menuItemDirection,
renderOptionLabel,
renderMultiOptions,
menuScrollDuration,
filterIgnoreAccents,
hideSelectedOptions,
getIsOptionDisabled,
getFilterOptionString,
isSearchable = true,
memoOptions = false,
lazyLoadMenu = false,
openMenuOnClick = true,
filterIgnoreCase = true,
tabSelectsOption = true,
closeMenuOnSelect = true,
scrollMenuIntoView = true,
backspaceClearsValue = true,
filterMatchFrom = FilterMatchEnum.ANY,
menuPosition = MenuPositionEnum.BOTTOM,
options = EMPTY_ARRAY,
pageSize = PAGE_SIZE_DEFAULT,
loadingMsg = LOADING_MSG_DEFAULT,
placeholder = PLACEHOLDER_DEFAULT,
noOptionsMsg = NO_OPTIONS_MSG_DEFAULT,
menuItemSize = MENU_ITEM_SIZE_DEFAULT,
menuMaxHeight = MENU_MAX_HEIGHT_DEFAULT
},
ref: Ref<SelectRef>
) => {
// DOM element refs
const listRef = useRef<FixedSizeList | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const controlRef = useRef<HTMLDivElement | null>(null);
// Local state values
const [inputValue, setInputValue] = useState<string>('');
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [isFocused, setIsFocused] = useState<boolean>(false);
const [focusedMultiValue, setFocusedMultiValue] = useState<string | number | null>(null);
const [focusedOption, setFocusedOption] = useState<FocusedOption>(FOCUSED_OPTION_DEFAULT);
// Memoized DefaultTheme object for styled-components ThemeProvider
const theme = useMemo<DefaultTheme>(() => {
return isPlainObject(themeConfig)
? mergeDeep(DEFAULT_THEME, themeConfig)
: DEFAULT_THEME;
}, [themeConfig]);
// Memoized callback functions referencing optional function properties on Select.tsx
const getOptionLabelFn = useMemo<OptionLabelCallback>(() => getOptionLabel || FUNCTIONS.optionLabel, [getOptionLabel]);
const getOptionValueFn = useMemo<OptionValueCallback>(() => getOptionValue || FUNCTIONS.optionValue, [getOptionValue]);
const renderOptionLabelFn = useMemo<RenderLabelCallback>(() => renderOptionLabel || getOptionLabelFn, [renderOptionLabel, getOptionLabelFn]);
// Custom hook abstraction that debounces search input value (opt-in)
const debouncedInputValue = useDebounce<string>(inputValue, inputDelay);
// Custom ref objects
const onSearchChangeFn = useCallbackRef(onSearchChange);
const onOptionChangeFn = useCallbackRef(onOptionChange);
const onSearchChangeIsFn = useLatestRef<boolean>(isFunction(onSearchChange));
const onOptionChangeIsFn = useLatestRef<boolean>(isFunction(onOptionChange));
const menuOpenRef = useLatestRef<boolean>(menuOpen);
const onChangeEvtValue = useRef<boolean>(false);
const prevMenuOptionsLength = useRef<number>();
// If initialValue is specified attempt to initialize, otherwise default to []
const [selectedOption, setSelectedOption] = useState<SelectedOption[]>(
() => normalizeValue(
initialValue,
getOptionValueFn,
getOptionLabelFn
)
);
// Custom hook abstraction that handles the creation of menuOptions
const menuOptions = useMenuOptions(
options,
debouncedInputValue,
filterMatchFrom,
selectedOption,
getOptionValueFn,
getOptionLabelFn,
getIsOptionDisabled,
getFilterOptionString,
filterIgnoreCase,
filterIgnoreAccents,
isMulti,
async,
hideSelectedOptions
);
// Custom hook abstraction that handles calculating menuHeightCalc (defaults to menuMaxHeight) / handles executing callbacks/logic on menuOpen state change.
const { menuStyleTop, menuHeightCalc } = useMenuPosition(
menuRef,
controlRef,
menuOpen,
menuPosition,
menuItemSize,
menuMaxHeight,
menuOptions.length,
!!menuPortalTarget,
onMenuOpen,
onMenuClose,
menuScrollDuration,
scrollMenuIntoView
);
const blurInput = (): void => inputRef.current?.blur();
const focusInput = (): void => inputRef.current?.focus();
const scrollToItemIndex = (idx: number): void => listRef.current?.scrollToItem(idx);
const hasSelectedOptions = isArrayWithLength(selectedOption);
const openMenuAndFocusOption = useCallback((position: OptionIndexEnum): void => {
if (!isArrayWithLength(menuOptions)) {
setMenuOpen(true);
return;
}
const selectedIdx = !isMulti
? menuOptions.findIndex((x) => x.isSelected)
: -1;
const index = selectedIdx > -1
? selectedIdx
: position === OptionIndexEnum.FIRST
? 0
: menuOptions.length - 1;
scrollToItemIndex(index);
setMenuOpen(true);
setFocusedMultiValue(null);
setFocusedOption({ index, ...menuOptions[index] });
}, [isMulti, menuOptions]);
const removeSelectedOption = useCallback((value?: string | number): void => {
setSelectedOption((so) => so.filter((x) => x.value !== value));
}, []);
const selectOption = useCallback((option: MenuOption): void => {
if (option.isDisabled) return;
if (option.isSelected) {
isMulti && removeSelectedOption(option.value);
} else {
const { isSelected, isDisabled, ...selectedOpt } = option;
setSelectedOption((so) => !isMulti ? [selectedOpt] : [...so, selectedOpt]);
}
const blurOrDefault = isBoolean(blurInputOnSelect) ? blurInputOnSelect : isTouchDevice();
if (blurOrDefault) {
blurInput();
} else if (closeMenuOnSelect) {
setInputValue('');
setMenuOpen(false);
}
}, [isMulti, closeMenuOnSelect, blurInputOnSelect, removeSelectedOption]);
/**
* `React.useImperativeHandle`
*
* Exposed API methods/properties available on a ref instance of this Select.tsx component.
* Dependency list passed as the third param to re-create the handle when one of them updates.
*/
useImperativeHandle(
ref,
() => ({
menuOpen: menuOpenRef.current,
blur: blurInput,
focus: focusInput,
clearValue: () => {
setSelectedOption(EMPTY_ARRAY);
setFocusedOption(FOCUSED_OPTION_DEFAULT);
},
setValue: (option?: OptionData) => {
setSelectedOption(
normalizeValue(option, getOptionValueFn, getOptionLabelFn)
);
},
toggleMenu: (state?: boolean) => {
if (state || (state === undefined && !menuOpenRef.current)) {
focusInput();
openMenuAndFocusOption(OptionIndexEnum.FIRST);
} else {
blurInput();
}
}
}),
[menuOpenRef, getOptionValueFn, getOptionLabelFn, openMenuAndFocusOption]
);
/**
* `useMountEffect`
*
* If 'autoFocus' true, focus the control following initial mount
*/
useMountEffect(() => {
if (autoFocus) {
focusInput();
}
});
/**
* `React.useEffect`
*
* If 'onSearchChange' function is defined, run as callback when the stateful debouncedInputValue
* updates check if onChangeEvtValue ref is set true, which indicates the inputValue change was triggered by input change event
*/
useEffect(() => {
if (onSearchChangeIsFn.current && onChangeEvtValue.current) {
onChangeEvtValue.current = false;
onSearchChangeFn(debouncedInputValue);
}
}, [onSearchChangeFn, onSearchChangeIsFn, debouncedInputValue]);
/**
* `useUpdateEffect`
*
* Handle passing 'selectedOption' value(s) to onOptionChange callback function prop (if defined)
*/
useUpdateEffect(() => {
if (onOptionChangeIsFn.current) {
const normalSelectedOpts = isMulti
? selectedOption.map((x) => x.data)
: isArrayWithLength(selectedOption)
? selectedOption[0].data
: null;
onOptionChangeFn(normalSelectedOpts);
}
}, [onOptionChangeFn, onOptionChangeIsFn, isMulti, selectedOption]);
/**
* `useUpdateEffect`
*
* Handle clearing focused option if menuOptions array has 0 length;
* Handle menuOptions changes - conditionally focus first option and do scroll to first option;
* Handle reseting scroll pos to first item after the previous search returned zero results (use prevMenuOptionsLen)
* ...or if there is a selected item and menuOptions is restored to include it, give it focus
*/
useUpdateEffect(() => {
const curLength = menuOptions.length;
const { current: prevLength } = prevMenuOptionsLength;
const inputChanged = curLength > 0 && (async || curLength !== options.length || prevLength === 0);
const menuOpenAndOptionsGrew = menuOpenRef.current && prevLength !== undefined && prevLength < curLength;
if (curLength === 0) {
setFocusedOption(FOCUSED_OPTION_DEFAULT);
} else if (curLength === 1 || inputChanged || menuOpenAndOptionsGrew) {
const index = Math.max(0, menuOptions.findIndex((x) => x.isSelected));
scrollToItemIndex(index);
setFocusedOption({ index, ...menuOptions[index] });
}
prevMenuOptionsLength.current = curLength;
}, [async, options, menuOpenRef, menuOptions]);
const selectOptionFromFocused = (): void => {
const { index, ...menuOpt } = focusedOption;
if (menuOpt.data) {
selectOption(menuOpt as MenuOption);
}
};
// only Multiselect mode supports value focusing (ArrowRight || ArrowLeft)
const focusValueOnArrowKey = (key: string): void => {
if (!hasSelectedOptions) return;
let focusedIdx = -1;
const lastValueIdx = selectedOption.length - 1;
const curFocusedIdx = focusedMultiValue ? selectedOption.findIndex((x) => x.value === focusedMultiValue) : -1;
if (key === 'ArrowRight') {
focusedIdx = (curFocusedIdx > -1 && curFocusedIdx < lastValueIdx)
? curFocusedIdx + 1
: -1;
} else {
focusedIdx = curFocusedIdx !== 0
? curFocusedIdx === -1
? lastValueIdx
: curFocusedIdx - 1
: 0;
}
const nextFocusedVal = focusedIdx > -1
? selectedOption[focusedIdx].value!
: null;
if (focusedOption.data)
setFocusedOption(FOCUSED_OPTION_DEFAULT);
if (nextFocusedVal !== focusedMultiValue)
setFocusedMultiValue(nextFocusedVal);
};
const focusOptionOnArrowKey = (direction: OptionIndexEnum): void => {
if (!isArrayWithLength(menuOptions)) return;
let index = focusedOption.index;
switch (direction) {
case OptionIndexEnum.UP: {
index = (focusedOption.index > 0) ? focusedOption.index - 1 : menuOptions.length - 1;
break;
}
case OptionIndexEnum.DOWN: {
index = (focusedOption.index + 1) % menuOptions.length;
break;
}
case OptionIndexEnum.PAGEUP: {
const pageIdx = focusedOption.index - pageSize;
index = (pageIdx < 0) ? 0 : pageIdx;
break;
}
case OptionIndexEnum.PAGEDOWN: {
const pageIdx = focusedOption.index + pageSize;
index = (pageIdx > menuOptions.length - 1) ? menuOptions.length - 1 : pageIdx;
break;
}
}
scrollToItemIndex(index);
focusedMultiValue && setFocusedMultiValue(null);
setFocusedOption({ index, ...menuOptions[index] });
};
const handleOnKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
if (isDisabled) return;
if (onKeyDown) {
onKeyDown(e, inputValue, focusedOption);
if (e.defaultPrevented) return;
}
switch (e.key) {
case 'ArrowDown': {
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.DOWN) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
break;
}
case 'ArrowUp': {
menuOpen ? focusOptionOnArrowKey(OptionIndexEnum.UP) : openMenuAndFocusOption(OptionIndexEnum.LAST);
break;
}
case 'ArrowLeft':
case 'ArrowRight': {
if (!isMulti || inputValue || renderMultiOptions) return;
focusValueOnArrowKey(e.key);
break;
}
case 'PageUp': {
if (!menuOpen) return;
focusOptionOnArrowKey(OptionIndexEnum.PAGEUP);
break;
}
case 'PageDown': {
if (!menuOpen) return;
focusOptionOnArrowKey(OptionIndexEnum.PAGEDOWN);
break;
}
// handle spacebar keydown events
case ' ': {
if (inputValue) return;
if (!menuOpen) {
openMenuAndFocusOption(OptionIndexEnum.FIRST);
} else if (!focusedOption.data) {
return;
} else {
selectOptionFromFocused();
}
break;
}
case 'Enter': {
if (!menuOpen) return;
selectOptionFromFocused();
break;
}
case 'Escape': {
if (menuOpen) {
setMenuOpen(false);
setInputValue('');
}
break;
}
case 'Tab': {
if (e.shiftKey || !menuOpen || !tabSelectsOption || !focusedOption.data) {
return;
}
selectOptionFromFocused();
break;
}
case 'Delete':
case 'Backspace': {
if (inputValue) return;
if (focusedMultiValue) {
const focusedIdx = selectedOption.findIndex((x) => x.value === focusedMultiValue);
const nextFocusedMultiValue = (focusedIdx > -1 && (focusedIdx < (selectedOption.length - 1)))
? selectedOption[focusedIdx + 1].value!
: null;
removeSelectedOption(focusedMultiValue);
setFocusedMultiValue(nextFocusedMultiValue);
} else {
if (!backspaceClearsValue) return;
if (!hasSelectedOptions) break;
if (isMulti && !renderMultiOptions) {
const { value } = selectedOption[selectedOption.length - 1];
removeSelectedOption(value);
} else if (isClearable) {
setSelectedOption(EMPTY_ARRAY);
}
}
break;
}
default:
return;
}
e.preventDefault();
};
const handleOnControlMouseDown = (e: MouseOrTouchEvent<HTMLElement>): void => {
if (isDisabled) return;
if (!isFocused) focusInput();
const isNotInput = (e.target as HTMLElement).nodeName !== 'INPUT';
if (!menuOpen) {
openMenuOnClick && openMenuAndFocusOption(OptionIndexEnum.FIRST);
} else if (isNotInput) {
setMenuOpen(false);
setInputValue('');
}
if (isNotInput) e.preventDefault();
};
const handleOnInputBlur = (e: FocusEvent<HTMLInputElement>): void => {
onInputBlur?.(e);
setIsFocused(false);
setMenuOpen(false);
setInputValue('');
};
const handleOnInputFocus = (e: FocusEvent<HTMLInputElement>): void => {
onInputFocus?.(e);
setIsFocused(true);
if (openMenuOnFocus) {
openMenuAndFocusOption(OptionIndexEnum.FIRST);
}
};
const handleOnInputChange = (e: FormEvent<HTMLInputElement>): void => {
onChangeEvtValue.current = true;
onInputChange?.(e.currentTarget.value);
setInputValue(e.currentTarget.value);
setMenuOpen(true);
};
// React Hooks linter rules require that this function be memoized in
// ...order to be referenced in deps array of callback functions below
const handleOnMouseDown = useCallback((e: SyntheticEvent<HTMLElement>): void => {
suppressEvent(e);
focusInput();
}, []);
const handleOnClearMouseDown = useCallback((e: MouseOrTouchEvent<HTMLElement>): void => {
handleOnMouseDown(e);
setSelectedOption(EMPTY_ARRAY);
}, [handleOnMouseDown]);
const handleOnCaretMouseDown = useCallback((e: MouseOrTouchEvent<HTMLElement>): void => {
if (!isDisabled && !openMenuOnClick) {
handleOnMouseDown(e);
menuOpenRef.current ? setMenuOpen(false) : openMenuAndFocusOption(OptionIndexEnum.FIRST);
}
}, [isDisabled, menuOpenRef, openMenuOnClick, handleOnMouseDown, openMenuAndFocusOption]);
const flexValueWrapper = !!isMulti && hasSelectedOptions;
const showClear = !!isClearable && !isDisabled && hasSelectedOptions;
return (
<ThemeProvider theme={theme}>
<SelectWrapper
id={selectId}
onKeyDown={handleOnKeyDown}
className={SELECT_CONTAINER_CLS}
data-testid={SELECT_CONTAINER_TESTID}
>
<ControlWrapper
ref={controlRef}
isInvalid={isInvalid}
isFocused={isFocused}
isDisabled={isDisabled}
className={CONTROL_CONTAINER_CLS}
onTouchEnd={handleOnControlMouseDown}
onMouseDown={handleOnControlMouseDown}
data-testid={CONTROL_CONTAINER_TESTID}
>
<ValueWrapper flex={flexValueWrapper}>
<Value
isMulti={isMulti}
hasInput={!!inputValue}
placeholder={placeholder}
selectedOption={selectedOption}
focusedMultiValue={focusedMultiValue}
renderMultiOptions={renderMultiOptions}
renderOptionLabel={renderOptionLabelFn}
removeSelectedOption={removeSelectedOption}
/>
<AutosizeInput
id={inputId}
ref={inputRef}
menuId={menuId}
menuOpen={menuOpen}
required={required}
ariaLabel={ariaLabel}
isInvalid={isInvalid}
inputValue={inputValue}
onBlur={handleOnInputBlur}
onFocus={handleOnInputFocus}
onChange={handleOnInputChange}
ariaLabelledBy={ariaLabelledBy}
hasSelectedOptions={hasSelectedOptions}
readOnly={!isSearchable || !!focusedMultiValue}
/>
</ValueWrapper>
<IndicatorIcons
menuOpen={menuOpen}
clearIcon={clearIcon}
caretIcon={caretIcon}
isInvalid={isInvalid}
isLoading={isLoading}
showClear={showClear}
isDisabled={isDisabled}
loadingNode={loadingNode}
onClearMouseDown={handleOnClearMouseDown}
onCaretMouseDown={handleOnCaretMouseDown}
/>
</ControlWrapper>
<Menu
id={menuId}
menuRef={menuRef}
menuOpen={menuOpen}
isLoading={isLoading}
menuTop={menuStyleTop}
height={menuHeightCalc}
itemSize={menuItemSize}
loadingMsg={loadingMsg}
menuOptions={menuOptions}
memoOptions={memoOptions}
fixedSizeListRef={listRef}
lazyLoadMenu={lazyLoadMenu}
noOptionsMsg={noOptionsMsg}
selectOption={selectOption}
direction={menuItemDirection}
itemKeySelector={itemKeySelector}
overscanCount={menuOverscanCount}
menuPortalTarget={menuPortalTarget}
onMenuMouseDown={handleOnMouseDown}
width={menuWidth || theme.menu.width}
renderOptionLabel={renderOptionLabelFn}
focusedOptionIndex={focusedOption.index}
/>
{isAriaLiveEnabled && (
<AriaLiveRegion
ariaLive={ariaLive}
menuOpen={menuOpen}
isFocused={isFocused}
ariaLabel={ariaLabel}
inputValue={inputValue}
isSearchable={isSearchable}
focusedOption={focusedOption}
selectedOption={selectedOption}
optionCount={menuOptions.length}
/>
)}
</SelectWrapper>
</ThemeProvider>
);
});
Select.displayName = 'Select';
export default Select;
================================================
FILE: src/components/AriaLiveRegion/index.tsx
================================================
import React from 'react';
import styled from 'styled-components';
import { ARIA_LIVE_SELECTION_ID, ARIA_LIVE_CONTEXT_ID } from '../../constants';
import type { FocusedOption, SelectedOption, AriaLiveAttribute } from '../../types';
type AriaLiveRegionProps = Readonly<{
menuOpen: boolean;
isFocused: boolean;
ariaLabel?: string;
inputValue: string;
optionCount: number;
isSearchable: boolean;
ariaLive?: AriaLiveAttribute;
focusedOption: FocusedOption;
selectedOption: SelectedOption[];
}>;
const A11yText = styled.span`
border: 0;
padding: 0;
width: 1px;
height: 1px;
z-index: 9999;
overflow: hidden;
position: absolute;
white-space: nowrap;
clip-path: inset(50%);
clip: rect(1px, 1px, 1px, 1px);
`;
const AriaLiveRegion: React.FC<AriaLiveRegionProps> = ({
menuOpen,
isFocused,
inputValue,
optionCount,
isSearchable,
focusedOption,
selectedOption,
ariaLive = 'polite',
ariaLabel = 'Select'
}) => {
if (!isFocused) {
return null;
}
// message contents for "aria-context"
const { index, label, isDisabled, isSelected } = focusedOption;
const menuMsg = menuOpen
? `Use Up and Down to choose options${isDisabled ? '' : ', press Enter or Tab to select the currently focused option'}, press Escape to close the menu.`
: `${ariaLabel} is focused${isSearchable ? ', type to filter options' : ''}, press Down arrow key to open the menu.`;
const focusedMsg = label
? `Option ${label} is ${isSelected ? 'selected' : 'focused'}${isDisabled ? ' disabled' : ''}, ${index + 1} of ${optionCount}.`
: '';
const optionsMsg = `${optionCount} option(s) available${inputValue ? (' for search ' + inputValue) : ''}.`;
const ariaContextMsg = `${focusedMsg} ${optionsMsg} ${menuMsg}`.trimStart();
// message contents for "aria-selection" SPAN
const selectedLbls = selectedOption.length ? selectedOption.map((x) => x.label).join(' ') : 'N/A';
const selectionMsg = `Selected option: ${selectedLbls}`;
return (
<A11yText
aria-atomic="false"
aria-live={ariaLive}
aria-relevant="additions text"
>
<span id={ARIA_LIVE_SELECTION_ID}>
{selectionMsg}
</span>
<span id={ARIA_LIVE_CONTEXT_ID}>
{ariaContextMsg}
</span>
</A11yText>
);
};
export default AriaLiveRegion;
================================================
FILE: src/components/AutosizeInput/index.tsx
================================================
import styled, { css } from 'styled-components';
import { AUTOSIZE_INPUT_ATTRS } from '../../constants';
import React, { forwardRef, type Ref, type FormEventHandler, type FocusEventHandler } from 'react';
type AutosizeInputProps = Readonly<{
id?: string;
menuId?: string;
menuOpen: boolean;
readOnly: boolean;
ariaLabel?: string;
inputValue: string;
required?: boolean;
isInvalid?: boolean;
ariaLabelledBy?: string;
hasSelectedOptions: boolean;
onBlur: FocusEventHandler<HTMLInputElement>;
onFocus: FocusEventHandler<HTMLInputElement>;
onChange: FormEventHandler<HTMLInputElement>;
}>;
type InputProps = Pick<AutosizeInputProps, 'isInvalid'>;
const INPUT_BASE_STYLE = css`
border: 0;
margin: 0;
outline: 0;
padding: 0;
font: inherit;
min-width: 2px;
grid-area: 1 / 2 / auto / auto;
`;
const InputWrapper = styled.div`
margin: 2px;
flex: 1 1 auto;
display: inline-grid;
box-sizing: border-box;
grid-area: 1 / 1 / 2 / 3;
grid-template-columns: 0 min-content;
:after {
white-space: pre;
visibility: hidden;
content: attr(data-value) " ";
${INPUT_BASE_STYLE}
}
`;
const Input = styled.input.attrs(AUTOSIZE_INPUT_ATTRS)<InputProps>`
width: 100%;
background: 0;
color: inherit;
${INPUT_BASE_STYLE}
:read-only {
cursor: default;
}
${({ theme }) => theme.input.css}
${({ theme, isInvalid }) => isInvalid && theme.input.cssRequired}
`;
const AutosizeInput = forwardRef<HTMLInputElement, AutosizeInputProps>((
{
id,
menuId,
onBlur,
onFocus,
onChange,
readOnly,
required,
menuOpen,
ariaLabel,
isInvalid,
inputValue,
ariaLabelledBy
},
ref: Ref<HTMLInputElement>
) => (
<InputWrapper data-value={inputValue}>
<Input
id={id}
ref={ref}
onBlur={onBlur}
onFocus={onFocus}
value={inputValue}
aria-owns={menuId}
readOnly={readOnly}
isInvalid={isInvalid}
aria-controls={menuId}
aria-label={ariaLabel}
aria-expanded={menuOpen}
aria-required={required}
aria-labelledby={ariaLabelledBy}
onChange={!readOnly ? onChange : undefined}
/>
</InputWrapper>
));
AutosizeInput.displayName = 'AutosizeInput';
export default AutosizeInput;
================================================
FILE: src/components/IndicatorIcons/ClearIcon.tsx
================================================
import React from 'react';
import styled, { css } from 'styled-components';
import { CLEAR_ICON_CLS } from '../../constants';
const ClearSvg = styled.svg`
fill: currentColor;
${({ theme }) => css`
width: ${theme.icon.clear.width};
height: ${theme.icon.clear.height};
animation: ${theme.icon.clear.animation};
transition: ${theme.icon.clear.transition};
`}
`;
const ClearIcon: React.FC = () => (
<ClearSvg
aria-hidden
focusable="false"
viewBox="0 0 14 16"
className={CLEAR_ICON_CLS}
>
<path
fillRule="evenodd"
d="M7.71 8.23l3.75 3.75-1.48 1.48-3.75-3.75-3.75 3.75L1 11.98l3.75-3.75L1 4.48 2.48 3l3.75 3.75L9.98 3l1.48 1.48-3.75 3.75z"
/>
</ClearSvg>
);
export default ClearIcon;
================================================
FILE: src/components/IndicatorIcons/LoadingDots.tsx
================================================
import React from 'react';
import styled, { css } from 'styled-components';
import { LOADING_DOTS_CLS } from '../../constants';
const StyledLoadingDots = styled.div`
display: flex;
align-self: center;
text-align: center;
margin-right: 0.25rem;
padding: ${({ theme }) => theme.loader.padding};
> div {
border-radius: 100%;
display: inline-block;
${({ theme }) => css`
width: ${theme.loader.size};
height: ${theme.loader.size};
animation: ${theme.loader.animation};
background-color: ${theme.loader.color};
`}
:nth-of-type(1) {
animation-delay: -0.272s;
}
:nth-of-type(2) {
animation-delay: -0.136s;
}
}
`;
const LoadingDots: React.FC = () => (
<StyledLoadingDots
aria-hidden
className={LOADING_DOTS_CLS}
>
<div />
<div />
<div />
</StyledLoadingDots>
);
export default LoadingDots;
================================================
FILE: src/components/IndicatorIcons/index.tsx
================================================
import React, { memo, type ReactNode } from 'react';
import ClearIcon from './ClearIcon';
import LoadingDots from './LoadingDots';
import { isFunction } from '../../utils';
import styled, { css } from 'styled-components';
import type { IconRenderer, MouseOrTouchEventHandler } from '../../types';
import { CARET_ICON_CLS, CLEAR_ICON_TESTID, CARET_ICON_TESTID } from '../../constants';
type IndicatorIconsProps = Readonly<{
menuOpen: boolean;
showClear: boolean;
isLoading?: boolean;
isInvalid?: boolean;
isDisabled?: boolean;
loadingNode?: ReactNode;
clearIcon?: IconRenderer;
caretIcon?: IconRenderer;
onClearMouseDown: MouseOrTouchEventHandler;
onCaretMouseDown: MouseOrTouchEventHandler;
}>;
type CaretProps = Pick<IndicatorIconsProps, 'menuOpen' | 'isInvalid'>;
const IndicatorIconsWrapper = styled.div`
display: flex;
flex-shrink: 0;
align-items: center;
align-self: stretch;
box-sizing: border-box;
> span {
width: 1px;
margin: 8px 0;
align-self: stretch;
box-sizing: border-box;
background-color: ${({ theme }) => theme.color.iconSeparator || theme.color.border};
}
`;
const IndicatorIcon = styled.div`
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
color: ${({ theme }) => theme.icon.color};
padding: ${({ theme }) => theme.icon.padding};
:hover {
color: ${({ theme }) => theme.icon.hoverColor};
}
${({ theme }) => theme.icon.css}
`;
const Caret = styled.div<CaretProps>`
transition: ${({ theme }) => theme.icon.caret.transition};
border-top: ${({ theme }) => theme.icon.caret.size} dashed;
border-left: ${({ theme }) => theme.icon.caret.size} solid transparent;
border-right: ${({ theme }) => theme.icon.caret.size} solid transparent;
${({ theme, menuOpen, isInvalid }) =>
menuOpen &&
css`
transform: rotate(180deg);
color: ${isInvalid ? theme.color.danger : theme.color.caretActive || theme.color.primary};
`}
`;
const IndicatorIcons = memo<IndicatorIconsProps>(({
menuOpen,
clearIcon,
caretIcon,
isInvalid,
showClear,
isLoading,
isDisabled,
loadingNode,
onCaretMouseDown,
onClearMouseDown
}) => {
const iconRenderer = (renderer: IconRenderer): ReactNode => {
return isFunction(renderer)
? renderer({ menuOpen, isLoading, isInvalid, isDisabled })
: renderer;
};
return (
<IndicatorIconsWrapper>
{showClear && !isLoading && (
<IndicatorIcon
onTouchEnd={onClearMouseDown}
onMouseDown={onClearMouseDown}
data-testid={CLEAR_ICON_TESTID}
>
{iconRenderer(clearIcon) || <ClearIcon />}
</IndicatorIcon>
)}
{isLoading && (loadingNode || <LoadingDots />)}
<span />
<IndicatorIcon
onTouchEnd={onCaretMouseDown}
onMouseDown={onCaretMouseDown}
data-testid={CARET_ICON_TESTID}
>
{iconRenderer(caretIcon) || (
<Caret
aria-hidden
menuOpen={menuOpen}
isInvalid={isInvalid}
className={CARET_ICON_CLS}
/>
)}
</IndicatorIcon>
</IndicatorIconsWrapper>
);
});
IndicatorIcons.displayName = 'IndicatorIcons';
export default IndicatorIcons;
================================================
FILE: src/components/Menu/MenuList.tsx
================================================
import React, { useMemo, Fragment, type MutableRefObject } from 'react';
import Option from './Option';
import styled from 'styled-components';
import { isArrayWithLength } from '../../utils';
import { FixedSizeList, type ListItemKeySelector } from 'react-window';
import type { MenuOption, ItemData, RenderLabelCallback } from '../../types';
export type MenuListProps = Readonly<{
height: number;
itemSize: number;
loadingMsg: string;
isLoading?: boolean;
memoOptions: boolean;
overscanCount?: number;
width: string | number;
direction?: 'ltr' | 'rtl';
menuOptions: MenuOption[];
focusedOptionIndex: number;
noOptionsMsg: string | null;
itemKeySelector?: string | number;
renderOptionLabel: RenderLabelCallback;
selectOption: (option: MenuOption) => void;
fixedSizeListRef: MutableRefObject<FixedSizeList | null> | undefined;
}>;
const NoOptionsMsg = styled.div`
text-align: center;
color: ${({ theme }) => theme.noOptions.color};
margin: ${({ theme }) => theme.noOptions.margin};
padding: ${({ theme }) => theme.noOptions.padding};
font-size: ${({ theme }) => theme.noOptions.fontSize};
${({ theme }) => theme.noOptions.css}
`;
const MenuList: React.FC<MenuListProps> = ({
width,
height,
itemSize,
direction,
isLoading,
loadingMsg,
menuOptions,
memoOptions,
selectOption,
noOptionsMsg,
overscanCount,
itemKeySelector,
fixedSizeListRef,
renderOptionLabel,
focusedOptionIndex
}) => {
const itemData = useMemo<ItemData>(() => ({
menuOptions,
memoOptions,
selectOption,
renderOptionLabel,
focusedOptionIndex
}), [menuOptions, memoOptions, focusedOptionIndex, selectOption, renderOptionLabel]);
if (isLoading) {
return <NoOptionsMsg>{loadingMsg}</NoOptionsMsg>;
}
const itemKey: ListItemKeySelector | undefined = itemKeySelector
? (index, data) => data.menuOptions[index][itemKeySelector]
: undefined;
return (
<Fragment>
<FixedSizeList
width={width}
height={height}
itemKey={itemKey}
itemSize={itemSize}
itemData={itemData}
direction={direction}
ref={fixedSizeListRef}
overscanCount={overscanCount}
itemCount={menuOptions.length}
>
{Option}
</FixedSizeList>
{!isArrayWithLength(menuOptions) && noOptionsMsg && (
<NoOptionsMsg>{noOptionsMsg}</NoOptionsMsg>
)}
</Fragment>
);
};
export default MenuList;
================================================
FILE: src/components/Menu/Option.tsx
================================================
import React, { memo, type CSSProperties } from 'react';
import { areEqual } from 'react-window';
import type { ItemData } from '../../types';
import { buildOptionClass } from '../../utils';
type OptionProps = Readonly<{
index: number;
data: ItemData;
style: CSSProperties;
}>;
// extends react-window 'areEqual'
const _areEqual = (
prevProps: OptionProps,
nextProps: OptionProps
): boolean => {
const { memoOptions } = nextProps.data;
return memoOptions && areEqual(prevProps, nextProps);
};
const Option = memo<OptionProps>(({
index,
style,
data: {
menuOptions,
selectOption,
renderOptionLabel,
focusedOptionIndex
}
}) => {
const opt = menuOptions[index];
const className = buildOptionClass(
opt.isDisabled,
opt.isSelected,
index === focusedOptionIndex
);
return (
<div
style={style}
className={className}
onClick={() => selectOption(opt)}
>
{renderOptionLabel(opt.data)}
</div>
);
}, _areEqual);
Option.displayName = 'Option';
export default Option;
================================================
FILE: src/components/Menu/index.tsx
================================================
import React, { type MutableRefObject } from 'react';
import { createPortal } from 'react-dom';
import styled, { css } from 'styled-components';
import type { MouseOrTouchEvent } from '../../types';
import MenuList, { type MenuListProps } from './MenuList';
import {
OPTION_CLS,
OPTION_FOCUSED_CLS,
OPTION_DISABLED_CLS,
OPTION_SELECTED_CLS,
MENU_CONTAINER_CLS,
MENU_CONTAINER_TESTID
} from '../../constants';
interface MenuProps extends MenuListProps {
readonly id?: string;
readonly menuTop?: string;
readonly menuOpen: boolean;
readonly lazyLoadMenu: boolean;
readonly menuPortalTarget?: Element;
readonly menuRef: MutableRefObject<HTMLDivElement | null>;
readonly onMenuMouseDown: (e: MouseOrTouchEvent<HTMLDivElement>) => void;
}
interface MenuWrapperProps extends Pick<MenuProps, 'menuOpen' | 'menuTop'> {
readonly hideNoOptionsMsg: boolean;
}
const MenuWrapper = styled.div<MenuWrapperProps>`
z-index: 999;
cursor: default;
position: absolute;
${({ menuTop, menuOpen, hideNoOptionsMsg, theme: { menu } }) => css`
width: ${menu.width};
margin: ${menu.margin};
padding: ${menu.padding};
animation: ${menu.animation};
border-radius: ${menu.borderRadius};
background-color: ${menu.backgroundColor};
box-shadow: ${hideNoOptionsMsg ? 'none' : menu.boxShadow};
${!menuOpen && 'display: none;'}
${menuTop && `top: ${menuTop};`}
`}
${({ theme }) => theme.menu.css}
.${OPTION_CLS} {
display: block;
overflow: hidden;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-tap-highlight-color: transparent;
padding: ${({ theme }) => theme.menu.option.padding};
text-align: ${({ theme }) => theme.menu.option.textAlign};
&.${OPTION_FOCUSED_CLS},
&:hover:not(.${OPTION_DISABLED_CLS}):not(.${OPTION_SELECTED_CLS}) {
background-color: ${({ theme }) => theme.menu.option.focusedBgColor};
}
&.${OPTION_SELECTED_CLS} {
color: ${({ theme }) => theme.menu.option.selectedColor};
background-color: ${({ theme }) => theme.menu.option.selectedBgColor};
}
&.${OPTION_DISABLED_CLS} {
opacity: 0.35;
}
}
`;
const Menu: React.FC<MenuProps> = ({
id,
menuRef,
menuTop,
menuOpen,
lazyLoadMenu,
onMenuMouseDown,
menuPortalTarget,
...menuListProps
}) => {
if (lazyLoadMenu && !menuOpen) {
return null;
}
const { menuOptions, noOptionsMsg } = menuListProps;
const hideNoOptionsMsg = menuOpen && !noOptionsMsg && !menuOptions.length;
const menuNode = (
<MenuWrapper
id={id}
ref={menuRef}
menuTop={menuTop}
menuOpen={menuOpen}
onMouseDown={onMenuMouseDown}
className={MENU_CONTAINER_CLS}
data-testid={MENU_CONTAINER_TESTID}
hideNoOptionsMsg={hideNoOptionsMsg}
>
<MenuList {...menuListProps} />
</MenuWrapper>
);
return menuPortalTarget
? createPortal(menuNode, menuPortalTarget)
: menuNode;
};
export default Menu;
================================================
FILE: src/components/Value/MultiValue.tsx
================================================
import React from 'react';
import { suppressEvent } from '../../utils';
import styled, { css } from 'styled-components';
import { CLEAR_ICON_MV_TESTID } from '../../constants';
import type { RenderLabelCallback, SelectedOption } from '../../types';
type MultiValueProps = SelectedOption & Readonly<{
isFocused: boolean;
renderOptionLabel: RenderLabelCallback;
removeSelectedOption: (value?: string | number) => void;
}>;
type ClearProps = Pick<MultiValueProps, 'isFocused'>;
const CLEAR_ICON_FOCUS_STYLE = css`
color: ${({ theme }) => theme.multiValue.clear.focusColor};
`;
const MultiValueWrapper = styled.div`
min-width: 0;
display: flex;
${({ theme: { multiValue } }) => css`
margin: ${multiValue.margin};
animation: ${multiValue.animation};
border-radius: ${multiValue.borderRadius};
background-color: ${multiValue.backgroundColor};
`}
${({ theme }) => theme.multiValue.css}
`;
const Label = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: ${({ theme }) => theme.multiValue.label.padding};
font-size: ${({ theme }) => theme.multiValue.label.fontSize};
border-radius: ${({ theme }) => theme.multiValue.label.borderRadius};
`;
const Clear = styled.i<ClearProps>`
display: flex;
font-style: inherit;
${({ theme: { multiValue: { clear } } }) => css`
color: ${clear.color};
padding: ${clear.padding};
font-size: ${clear.fontSize};
align-self: ${clear.alignSelf};
transition: ${clear.transition};
font-weight: ${clear.fontWeight};
&:hover {
${CLEAR_ICON_FOCUS_STYLE}
}
`}
${({ isFocused }) => isFocused && CLEAR_ICON_FOCUS_STYLE}
`;
const MultiValue: React.FC<MultiValueProps> = ({
data,
value,
isFocused,
renderOptionLabel,
removeSelectedOption
}) => {
const handleOnClear = () => removeSelectedOption(value);
return (
<MultiValueWrapper>
<Label>
{renderOptionLabel(data)}
</Label>
<Clear
isFocused={isFocused}
onClick={handleOnClear}
onTouchEnd={handleOnClear}
onMouseDown={suppressEvent}
data-testid={CLEAR_ICON_MV_TESTID}
>
✖
</Clear>
</MultiValueWrapper>
);
};
export default MultiValue;
================================================
FILE: src/components/Value/index.tsx
================================================
import React, { memo, Fragment, type ReactNode } from 'react';
import MultiValue from './MultiValue';
import styled from 'styled-components';
import type { MultiParams, SelectedOption, RenderLabelCallback } from '../../types';
type ValueProps = Readonly<{
isMulti?: boolean;
hasInput: boolean;
placeholder: string;
selectedOption: SelectedOption[];
focusedMultiValue: string | number | null;
renderOptionLabel: RenderLabelCallback;
removeSelectedOption: (value?: string | number) => void;
renderMultiOptions?: (params: MultiParams) => ReactNode;
}>;
const SingleValue = styled.div`
margin: 0 2px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
box-sizing: border-box;
text-overflow: ellipsis;
grid-area: 1 / 1 / 2 / 3;
`;
const Placeholder = styled(SingleValue)`
color: ${({ theme }) => theme.color.placeholder};
`;
const Value = memo<ValueProps>(({
isMulti,
hasInput,
placeholder,
selectedOption,
focusedMultiValue,
renderOptionLabel,
renderMultiOptions,
removeSelectedOption
}) => {
if (hasInput && (!isMulti || (isMulti && (!selectedOption.length || renderMultiOptions)))) {
return null;
}
if (!selectedOption.length) {
return <Placeholder>{placeholder}</Placeholder>;
}
if (!isMulti) {
return (
<SingleValue>
{renderOptionLabel(selectedOption[0].data)}
</SingleValue>
);
}
return (
<Fragment>
{renderMultiOptions
? renderMultiOptions({ renderOptionLabel, selected: selectedOption })
: selectedOption.map(({ data, value }) => (
<MultiValue
key={value}
data={data}
value={value}
renderOptionLabel={renderOptionLabel}
isFocused={value === focusedMultiValue}
removeSelectedOption={removeSelectedOption}
/>
))}
</Fragment>
);
});
Value.displayName = 'Value';
export default Value;
================================================
FILE: src/components/index.ts
================================================
export { default as Menu } from './Menu';
export { default as Value } from './Value';
export { default as AutosizeInput } from './AutosizeInput';
export { default as AriaLiveRegion } from './AriaLiveRegion';
export { default as IndicatorIcons } from './IndicatorIcons';
================================================
FILE: src/constants/defaults.ts
================================================
import type {
FocusedOption,
OptionValueCallback,
OptionLabelCallback,
OptionFilterCallback,
OptionDisabledCallback
} from '../types';
export const PAGE_SIZE_DEFAULT = 5;
export const MENU_ITEM_SIZE_DEFAULT = 35;
export const MENU_MAX_HEIGHT_DEFAULT = 300;
export const LOADING_MSG_DEFAULT = 'Loading..';
export const NO_OPTIONS_MSG_DEFAULT = 'No options';
export const PLACEHOLDER_DEFAULT = 'Select option..';
export const EMPTY_ARRAY: any[] = []; // Default for options and selectedOption props
export const FOCUSED_OPTION_DEFAULT: FocusedOption = { index: -1 };
export const FUNCTIONS = {
optionLabel: ((x) => x.label) as OptionLabelCallback,
optionValue: ((x) => x.value) as OptionValueCallback,
isOptionDisabled: ((x) => !!x.isDisabled) as OptionDisabledCallback,
optionFilter: ((x) => typeof x.label === 'string' ? x.label : '' + x.label) as OptionFilterCallback
};
================================================
FILE: src/constants/dom.ts
================================================
import type { TestableElement } from '../types';
import type { InputHTMLAttributes } from 'react';
// id attributes for AriaLiveRegion.tsx innerHTML content
export const ARIA_LIVE_CONTEXT_ID = 'aria-context';
export const ARIA_LIVE_SELECTION_ID = 'aria-selection';
// classNames (menu options)
export const OPTION_CLS = 'rfs-option';
export const OPTION_FOCUSED_CLS = `${OPTION_CLS}-focused`;
export const OPTION_SELECTED_CLS = `${OPTION_CLS}-selected`;
export const OPTION_DISABLED_CLS = `${OPTION_CLS}-disabled`;
// classNames (containers & icons)
export const CARET_ICON_CLS = 'rfs-caret-icon';
export const CLEAR_ICON_CLS = 'rfs-clear-icon';
export const LOADING_DOTS_CLS = 'rfs-loading-dots';
export const AUTOSIZE_INPUT_CLS = 'rfs-autosize-input';
export const MENU_CONTAINER_CLS = 'rfs-menu-container';
export const SELECT_CONTAINER_CLS = 'rfs-select-container';
export const CONTROL_CONTAINER_CLS = 'rfs-control-container';
// data-testid attributes used for DOM element querying in unit test cases
// ...this attribute gets rendered in development and test environments (removed in production)
const isTest = process.env.NODE_ENV === 'test';
export const CLEAR_ICON_TESTID = isTest ? CLEAR_ICON_CLS : undefined;
export const CARET_ICON_TESTID = isTest ? CARET_ICON_CLS : undefined;
export const AUTOSIZE_INPUT_TESTID = isTest ? AUTOSIZE_INPUT_CLS : undefined;
export const MENU_CONTAINER_TESTID = isTest ? MENU_CONTAINER_CLS : undefined;
export const CLEAR_ICON_MV_TESTID = isTest ? `${CLEAR_ICON_CLS}-mv` : undefined;
export const SELECT_CONTAINER_TESTID = isTest ? SELECT_CONTAINER_CLS : undefined;
export const CONTROL_CONTAINER_TESTID = isTest ? CONTROL_CONTAINER_CLS : undefined;
/**
* Static attributes for 'AutosizeInput' input element
*/
export const AUTOSIZE_INPUT_ATTRS: InputHTMLAttributes<HTMLInputElement> & TestableElement = {
tabIndex: 0,
type: 'text',
role: 'combobox',
spellCheck: false,
autoCorrect: 'off',
autoComplete: 'off',
'aria-haspopup': true,
autoCapitalize: 'none',
'aria-autocomplete': 'list',
className: AUTOSIZE_INPUT_CLS,
'data-testid': AUTOSIZE_INPUT_TESTID
} as const;
================================================
FILE: src/constants/enums.ts
================================================
/**
* Menu position in relation to the control.
* Defaults to 'auto' - meaning, if not enough space below control, then place above.
*/
export const MenuPositionEnum = {
TOP: 'top',
AUTO: 'auto',
BOTTOM: 'bottom'
} as const;
export type MenuPositionEnum = typeof MenuPositionEnum[keyof typeof MenuPositionEnum];
/**
* Property filterMatchFrom values. Defaults to 'any'.
* Determines where to match search input in option during filter process.
*/
export const FilterMatchEnum = {
ANY: 'any',
START: 'start'
} as const;
export type FilterMatchEnum = typeof FilterMatchEnum[keyof typeof FilterMatchEnum];
/**
* Arrow key direction OR position for cycling through menu options.
*/
export const OptionIndexEnum = {
UP: 0,
DOWN: 1,
LAST: 2,
FIRST: 3,
PAGEUP: 4,
PAGEDOWN: 5
} as const;
export type OptionIndexEnum = typeof OptionIndexEnum[keyof typeof OptionIndexEnum];
================================================
FILE: src/constants/index.ts
================================================
export * from './dom';
export * from './enums';
export * from './theme';
export * from './styled';
export * from './defaults';
================================================
FILE: src/constants/styled.ts
================================================
import { css, keyframes } from 'styled-components';
const BOUNCE_KEYFRAMES = keyframes`
0%, 80%, 100% {
transform: scale(0);
} 40% {
transform: scale(1.0);
}
`;
const FADE_IN_KEYFRAMES = keyframes`
from {
opacity: 0;
} to {
opacity: 1;
}
`;
export const FADE_IN_ANIMATION_CSS = css`${FADE_IN_KEYFRAMES} 0.2s ease-in`;
export const BOUNCE_ANIMATION_CSS = css`${BOUNCE_KEYFRAMES} 1.19s ease-in-out infinite`;
================================================
FILE: src/constants/theme.ts
================================================
import type { DefaultTheme } from 'styled-components';
import { BOUNCE_ANIMATION_CSS, FADE_IN_ANIMATION_CSS } from './styled';
const color = {
border: '#ced4da',
danger: '#dc3545',
primary: '#007bff',
disabled: '#e9ecef',
placeholder: '#6e7276',
dangerLight: 'rgba(220, 53, 69, 0.25)'
} as const;
export const DEFAULT_THEME: DefaultTheme = {
color,
input: {},
select: {},
loader: {
size: '0.625rem',
padding: '0.375rem 0.75rem',
animation: BOUNCE_ANIMATION_CSS,
color: 'rgba(0, 123, 255, 0.42)'
},
icon: {
color: '#ccc',
hoverColor: '#a6a6a6',
padding: '0 14px',
clear: {
width: '14px',
height: '16px',
animation: FADE_IN_ANIMATION_CSS,
transition: 'color 0.2s ease-out'
},
caret: {
size: '7px',
transition: 'transform 0.3s ease-in-out, color 0.2s ease-out'
}
},
control: {
minHeight: '38px',
borderWidth: '1px',
borderStyle: 'solid',
borderRadius: '3px',
padding: '2px 8px',
boxShadow: '0 0 0 0.2rem',
boxShadowColor: 'rgba(0, 123, 255, 0.25)',
focusedBorderColor: 'rgba(0, 123, 255, 0.75)',
transition: 'box-shadow 0.2s ease-out, border-color 0.2s ease-out'
},
menu: {
padding: '0',
width: '100%',
margin: '0.35rem 0',
borderRadius: '3px',
backgroundColor: '#fff',
animation: FADE_IN_ANIMATION_CSS,
boxShadow: '0 0.5em 1em -0.125em rgb(10 10 10 / 12%), 0 0 0 1px rgb(10 10 10 / 4%)',
option: {
textAlign: 'left',
selectedColor: '#fff',
padding: '0.375rem 0.75rem',
selectedBgColor: color.primary,
focusedBgColor: 'rgba(0, 123, 255, 0.15)'
}
},
noOptions: {
fontSize: '1.25rem',
margin: '0.25rem 0',
color: 'hsl(0, 0%, 60%)',
padding: '0.375rem 0.75rem'
},
multiValue: {
margin: '1px 2px',
borderRadius: '3px',
backgroundColor: '#e7edf3',
animation: FADE_IN_ANIMATION_CSS,
label: {
borderRadius: '3px',
fontSize: '0.825em',
padding: '1px 0 1px 6px'
},
clear: {
fontWeight: 600,
padding: '0 6px',
color: '#a6a6a6',
fontSize: '0.65em',
alignSelf: 'center',
focusColor: color.danger,
transition: 'color 0.2s ease-out'
}
}
} as const;
================================================
FILE: src/globals.d.ts
================================================
import type { FlattenSimpleInterpolation } from 'styled-components';
declare module 'styled-components' {
export interface DefaultTheme {
color: {
border: string;
danger: string;
primary: string;
disabled: string;
placeholder: string;
dangerLight: string;
caretActive?: string;
iconSeparator?: string;
};
select: {
css?: string | FlattenSimpleInterpolation;
};
loader: {
size: string;
color: string;
padding: string;
animation: string | FlattenSimpleInterpolation;
};
icon: {
color: string;
padding: string;
hoverColor: string;
css?: string | FlattenSimpleInterpolation;
clear: {
width: string;
height: string;
transition: string;
animation: string | FlattenSimpleInterpolation;
};
caret: {
size: string;
transition: string;
};
};
control: {
height?: string;
padding: string;
minHeight: string;
boxShadow: string;
transition: string;
borderWidth: string;
borderStyle: string;
borderRadius: string;
boxShadowColor: string;
backgroundColor?: string;
focusedBorderColor: string;
css?: string | FlattenSimpleInterpolation;
focusedCss?: string | FlattenSimpleInterpolation;
},
input: {
css?: string | FlattenSimpleInterpolation;
cssRequired?: string | FlattenSimpleInterpolation;
}
menu: {
margin: string;
padding: string;
boxShadow: string;
borderRadius: string;
width: string | number;
backgroundColor: string;
css?: string | FlattenSimpleInterpolation;
animation: string | FlattenSimpleInterpolation;
option: {
padding: string;
textAlign: string;
selectedColor: string;
focusedBgColor: string;
selectedBgColor: string;
};
};
noOptions: {
color: string;
margin: string;
padding: string;
fontSize: string;
css?: string | FlattenSimpleInterpolation;
};
multiValue: {
margin: string;
borderRadius: string;
backgroundColor: string;
css?: string | FlattenSimpleInterpolation;
animation: string | FlattenSimpleInterpolation;
label: {
padding: string;
fontSize: string;
borderRadius: string;
};
clear: {
color: string;
padding: string;
fontSize: string;
alignSelf: string;
transition: string;
focusColor: string;
fontWeight: string | number;
}
}
}
}
================================================
FILE: src/hooks/index.ts
================================================
export { default as useDebounce } from './useDebounce';
export { default as useLatestRef } from './useLatestRef';
export { default as useMountEffect } from './useMountEffect';
export { default as useMenuOptions } from './useMenuOptions';
export { default as useCallbackRef } from './useCallbackRef';
export { default as useUpdateEffect } from './useUpdateEffect';
export { default as useMenuPosition } from './useMenuPosition';
================================================
FILE: src/hooks/useCallbackRef.ts
================================================
import type { CallbackFn } from '../types';
import { useEffect, useRef, useCallback } from 'react';
/**
* Creates a stable callback function that has access to the latest
* state and can be used within event handlers and effect callbacks.
*
* @param callback the callback to write to ref object
*/
const useCallbackRef = <T extends CallbackFn>(callback?: T): T => {
const ref = useRef(callback);
useEffect(() => {
ref.current = callback;
});
return useCallback<CallbackFn>((...args) => ref.current?.(...args), []) as T;
};
export default useCallbackRef;
================================================
FILE: src/hooks/useDebounce.ts
================================================
import { useState } from 'react';
import useUpdateEffect from './useUpdateEffect';
/**
* Debouncer hook (hacky fix to prevent unecessary state mutations if no delay is passed).
* If a number is passed for the delay parameter, use to debounce/set the value.
*
* @param value the value to debounce
* @param delay the delay (ms) for the setTimeout
*/
const useDebounce = <T>(value: T, delay: number = 0): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useUpdateEffect(() => {
if (delay <= 0) return;
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return delay <= 0 ? value : debouncedValue;
};
export default useDebounce;
================================================
FILE: src/hooks/useLatestRef.ts
================================================
import { useRef, type MutableRefObject } from "react"
/**
* Hook to persist value between renders - keeps it up-to-date on changes.
*
* @param value the value to persist
*/
const useLatestRef = <T>(value: T): MutableRefObject<T> => {
const ref = useRef<T>(value);
ref.current = value;
return ref;
};
export default useLatestRef;
================================================
FILE: src/hooks/useMenuOptions.ts
================================================
import { useMemo } from 'react';
import useCallbackRef from './useCallbackRef';
import { FilterMatchEnum, FUNCTIONS } from '../constants';
import { isBoolean, trimAndFormatFilterStr } from '../utils';
import type {
MenuOption,
OptionData,
SelectedOption,
OptionValueCallback,
OptionLabelCallback,
OptionFilterCallback,
OptionDisabledCallback
} from '../types';
/**
* Parse options to array of MenuOptions and perform filtering (if applicable).
*/
const useMenuOptions = (
options: OptionData[],
debouncedInputValue: string,
filterMatchFrom: FilterMatchEnum,
selectedOption: SelectedOption[],
getOptionValue: OptionValueCallback,
getOptionLabel: OptionLabelCallback,
getIsOptionDisabled?: OptionDisabledCallback,
getFilterOptionString?: OptionFilterCallback,
filterIgnoreCase: boolean = false,
filterIgnoreAccents: boolean = false,
isMulti: boolean = false,
async: boolean = false,
hideSelectedOptions?: boolean
): MenuOption[] => {
const getIsOptionDisabledFn = useCallbackRef(getIsOptionDisabled || FUNCTIONS.isOptionDisabled);
const getFilterOptionStringFn = useCallbackRef(getFilterOptionString || FUNCTIONS.optionFilter);
const hideSelectedOptsOrDefault = isBoolean(hideSelectedOptions) ? hideSelectedOptions : isMulti;
const searchValue = !async ? debouncedInputValue : ''; // prevent recomputing on input mutations in async mode
const menuOptions = useMemo<MenuOption[]>(() => {
const selectedValues = selectedOption.map((x) => x.value);
const isFilterMatchAny = filterMatchFrom === FilterMatchEnum.ANY;
const matchVal = trimAndFormatFilterStr(searchValue, filterIgnoreCase, filterIgnoreAccents);
const isOptionFilterMatch = (option: MenuOption): boolean => {
if (!matchVal) return true;
const filterVal = getFilterOptionStringFn(option);
const normalFilterVal = trimAndFormatFilterStr(filterVal, filterIgnoreCase, filterIgnoreAccents);
return isFilterMatchAny
? normalFilterVal.includes(matchVal)
: normalFilterVal.startsWith(matchVal);
};
const parseMenuOption = (data: OptionData): MenuOption | undefined => {
const value = getOptionValue(data);
const label = getOptionLabel(data);
const isDisabled = getIsOptionDisabledFn(data);
const isSelected = selectedValues.includes(value);
const menuOption: MenuOption = { data, value, label, isDisabled, isSelected };
return !isOptionFilterMatch(menuOption) || (hideSelectedOptsOrDefault && isSelected)
? undefined
: menuOption;
};
return options.reduce((acc: MenuOption[], data: OptionData) => {
const menuOption = parseMenuOption(data);
menuOption && acc.push(menuOption);
return acc;
}, []);
}, [
options,
searchValue,
getOptionValue,
getOptionLabel,
selectedOption,
filterMatchFrom,
filterIgnoreCase,
filterIgnoreAccents,
getIsOptionDisabledFn,
getFilterOptionStringFn,
hideSelectedOptsOrDefault
]);
return menuOptions;
};
export default useMenuOptions;
================================================
FILE: src/hooks/useMenuPosition.ts
================================================
import useLatestRef from './useLatestRef';
import type { CallbackFn } from '../types';
import useCallbackRef from './useCallbackRef';
import useUpdateEffect from './useUpdateEffect';
import { MenuPositionEnum } from '../constants';
import { useState, useRef, type RefObject } from 'react';
import { calculateMenuTop, menuFitsBelowControl, scrollMenuIntoViewOnOpen } from '../utils';
type MenuPosition = Readonly<{
menuStyleTop?: string;
menuHeightCalc: number;
}>;
/**
* Handle calculating and maintaining the menuHeight used by react-window.
* Handle scroll animation and callback execution when menuOpen = true.
* Handle resetting menuHeight back to the menuHeightDefault and callback execution when menuOpen = false.
* Use ref to track if the menuHeight was resized, and if so, set the menu height back to default (avoids uncessary renders) with call to setMenuHeight.
* Handle determining where to place the menu in relation to control - when menuPosition = 'top' or menuPosition = 'bottom' and there is not sufficient space below control, place on top.
*/
const useMenuPosition = (
menuRef: RefObject<HTMLElement | null>,
controlRef: RefObject<HTMLElement | null>,
menuOpen: boolean,
menuPosition: MenuPositionEnum,
menuItemSize: number,
menuHeightDefault: number,
menuOptionsLength: number,
isMenuPortaled: boolean,
onMenuOpen?: CallbackFn,
onMenuClose?: CallbackFn,
menuScrollDuration?: number,
scrollMenuIntoView?: boolean
): MenuPosition => {
const isMenuTopPosition =
menuPosition === MenuPositionEnum.TOP ||
(menuPosition === MenuPositionEnum.AUTO && !menuFitsBelowControl(menuRef.current));
const onMenuOpenFn = useCallbackRef(onMenuOpen);
const onMenuCloseFn = useCallbackRef(onMenuClose);
const resetMenuHeightRef = useRef<boolean>(false);
const [menuHeight, setMenuHeight] = useState<number>(menuHeightDefault);
const shouldScrollRef = useLatestRef<boolean>(!isMenuTopPosition && !isMenuPortaled);
useUpdateEffect(() => {
if (menuOpen) {
const handleOnMenuOpen = (availableSpace?: number) => {
onMenuOpenFn();
if (availableSpace) {
resetMenuHeightRef.current = true;
setMenuHeight(availableSpace);
}
};
shouldScrollRef.current
? scrollMenuIntoViewOnOpen(
menuRef.current,
menuScrollDuration,
scrollMenuIntoView,
handleOnMenuOpen
)
: handleOnMenuOpen();
} else {
onMenuCloseFn();
if (resetMenuHeightRef.current) {
resetMen
gitextract_i9kdersq/ ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github/ │ └── workflows/ │ └── chromatic.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .storybook/ │ ├── global-style/ │ │ ├── global-style.ts │ │ ├── index.ts │ │ └── react-toastify-override.ts │ ├── main.ts │ ├── manager-head.html │ ├── manager.ts │ └── preview.tsx ├── .test/ │ ├── custom-test-env.ts │ └── setup-tests.ts ├── .travis.yml ├── LICENSE ├── README.md ├── __stories__/ │ ├── helpers/ │ │ ├── components/ │ │ │ ├── Checkbox.tsx │ │ │ ├── CodeMarkup.tsx │ │ │ ├── OptionsCountButton.tsx │ │ │ ├── PackageLink.tsx │ │ │ └── index.ts │ │ ├── constants/ │ │ │ ├── index.ts │ │ │ ├── markup.ts │ │ │ ├── npm-package.ts │ │ │ ├── options-data.ts │ │ │ ├── react-toastify.ts │ │ │ ├── svg-props.ts │ │ │ └── theme.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ └── useCallbackState.ts │ │ ├── index.ts │ │ ├── styled/ │ │ │ └── index.ts │ │ └── utils/ │ │ └── index.ts │ ├── index.stories.tsx │ └── types/ │ └── index.d.ts ├── __tests__/ │ ├── AriaLiveRegion.test.tsx │ ├── AutosizeInput.test.tsx │ ├── IndicatorIcons.test.tsx │ ├── LoadingDots.test.tsx │ ├── MenuList.test.tsx │ ├── MultiValue.test.tsx │ ├── Option.test.tsx │ ├── ReactSSR.test.tsx │ ├── Select.test.tsx │ ├── Value.test.tsx │ └── helpers/ │ ├── ThemeWrapper.tsx │ ├── index.ts │ └── utils.ts ├── babel.config.js ├── jest.config.js ├── package.json ├── rollup.config.js ├── src/ │ ├── Select.tsx │ ├── components/ │ │ ├── AriaLiveRegion/ │ │ │ └── index.tsx │ │ ├── AutosizeInput/ │ │ │ └── index.tsx │ │ ├── IndicatorIcons/ │ │ │ ├── ClearIcon.tsx │ │ │ ├── LoadingDots.tsx │ │ │ └── index.tsx │ │ ├── Menu/ │ │ │ ├── MenuList.tsx │ │ │ ├── Option.tsx │ │ │ └── index.tsx │ │ ├── Value/ │ │ │ ├── MultiValue.tsx │ │ │ └── index.tsx │ │ └── index.ts │ ├── constants/ │ │ ├── defaults.ts │ │ ├── dom.ts │ │ ├── enums.ts │ │ ├── index.ts │ │ ├── styled.ts │ │ └── theme.ts │ ├── globals.d.ts │ ├── hooks/ │ │ ├── index.ts │ │ ├── useCallbackRef.ts │ │ ├── useDebounce.ts │ │ ├── useLatestRef.ts │ │ ├── useMenuOptions.ts │ │ ├── useMenuPosition.ts │ │ ├── useMountEffect.ts │ │ └── useUpdateEffect.ts │ ├── index.ts │ ├── types.ts │ └── utils/ │ ├── common.ts │ ├── device.ts │ ├── index.ts │ └── menu.ts └── tsconfig.json
SYMBOL INDEX (134 symbols across 41 files)
FILE: .storybook/global-style/react-toastify-override.ts
constant TOASTIFY_BOUNCE_OUT (line 3) | const TOASTIFY_BOUNCE_OUT = keyframes`
constant TOASTIFY_BOUNCE_IN (line 16) | const TOASTIFY_BOUNCE_IN = keyframes`
FILE: .test/custom-test-env.ts
method setup (line 7) | async setup() {
FILE: __stories__/helpers/components/Checkbox.tsx
type CheckboxProps (line 5) | type CheckboxProps = Readonly<{
constant CHECK_COLOR (line 12) | const CHECK_COLOR = '#149DF3';
constant CHECK_BORDER_COLOR (line 13) | const CHECK_BORDER_COLOR = hexToRgba(CHECK_COLOR, 0.83);
FILE: __stories__/helpers/components/CodeMarkup.tsx
type CodeMarkupProps (line 14) | type CodeMarkupProps = Readonly<{
FILE: __stories__/helpers/components/OptionsCountButton.tsx
type OptionsCountButtonProps (line 6) | type OptionsCountButtonProps = Readonly<{
FILE: __stories__/helpers/components/PackageLink.tsx
type PackageLinkProps (line 4) | type PackageLinkProps = Readonly<{
FILE: __stories__/helpers/constants/markup.ts
constant CLASS_NAME_HTML (line 15) | const CLASS_NAME_HTML =
FILE: __stories__/helpers/constants/npm-package.ts
constant STYLED_COMPONENTS_PACKAGE (line 1) | const STYLED_COMPONENTS_PACKAGE = {
constant REACT_WINDOW_PACKAGE (line 6) | const REACT_WINDOW_PACKAGE = {
FILE: __stories__/helpers/constants/options-data.ts
constant PACKAGE_OPTIONS (line 3) | const PACKAGE_OPTIONS: PackageOption[] = [
constant CITY_OPTIONS (line 11) | const CITY_OPTIONS: CityOption[] = [
FILE: __stories__/helpers/constants/react-toastify.ts
constant TOAST_CONTAINER_PROPS (line 10) | const TOAST_CONTAINER_PROPS: ToastContainerProps = {
FILE: __stories__/helpers/constants/svg-props.ts
constant CHEVRON_SVG_PROPS (line 3) | const CHEVRON_SVG_PROPS = {
constant CHEVRON_DOWN_PATH_PROPS (line 8) | const CHEVRON_DOWN_PATH_PROPS: SVGProps<SVGPathElement> = {
constant REACT_SVG_PROPS (line 12) | const REACT_SVG_PROPS = {
constant REACT_SVG_PATH_PROPS (line 17) | const REACT_SVG_PATH_PROPS: SVGProps<SVGPathElement> = {
constant REACT_SVG_CIRCLE_PROPS (line 21) | const REACT_SVG_CIRCLE_PROPS: SVGProps<SVGCircleElement> = {
FILE: __stories__/helpers/constants/theme.ts
constant FADE_IN_KEYFRAMES_STR (line 7) | const FADE_IN_KEYFRAMES_STR = 'FADE_IN_KEYFRAMES 0.25s ease-in-out';
constant BOUNCE_KEYFRAMES_STR (line 8) | const BOUNCE_KEYFRAMES_STR = 'BOUNCE_KEYFRAMES 1.19s ease-in-out infinite';
constant THEME_ANIMATIONS (line 10) | const THEME_ANIMATIONS: Theme = {
constant THEME_CONFIG (line 34) | const THEME_CONFIG: Theme = {
constant THEME_OPTIONS (line 84) | const THEME_OPTIONS = createThemeOptions(ThemeEnum);
constant THEME_DEFAULTS (line 85) | const THEME_DEFAULTS = mergeDeep(DEFAULT_THEME, THEME_ANIMATIONS);
FILE: __stories__/helpers/styled/index.ts
constant MEDIA_QUERY_IS_MOBILE (line 3) | const MEDIA_QUERY_IS_MOBILE = '@media only screen and (max-width: 768px)';
constant MEDIA_QUERY_IS_MOBILE_XS (line 4) | const MEDIA_QUERY_IS_MOBILE_XS = '@media only screen and (max-width: 525...
constant MEDIA_QUERY_IS_TABLET_OR_DESKTOP (line 5) | const MEDIA_QUERY_IS_TABLET_OR_DESKTOP = '@media only screen and (min-wi...
constant MEDIA_QUERY_IS_TABLET (line 6) | const MEDIA_QUERY_IS_TABLET = '@media only screen and (max-width: 991px)...
constant PARAGRAPH_BASE_STYLE (line 10) | const PARAGRAPH_BASE_STYLE = css`
constant SPIN_KEYFRAMES (line 342) | const SPIN_KEYFRAMES = keyframes`
constant SPIN_ANIMATION_CSS (line 350) | const SPIN_ANIMATION_CSS = css`animation: ${SPIN_KEYFRAMES} infinite 8s ...
FILE: __stories__/types/index.d.ts
type Option (line 1) | type Option = Readonly<{
type CityOption (line 6) | type CityOption = Readonly<{
type PackageOption (line 12) | type PackageOption = Readonly<{
FILE: __tests__/AriaLiveRegion.test.tsx
type AriaLiveRegionProps (line 8) | type AriaLiveRegionProps = ComponentProps<typeof AriaLiveRegion>;
constant BASE_PROPS (line 25) | const BASE_PROPS: AriaLiveRegionProps = {
FILE: __tests__/AutosizeInput.test.tsx
type AutosizeInputProps (line 8) | type AutosizeInputProps = ComponentProps<typeof AutosizeInput>;
constant BASE_PROPS (line 29) | const BASE_PROPS: AutosizeInputProps = {
FILE: __tests__/IndicatorIcons.test.tsx
type IndicatorIconsProps (line 8) | type IndicatorIconsProps = ComponentProps<typeof IndicatorIcons>;
constant BASE_PROPS (line 28) | const BASE_PROPS: IndicatorIconsProps = {
FILE: __tests__/MenuList.test.tsx
type MenuListProps (line 15) | type MenuListProps = ComponentProps<typeof MenuList>;
constant BASE_PROPS (line 29) | const BASE_PROPS: MenuListProps = {
FILE: __tests__/MultiValue.test.tsx
type MultiValueProps (line 8) | type MultiValueProps = ComponentProps<typeof MultiValue>;
constant BASE_PROPS (line 30) | const BASE_PROPS: MultiValueProps = {
FILE: __tests__/Option.test.tsx
type OptionProps (line 9) | type OptionProps = ComponentProps<typeof Option>;
constant OPTION_STYLE (line 26) | const OPTION_STYLE: CSSProperties = {
FILE: __tests__/Select.test.tsx
type SelectProps (line 15) | type SelectProps = ComponentProps<typeof Select>;
FILE: __tests__/Value.test.tsx
type ValueProps (line 8) | type ValueProps = ComponentProps<typeof Value>;
constant BASE_PROPS (line 34) | const BASE_PROPS: ValueProps = {
FILE: __tests__/helpers/utils.ts
type Option (line 9) | type Option = Readonly<{
constant OPTIONS (line 14) | const OPTIONS: Option[] = [
constant MENU_OPTION_SELECTED (line 32) | const MENU_OPTION_SELECTED: MenuOption = {
constant MENU_OPTION_DISABLED (line 43) | const MENU_OPTION_DISABLED: MenuOption = {
constant MENU_OPTIONS (line 54) | const MENU_OPTIONS: MenuOption[] = [MENU_OPTION_SELECTED, MENU_OPTION_DI...
FILE: src/Select.tsx
type SelectProps (line 58) | type SelectProps = Readonly<{
type ValueWrapperProps (line 124) | type ValueWrapperProps = Readonly<{
type ControlWrapperProps (line 128) | interface ControlWrapperProps extends Pick<SelectProps, 'isInvalid' | 'i...
FILE: src/components/AriaLiveRegion/index.tsx
type AriaLiveRegionProps (line 6) | type AriaLiveRegionProps = Readonly<{
FILE: src/components/AutosizeInput/index.tsx
type AutosizeInputProps (line 5) | type AutosizeInputProps = Readonly<{
type InputProps (line 21) | type InputProps = Pick<AutosizeInputProps, 'isInvalid'>;
constant INPUT_BASE_STYLE (line 23) | const INPUT_BASE_STYLE = css`
FILE: src/components/IndicatorIcons/index.tsx
type IndicatorIconsProps (line 9) | type IndicatorIconsProps = Readonly<{
type CaretProps (line 22) | type CaretProps = Pick<IndicatorIconsProps, 'menuOpen' | 'isInvalid'>;
FILE: src/components/Menu/MenuList.tsx
type MenuListProps (line 8) | type MenuListProps = Readonly<{
FILE: src/components/Menu/Option.tsx
type OptionProps (line 6) | type OptionProps = Readonly<{
FILE: src/components/Menu/index.tsx
type MenuProps (line 15) | interface MenuProps extends MenuListProps {
type MenuWrapperProps (line 25) | interface MenuWrapperProps extends Pick<MenuProps, 'menuOpen' | 'menuTop...
FILE: src/components/Value/MultiValue.tsx
type MultiValueProps (line 7) | type MultiValueProps = SelectedOption & Readonly<{
type ClearProps (line 13) | type ClearProps = Pick<MultiValueProps, 'isFocused'>;
constant CLEAR_ICON_FOCUS_STYLE (line 15) | const CLEAR_ICON_FOCUS_STYLE = css`
FILE: src/components/Value/index.tsx
type ValueProps (line 6) | type ValueProps = Readonly<{
FILE: src/constants/defaults.ts
constant PAGE_SIZE_DEFAULT (line 9) | const PAGE_SIZE_DEFAULT = 5;
constant MENU_ITEM_SIZE_DEFAULT (line 10) | const MENU_ITEM_SIZE_DEFAULT = 35;
constant MENU_MAX_HEIGHT_DEFAULT (line 11) | const MENU_MAX_HEIGHT_DEFAULT = 300;
constant LOADING_MSG_DEFAULT (line 12) | const LOADING_MSG_DEFAULT = 'Loading..';
constant NO_OPTIONS_MSG_DEFAULT (line 13) | const NO_OPTIONS_MSG_DEFAULT = 'No options';
constant PLACEHOLDER_DEFAULT (line 14) | const PLACEHOLDER_DEFAULT = 'Select option..';
constant EMPTY_ARRAY (line 16) | const EMPTY_ARRAY: any[] = [];
constant FOCUSED_OPTION_DEFAULT (line 17) | const FOCUSED_OPTION_DEFAULT: FocusedOption = { index: -1 };
constant FUNCTIONS (line 19) | const FUNCTIONS = {
FILE: src/constants/dom.ts
constant ARIA_LIVE_CONTEXT_ID (line 5) | const ARIA_LIVE_CONTEXT_ID = 'aria-context';
constant ARIA_LIVE_SELECTION_ID (line 6) | const ARIA_LIVE_SELECTION_ID = 'aria-selection';
constant OPTION_CLS (line 9) | const OPTION_CLS = 'rfs-option';
constant OPTION_FOCUSED_CLS (line 10) | const OPTION_FOCUSED_CLS = `${OPTION_CLS}-focused`;
constant OPTION_SELECTED_CLS (line 11) | const OPTION_SELECTED_CLS = `${OPTION_CLS}-selected`;
constant OPTION_DISABLED_CLS (line 12) | const OPTION_DISABLED_CLS = `${OPTION_CLS}-disabled`;
constant CARET_ICON_CLS (line 15) | const CARET_ICON_CLS = 'rfs-caret-icon';
constant CLEAR_ICON_CLS (line 16) | const CLEAR_ICON_CLS = 'rfs-clear-icon';
constant LOADING_DOTS_CLS (line 17) | const LOADING_DOTS_CLS = 'rfs-loading-dots';
constant AUTOSIZE_INPUT_CLS (line 18) | const AUTOSIZE_INPUT_CLS = 'rfs-autosize-input';
constant MENU_CONTAINER_CLS (line 19) | const MENU_CONTAINER_CLS = 'rfs-menu-container';
constant SELECT_CONTAINER_CLS (line 20) | const SELECT_CONTAINER_CLS = 'rfs-select-container';
constant CONTROL_CONTAINER_CLS (line 21) | const CONTROL_CONTAINER_CLS = 'rfs-control-container';
constant CLEAR_ICON_TESTID (line 26) | const CLEAR_ICON_TESTID = isTest ? CLEAR_ICON_CLS : undefined;
constant CARET_ICON_TESTID (line 27) | const CARET_ICON_TESTID = isTest ? CARET_ICON_CLS : undefined;
constant AUTOSIZE_INPUT_TESTID (line 28) | const AUTOSIZE_INPUT_TESTID = isTest ? AUTOSIZE_INPUT_CLS : undefined;
constant MENU_CONTAINER_TESTID (line 29) | const MENU_CONTAINER_TESTID = isTest ? MENU_CONTAINER_CLS : undefined;
constant CLEAR_ICON_MV_TESTID (line 30) | const CLEAR_ICON_MV_TESTID = isTest ? `${CLEAR_ICON_CLS}-mv` : undefined;
constant SELECT_CONTAINER_TESTID (line 31) | const SELECT_CONTAINER_TESTID = isTest ? SELECT_CONTAINER_CLS : undefined;
constant CONTROL_CONTAINER_TESTID (line 32) | const CONTROL_CONTAINER_TESTID = isTest ? CONTROL_CONTAINER_CLS : undefi...
constant AUTOSIZE_INPUT_ATTRS (line 37) | const AUTOSIZE_INPUT_ATTRS: InputHTMLAttributes<HTMLInputElement> & Test...
FILE: src/constants/enums.ts
type MenuPositionEnum (line 11) | type MenuPositionEnum = typeof MenuPositionEnum[keyof typeof MenuPositio...
type FilterMatchEnum (line 22) | type FilterMatchEnum = typeof FilterMatchEnum[keyof typeof FilterMatchEn...
type OptionIndexEnum (line 36) | type OptionIndexEnum = typeof OptionIndexEnum[keyof typeof OptionIndexEn...
FILE: src/constants/styled.ts
constant BOUNCE_KEYFRAMES (line 3) | const BOUNCE_KEYFRAMES = keyframes`
constant FADE_IN_KEYFRAMES (line 11) | const FADE_IN_KEYFRAMES = keyframes`
constant FADE_IN_ANIMATION_CSS (line 19) | const FADE_IN_ANIMATION_CSS = css`${FADE_IN_KEYFRAMES} 0.2s ease-in`;
constant BOUNCE_ANIMATION_CSS (line 20) | const BOUNCE_ANIMATION_CSS = css`${BOUNCE_KEYFRAMES} 1.19s ease-in-out i...
FILE: src/constants/theme.ts
constant DEFAULT_THEME (line 13) | const DEFAULT_THEME: DefaultTheme = {
FILE: src/globals.d.ts
type DefaultTheme (line 4) | interface DefaultTheme {
FILE: src/hooks/useMenuPosition.ts
type MenuPosition (line 9) | type MenuPosition = Readonly<{
FILE: src/types.ts
type OptionData (line 4) | type OptionData = any;
type CallbackFn (line 5) | type CallbackFn = (...args: any[]) => any;
type AriaLiveAttribute (line 6) | type AriaLiveAttribute = 'off' | 'polite' | 'assertive';
type IconRenderer (line 8) | type IconRenderer = ReactNode | ((...args: any[]) => ReactNode);
type OptionValueCallback (line 10) | type OptionValueCallback = (data: OptionData) => string | number;
type OptionLabelCallback (line 11) | type OptionLabelCallback = OptionValueCallback;
type RenderLabelCallback (line 12) | type RenderLabelCallback = (data: OptionData) => ReactNode;
type OptionFilterCallback (line 13) | type OptionFilterCallback = (option: MenuOption) => string;
type OptionDisabledCallback (line 14) | type OptionDisabledCallback = (data: OptionData) => boolean;
type MouseOrTouchEvent (line 16) | type MouseOrTouchEvent<T = Element> = MouseEvent<T> | TouchEvent<T>;
type MouseOrTouchEventHandler (line 17) | type MouseOrTouchEventHandler<T = Element> = EventHandler<MouseOrTouchEv...
type PartialDeep (line 19) | type PartialDeep<T> = {
type TestableElement (line 23) | type TestableElement = Readonly<{
type SelectedOption (line 27) | type SelectedOption = Readonly<{
type FocusedOption (line 33) | interface FocusedOption extends SelectedOption {
type ItemData (line 39) | type ItemData = Readonly<{
type MultiParams (line 47) | type MultiParams = Readonly<{
type MenuOption (line 52) | type MenuOption = Readonly<{
type SelectRef (line 60) | type SelectRef = Readonly<{
type Theme (line 69) | type Theme = PartialDeep<DefaultTheme>;
FILE: src/utils/common.ts
constant DIACRITICS_REG_EXP (line 5) | const DIACRITICS_REG_EXP = /[\u0300-\u036f]/g;
Condensed preview — 90 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (214K chars).
[
{
"path": ".editorconfig",
"chars": 271,
"preview": "# http://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nindent_size = 2\nend_of_line = lf\nindent_style = space\ninsert_"
},
{
"path": ".eslintignore",
"chars": 17,
"preview": "dist\nnode_modules"
},
{
"path": ".eslintrc",
"chars": 1003,
"preview": "{\n \"parser\": \"@typescript-eslint/parser\",\n \"extends\": [\n \"plugin:react-hooks/recommended\",\n \"plugin:@typescript-"
},
{
"path": ".github/workflows/chromatic.yml",
"chars": 578,
"preview": "# .github/workflows/chromatic.yml\n\n# Workflow name\nname: 'Chromatic'\n\n# Event for the workflow\non: push\n\njobs:\n chromat"
},
{
"path": ".gitignore",
"chars": 324,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# Editor directories and files\n.i"
},
{
"path": ".npmrc",
"chars": 41,
"preview": "registry = \"https://registry.npmjs.com/\"\n"
},
{
"path": ".prettierignore",
"chars": 17,
"preview": "dist\nnode_modules"
},
{
"path": ".prettierrc",
"chars": 139,
"preview": "{\n \"bracketSpacing\": false,\n \"printWidth\": 100,\n \"trailingComma\": \"es5\",\n \"tabWidth\": 2,\n \"singleQuote\": true,\n \"e"
},
{
"path": ".storybook/global-style/global-style.ts",
"chars": 1362,
"preview": "import { createGlobalStyle } from 'styled-components';\nimport ReactToastifyOverride from './react-toastify-override';\n\nc"
},
{
"path": ".storybook/global-style/index.ts",
"chars": 56,
"preview": "export { default as GlobalStyle } from './global-style';"
},
{
"path": ".storybook/global-style/react-toastify-override.ts",
"chars": 1298,
"preview": "import { css, keyframes } from 'styled-components';\n\nconst TOASTIFY_BOUNCE_OUT = keyframes`\n 20% {\n transform: scale"
},
{
"path": ".storybook/main.ts",
"chars": 356,
"preview": "import type { StorybookConfig } from '@storybook/react/types';\n\nconst config: StorybookConfig = {\n framework: '@storybo"
},
{
"path": ".storybook/manager-head.html",
"chars": 4688,
"preview": "<style lang=\"css\">\n #panel-tab-content {\n background: #292d3e !important;\n }\n\n #storybook-panel-root .os-content >"
},
{
"path": ".storybook/manager.ts",
"chars": 396,
"preview": "import { addons } from '@storybook/addons';\nimport { create } from '@storybook/theming';\n\nconst theme = create({\n base:"
},
{
"path": ".storybook/preview.tsx",
"chars": 426,
"preview": "import React, { Fragment } from 'react';\nimport { GlobalStyle } from './global-style';\nimport type { DecoratorFn } from "
},
{
"path": ".test/custom-test-env.ts",
"chars": 419,
"preview": "import Environment from 'jest-environment-jsdom';\n\n/**\n * A custom environment to set the TextEncoder that is required b"
},
{
"path": ".test/setup-tests.ts",
"chars": 36,
"preview": "import '@testing-library/jest-dom';\n"
},
{
"path": ".travis.yml",
"chars": 35,
"preview": "language: node_js\nnode_js:\n - node"
},
{
"path": "LICENSE",
"chars": 1072,
"preview": "MIT License\n\nCopyright (c) 2021 Matthew Areddia\n\nPermission is hereby granted, free of charge, to any person obtaining a"
},
{
"path": "README.md",
"chars": 14612,
"preview": "[](https://www.npmjs.com/package/react"
},
{
"path": "__stories__/helpers/components/Checkbox.tsx",
"chars": 2271,
"preview": "import React from 'react';\nimport { hexToRgba } from '../utils';\nimport styled, { css } from 'styled-components';\n\ntype "
},
{
"path": "__stories__/helpers/components/CodeMarkup.tsx",
"chars": 2608,
"preview": "import React, { memo } from 'react';\nimport styled from 'styled-components';\nimport { MEDIA_QUERY_IS_MOBILE } from '../s"
},
{
"path": "__stories__/helpers/components/OptionsCountButton.tsx",
"chars": 1073,
"preview": "import React from 'react';\nimport { Button } from '../styled';\nimport { numberWithCommas } from '../utils';\nimport style"
},
{
"path": "__stories__/helpers/components/PackageLink.tsx",
"chars": 539,
"preview": "import React from 'react';\nimport styled from 'styled-components';\n\ntype PackageLinkProps = Readonly<{\n name: string;\n "
},
{
"path": "__stories__/helpers/components/index.ts",
"chars": 229,
"preview": "export { default as Checkbox } from './Checkbox';\nexport { default as CodeMarkup } from './CodeMarkup';\nexport { default"
},
{
"path": "__stories__/helpers/constants/index.ts",
"chars": 176,
"preview": "export * from './theme';\nexport * from './markup';\nexport * from './svg-props';\nexport * from './npm-package';\nexport * "
},
{
"path": "__stories__/helpers/constants/markup.ts",
"chars": 1474,
"preview": "import {\n OPTION_CLS,\n OPTION_FOCUSED_CLS,\n OPTION_DISABLED_CLS,\n OPTION_SELECTED_CLS,\n CARET_ICON_CLS,\n CLEAR_ICO"
},
{
"path": "__stories__/helpers/constants/npm-package.ts",
"chars": 252,
"preview": "export const STYLED_COMPONENTS_PACKAGE = {\n name: 'styled-components',\n href: 'https://www.styled-components.com'\n} as"
},
{
"path": "__stories__/helpers/constants/options-data.ts",
"chars": 759,
"preview": "import type { CityOption, PackageOption } from '../../types';\n\nexport const PACKAGE_OPTIONS: PackageOption[] = [\n { id:"
},
{
"path": "__stories__/helpers/constants/react-toastify.ts",
"chars": 475,
"preview": "import { cssTransition, type ToastContainerProps } from 'react-toastify';\n\n// CSS transition config => 'transition' prop"
},
{
"path": "__stories__/helpers/constants/svg-props.ts",
"chars": 3269,
"preview": "import type { SVGProps } from 'react';\n\nexport const CHEVRON_SVG_PROPS = {\n 'aria-hidden': true,\n viewBox: '0 0 448 51"
},
{
"path": "__stories__/helpers/constants/theme.ts",
"chars": 1988,
"preview": "import type { Theme } from '../../../src';\nimport { createThemeOptions } from '../utils';\nimport { mergeDeep } from '../"
},
{
"path": "__stories__/helpers/hooks/index.ts",
"chars": 65,
"preview": "export { default as useCallbackState } from './useCallbackState';"
},
{
"path": "__stories__/helpers/hooks/useCallbackState.ts",
"chars": 335,
"preview": "import { useCallback, useState } from 'react';\n\nconst useCallbackState = <T>(initState: T): [T, (newState: T) => void] ="
},
{
"path": "__stories__/helpers/index.ts",
"chars": 134,
"preview": "export * from './utils';\nexport * from './hooks';\nexport * from './styled';\nexport * from './constants';\nexport * from '"
},
{
"path": "__stories__/helpers/styled/index.ts",
"chars": 7700,
"preview": "import styled, { css, keyframes } from 'styled-components';\n\nexport const MEDIA_QUERY_IS_MOBILE = '@media only screen an"
},
{
"path": "__stories__/helpers/utils/index.ts",
"chars": 1749,
"preview": "import type { Option } from '../../types';\n\nexport const numberWithCommas = (value: number): string => {\n return value."
},
{
"path": "__stories__/index.stories.tsx",
"chars": 38843,
"preview": "import { Select } from '../src';\nimport { useUpdateEffect } from '../src/hooks';\nimport type { SelectedOption } from '.."
},
{
"path": "__stories__/types/index.d.ts",
"chars": 249,
"preview": "export type Option = Readonly<{\n label: string | number;\n value: string | number;\n}>;\n\nexport type CityOption = Readon"
},
{
"path": "__tests__/AriaLiveRegion.test.tsx",
"chars": 2070,
"preview": "import React, { type ComponentProps } from 'react';\nimport { render } from '@testing-library/react';\nimport AriaLiveRegi"
},
{
"path": "__tests__/AutosizeInput.test.tsx",
"chars": 2987,
"preview": "import React, { type ComponentProps } from 'react';\nimport { ThemeWrapper } from './helpers';\nimport userEvent from '@te"
},
{
"path": "__tests__/IndicatorIcons.test.tsx",
"chars": 4765,
"preview": "import React, { type ReactNode, type ComponentProps } from 'react';\nimport { ThemeWrapper } from './helpers';\nimport { r"
},
{
"path": "__tests__/LoadingDots.test.tsx",
"chars": 744,
"preview": "import React from 'react';\nimport { ThemeWrapper } from './helpers';\nimport { render } from '@testing-library/react';\nim"
},
{
"path": "__tests__/MenuList.test.tsx",
"chars": 3151,
"preview": "import React, { type ComponentProps } from 'react';\nimport { ThemeWrapper } from './helpers';\nimport type { MenuOption }"
},
{
"path": "__tests__/MultiValue.test.tsx",
"chars": 1788,
"preview": "import React, { type ComponentProps } from 'react';\nimport { render } from '@testing-library/react';\nimport userEvent fr"
},
{
"path": "__tests__/Option.test.tsx",
"chars": 2887,
"preview": "import React, { type ComponentProps } from 'react';\nimport type { CSSProperties } from 'react';\nimport { render } from '"
},
{
"path": "__tests__/ReactSSR.test.tsx",
"chars": 251,
"preview": "import React from 'react';\nimport { Select } from '../src';\nimport { renderToString } from 'react-dom/server';\n\ntest('Se"
},
{
"path": "__tests__/Select.test.tsx",
"chars": 3224,
"preview": "import { Select } from '../src';\nimport React, { type ComponentProps } from 'react';\nimport userEvent from '@testing-lib"
},
{
"path": "__tests__/Value.test.tsx",
"chars": 3077,
"preview": "import React, { type ComponentProps } from 'react';\nimport Value from '../src/components/Value';\nimport { render } from "
},
{
"path": "__tests__/helpers/ThemeWrapper.tsx",
"chars": 279,
"preview": "import React from 'react';\nimport { ThemeProvider } from 'styled-components';\nimport { DEFAULT_THEME } from '../../src/c"
},
{
"path": "__tests__/helpers/index.ts",
"chars": 82,
"preview": "export * from './utils';\nexport { default as ThemeWrapper } from './ThemeWrapper';"
},
{
"path": "__tests__/helpers/utils.ts",
"chars": 1999,
"preview": "import type { ReactNode, CSSProperties } from 'react';\nimport type { MultiParams, MenuOption } from '../../src';\nimport "
},
{
"path": "babel.config.js",
"chars": 584,
"preview": "module.exports = (api) => {\n const isTest = api.env('test');\n const targets = isTest ? { node: 'current' } : undefined"
},
{
"path": "jest.config.js",
"chars": 303,
"preview": "module.exports = {\n transform: {'\\\\.[jt]sx?$': 'babel-jest'},\n testEnvironment: '<rootDir>/.test/custom-test-env.ts',\n"
},
{
"path": "package.json",
"chars": 3932,
"preview": "{\n \"name\": \"react-functional-select\",\n \"version\": \"5.0.0\",\n \"description\": \"Micro-sized and micro-optimized select co"
},
{
"path": "rollup.config.js",
"chars": 2587,
"preview": "import path from 'path';\nimport babel from '@rollup/plugin-babel';\nimport terser from '@rollup/plugin-terser';\nimport re"
},
{
"path": "src/Select.tsx",
"chars": 25237,
"preview": "import React, {\n useRef,\n useMemo,\n useState,\n useEffect,\n forwardRef,\n useCallback,\n useImperativeHandle,\n type"
},
{
"path": "src/components/AriaLiveRegion/index.tsx",
"chars": 2315,
"preview": "import React from 'react';\nimport styled from 'styled-components';\nimport { ARIA_LIVE_SELECTION_ID, ARIA_LIVE_CONTEXT_ID"
},
{
"path": "src/components/AutosizeInput/index.tsx",
"chars": 2259,
"preview": "import styled, { css } from 'styled-components';\nimport { AUTOSIZE_INPUT_ATTRS } from '../../constants';\nimport React, {"
},
{
"path": "src/components/IndicatorIcons/ClearIcon.tsx",
"chars": 745,
"preview": "import React from 'react';\nimport styled, { css } from 'styled-components';\nimport { CLEAR_ICON_CLS } from '../../consta"
},
{
"path": "src/components/IndicatorIcons/LoadingDots.tsx",
"chars": 894,
"preview": "import React from 'react';\nimport styled, { css } from 'styled-components';\nimport { LOADING_DOTS_CLS } from '../../cons"
},
{
"path": "src/components/IndicatorIcons/index.tsx",
"chars": 3241,
"preview": "import React, { memo, type ReactNode } from 'react';\nimport ClearIcon from './ClearIcon';\nimport LoadingDots from './Loa"
},
{
"path": "src/components/Menu/MenuList.tsx",
"chars": 2541,
"preview": "import React, { useMemo, Fragment, type MutableRefObject } from 'react';\r\nimport Option from './Option';\r\nimport styled "
},
{
"path": "src/components/Menu/Option.tsx",
"chars": 1108,
"preview": "import React, { memo, type CSSProperties } from 'react';\r\nimport { areEqual } from 'react-window';\r\nimport type { ItemDa"
},
{
"path": "src/components/Menu/index.tsx",
"chars": 2988,
"preview": "import React, { type MutableRefObject } from 'react';\nimport { createPortal } from 'react-dom';\nimport styled, { css } f"
},
{
"path": "src/components/Value/MultiValue.tsx",
"chars": 2336,
"preview": "import React from 'react';\r\nimport { suppressEvent } from '../../utils';\r\nimport styled, { css } from 'styled-components"
},
{
"path": "src/components/Value/index.tsx",
"chars": 1946,
"preview": "import React, { memo, Fragment, type ReactNode } from 'react';\nimport MultiValue from './MultiValue';\nimport styled from"
},
{
"path": "src/components/index.ts",
"chars": 269,
"preview": "export { default as Menu } from './Menu';\nexport { default as Value } from './Value';\nexport { default as AutosizeInput "
},
{
"path": "src/constants/defaults.ts",
"chars": 892,
"preview": "import type {\n FocusedOption,\n OptionValueCallback,\n OptionLabelCallback,\n OptionFilterCallback,\n OptionDisabledCal"
},
{
"path": "src/constants/dom.ts",
"chars": 2139,
"preview": "import type { TestableElement } from '../types';\nimport type { InputHTMLAttributes } from 'react';\n\n// id attributes for"
},
{
"path": "src/constants/enums.ts",
"chars": 899,
"preview": "/**\n * Menu position in relation to the control.\n * Defaults to 'auto' - meaning, if not enough space below control, the"
},
{
"path": "src/constants/index.ts",
"chars": 126,
"preview": "export * from './dom';\nexport * from './enums';\nexport * from './theme';\nexport * from './styled';\nexport * from './defa"
},
{
"path": "src/constants/styled.ts",
"chars": 437,
"preview": "import { css, keyframes } from 'styled-components';\n\nconst BOUNCE_KEYFRAMES = keyframes`\n 0%, 80%, 100% {\n transform"
},
{
"path": "src/constants/theme.ts",
"chars": 2265,
"preview": "import type { DefaultTheme } from 'styled-components';\nimport { BOUNCE_ANIMATION_CSS, FADE_IN_ANIMATION_CSS } from './st"
},
{
"path": "src/globals.d.ts",
"chars": 2644,
"preview": "import type { FlattenSimpleInterpolation } from 'styled-components';\n\ndeclare module 'styled-components' {\n export inte"
},
{
"path": "src/hooks/index.ts",
"chars": 427,
"preview": "export { default as useDebounce } from './useDebounce';\nexport { default as useLatestRef } from './useLatestRef';\nexport"
},
{
"path": "src/hooks/useCallbackRef.ts",
"chars": 574,
"preview": "import type { CallbackFn } from '../types';\nimport { useEffect, useRef, useCallback } from 'react';\n\n/**\n * Creates a st"
},
{
"path": "src/hooks/useDebounce.ts",
"chars": 780,
"preview": "import { useState } from 'react';\nimport useUpdateEffect from './useUpdateEffect';\n\n/**\n * Debouncer hook (hacky fix to "
},
{
"path": "src/hooks/useLatestRef.ts",
"chars": 340,
"preview": "import { useRef, type MutableRefObject } from \"react\"\n\n/**\n * Hook to persist value between renders - keeps it up-to-dat"
},
{
"path": "src/hooks/useMenuOptions.ts",
"chars": 3063,
"preview": "import { useMemo } from 'react';\nimport useCallbackRef from './useCallbackRef';\nimport { FilterMatchEnum, FUNCTIONS } fr"
},
{
"path": "src/hooks/useMenuPosition.ts",
"chars": 3237,
"preview": "import useLatestRef from './useLatestRef';\nimport type { CallbackFn } from '../types';\nimport useCallbackRef from './use"
},
{
"path": "src/hooks/useMountEffect.ts",
"chars": 330,
"preview": "import { useEffect, type EffectCallback } from 'react';\n\n/**\n * Run an effect only once (on initial mount).\n *\n * @param"
},
{
"path": "src/hooks/useUpdateEffect.ts",
"chars": 591,
"preview": "import {useRef, useEffect, type EffectCallback, type DependencyList} from 'react';\n\n/**\n * `React.useEffect` that will n"
},
{
"path": "src/index.ts",
"chars": 119,
"preview": "export { default as Select } from './Select';\nexport type { Theme, SelectRef, MenuOption, MultiParams } from './types';"
},
{
"path": "src/types.ts",
"chars": 2029,
"preview": "import type { DefaultTheme } from 'styled-components';\nimport type { ReactNode, MouseEvent, TouchEvent, EventHandler } f"
},
{
"path": "src/utils/common.ts",
"chars": 2978,
"preview": "import type { SyntheticEvent } from 'react';\nimport type { SelectedOption, OptionValueCallback, OptionLabelCallback, Cal"
},
{
"path": "src/utils/device.ts",
"chars": 444,
"preview": "import { isBoolean } from './common';\n\nlet _isTouchDevice: boolean | undefined;\n\n/**\n * Determines if the current device"
},
{
"path": "src/utils/index.ts",
"chars": 75,
"preview": "export * from './menu';\nexport * from './common';\nexport * from './device';"
},
{
"path": "src/utils/menu.ts",
"chars": 4492,
"preview": "import type { CallbackFn } from '../types';\n\nconst getScrollTop = (el: HTMLElement): number => {\n return isDocumentElem"
},
{
"path": "tsconfig.json",
"chars": 462,
"preview": "{\n \"compilerOptions\": {\n \"jsx\": \"react\",\n \"target\": \"ESNext\",\n \"module\": \"ESNext\",\n \"lib\": [\"DOM\", \"DOM.Ite"
}
]
About this extraction
This page contains the full source code of the based-ghost/react-functional-select GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 90 files (196.5 KB), approximately 54.6k tokens, and a symbol index with 134 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.