├── src ├── SelectMenu │ ├── index.ts │ ├── SelectMenu.module.css │ └── SelectMenu.tsx ├── SelectInput │ ├── index.ts │ ├── SelectInputChip.module.css │ ├── SelectInputChip.tsx │ ├── SelectInput.module.css │ └── SelectInput.tsx ├── utils │ ├── index.ts │ ├── options.ts │ ├── useWidth.ts │ └── grouping.ts ├── typings.d.ts ├── icons │ ├── index.ts │ ├── DownArrowIcon.tsx │ ├── CloseIcon.tsx │ ├── LoadingIcon.tsx │ └── RefreshIcon.tsx ├── index.ts ├── SelectOption.tsx └── Select.tsx ├── .github ├── FUNDING.yml ├── workflows │ ├── build.yml │ └── release.yml ├── stale.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .eslintignore ├── .storybook ├── manager.js ├── theme.js ├── main.js ├── manager-head.html ├── preview.js └── preview-head.html ├── .prettierrc ├── postcss.config.js ├── babel.config.js ├── .editorconfig ├── .eslintrc.js ├── docs ├── Api.story.mdx ├── Intro.story.mdx ├── GettingStarted.story.mdx ├── MultiSelect.story.tsx └── SingleSelect.story.tsx ├── tsconfig.json ├── CHANGELOG.md ├── .gitignore ├── README.md ├── rollup.config.js ├── package.json └── LICENSE /src/SelectMenu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SelectMenu'; 2 | -------------------------------------------------------------------------------- /src/SelectInput/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SelectInput'; 2 | export * from './SelectInputChip'; 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: reaviz 4 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './grouping'; 2 | export * from './options'; 3 | export * from './useWidth'; 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | types/ 3 | docs/ 4 | demo/ 5 | .storybook/ 6 | coverage/ 7 | src/**/*.story.tsx 8 | src/**/*.test.ts 9 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json'; 2 | declare module '*.css'; 3 | declare module '*.md'; 4 | declare module '*.svg'; 5 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import theme from './theme'; 3 | 4 | addons.setConfig({ 5 | theme 6 | }); 7 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DownArrowIcon'; 2 | export * from './CloseIcon'; 3 | export * from './RefreshIcon'; 4 | export * from './LoadingIcon'; 5 | -------------------------------------------------------------------------------- /.storybook/theme.js: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming/create'; 2 | 3 | export default create({ 4 | base: 'light', 5 | brandTitle: 'REASELECT' 6 | }); 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # https://github.com/prettier/prettier#configuration-file 2 | semi: true 3 | singleQuote: true 4 | overrides: 5 | - files: ".prettierrc" 6 | options: 7 | parser: json 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-nested'), 4 | require('postcss-preset-env')({ stage: 1 }), 5 | require('autoprefixer') 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Select'; 2 | export * from './SelectOption'; 3 | export * from './SelectInput'; 4 | export * from './SelectMenu'; 5 | export * from './icons'; 6 | export * from './utils'; 7 | -------------------------------------------------------------------------------- /src/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { Children } from 'react'; 2 | import { SelectOption } from '../SelectOption'; 3 | 4 | export function createOptions(children) { 5 | const arr = Children.toArray(children); 6 | return arr 7 | .filter((child: any) => child.type?.name === SelectOption.name) 8 | .map((child: any) => child.props); 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/DownArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | export const DownArrowIcon: FC = () => ( 4 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | export const CloseIcon: FC = () => ( 4 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../docs/**/*.story.mdx', '../docs/**/*.story.tsx'], 3 | addons: [ 4 | 'storybook-css-modules-preset', 5 | '@storybook/addon-storysource', 6 | '@storybook/addon-docs/preset', 7 | '@storybook/addon-essentials', 8 | 'storybook-dark-mode' 9 | ], 10 | webpackFinal: async config => { 11 | config.module.rules.push({ 12 | type: 'javascript/auto', 13 | test: /\.mjs$/, 14 | include: /node_modules/ 15 | }); 16 | 17 | return config; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | REASELECT 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Use Node.js 14.x 18 | uses: actions/setup-node@v1 19 | with: 20 | version: 14.x 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Build Prod 26 | run: yarn build 27 | 28 | - name: Build Storybook 29 | run: yarn build-storybook 30 | 31 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache.forever(); 3 | 4 | const presets = [ 5 | ['@babel/preset-env', 6 | { 7 | targets: { 8 | esmodules: true 9 | } 10 | }], 11 | '@babel/preset-react', 12 | ['@babel/typescript', { isTSX: true, allExtensions: true }] 13 | ]; 14 | 15 | const plugins = [ 16 | '@babel/proposal-class-properties', 17 | '@babel/proposal-object-rest-spread', 18 | '@babel/plugin-syntax-dynamic-import', 19 | '@babel/plugin-proposal-nullish-coalescing-operator', 20 | '@babel/plugin-proposal-optional-chaining' 21 | ]; 22 | 23 | return { 24 | presets, 25 | plugins 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/icons/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | const SPEED = 0.2; 5 | 6 | export const LoadingIcon: FC = () => ( 7 | 8 | {[...Array(3)].map((_, i) => ( 9 | 24 | ))} 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/icons/RefreshIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | export const RefreshIcon: FC = () => ( 4 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://EditorConfig.org 2 | # EditorConfig Properties: https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | ### defaults 8 | [*] 9 | charset = utf-8 10 | 11 | # Unix-style newlines with 12 | end_of_line = lf 13 | 14 | # 2 space indentation 15 | indent_size = 2 16 | indent_style = space 17 | 18 | # remove any whitespace characters preceding newline characters 19 | trim_trailing_whitespace = true 20 | 21 | # newline ending every file 22 | insert_final_newline = true 23 | 24 | # Forces hard line wrapping after the amount of characters specified 25 | max_line_length = off 26 | 27 | ### custom for markdown 28 | [*.md] 29 | # do not remove any whitespace characters preceding newline characters 30 | trim_trailing_whitespace = false 31 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import theme from './theme'; 2 | 3 | const order = [ 4 | 'docs-intro-', 5 | 'docs-getting-started-', 6 | 'docs-api-', 7 | 'demos-single-select-', 8 | 'demos-multi-select-' 9 | ]; 10 | 11 | export const parameters = { 12 | layout: 'centered', 13 | darkMode: { 14 | stylePreview: true, 15 | darkClass: 'dark', 16 | lightClass: 'light' 17 | }, 18 | options: { 19 | storySort: (a, b) => { 20 | const aName = a[0]; 21 | const bName = b[0]; 22 | 23 | if (aName.includes('docs-') || bName.includes('docs-')) { 24 | const aIdx = order.findIndex(i => aName.indexOf(i) > -1); 25 | const bIdx = order.findIndex(i => bName.indexOf(i) > -1); 26 | return aIdx - bIdx; 27 | } 28 | 29 | return aName < bName ? -1 : 1; 30 | } 31 | }, 32 | docs: { 33 | theme 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended', 10 | 'plugin:react-hooks/recommended', 11 | 'prettier', 12 | 'plugin:storybook/recommended' 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | 'ecmaFeatures': { 17 | 'jsx': true 18 | }, 19 | 'ecmaVersion': 12, 20 | 'sourceType': 'module' 21 | }, 22 | plugins: [ 23 | 'react', 24 | '@typescript-eslint' 25 | ], 26 | overrides: [{ 27 | files: ['*.test.*'], 28 | env: { 29 | jest: true 30 | } 31 | }], 32 | 'rules': { 33 | 'no-unused-vars': [0], 34 | 'indent': ['error', 2], 35 | 'react/prop-types': [0], 36 | 'linebreak-style': ['error', 'unix'], 37 | 'quotes': ['error', 'single'], 38 | 'semi': ['error', 'always'] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/SelectInput/SelectInputChip.module.css: -------------------------------------------------------------------------------- 1 | .tag { 2 | margin-top: 2px; 3 | margin-bottom: 2px; 4 | border: solid 1px transparent; 5 | cursor: pointer; 6 | background: var(--color-select-chip); 7 | border: solid 1px var(--color-select-chip-border); 8 | color: var(--color-on-select-chip); 9 | display: flex; 10 | padding: 2px 4px; 11 | margin-right: 4px; 12 | font-size: 12px; 13 | border-radius: 4px; 14 | 15 | &.disabled { 16 | cursor: not-allowed; 17 | } 18 | 19 | &:focus { 20 | border: solid 1px transparent; 21 | } 22 | 23 | button { 24 | cursor: pointer; 25 | background: none; 26 | border: none; 27 | line-height: 0; 28 | padding: 0; 29 | margin-left: 4px; 30 | } 31 | 32 | &:focus { 33 | outline: none; 34 | } 35 | 36 | svg { 37 | height: 12px; 38 | width: 12px; 39 | vertical-align: baseline; 40 | pointer-events: none; 41 | fill: var(--color-select-chip-icon); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | if: github.repository == 'reaviz/reaselct' 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Use Node.js 14.x 19 | uses: actions/setup-node@v1 20 | with: 21 | version: 14.x 22 | 23 | - name: Install deps and build (with cache) 24 | uses: bahmutov/npm-install@v1 25 | 26 | - name: Build Storybook 27 | run: yarn build-storybook 28 | 29 | - name: Publish Storybook to GH Pages 30 | if: success() 31 | uses: crazy-max/ghaction-github-pages@v2 32 | with: 33 | target_branch: gh-pages 34 | build_dir: storybook-static 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Publish Chromatic 39 | run: yarn chromatic 40 | -------------------------------------------------------------------------------- /docs/Api.story.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, ArgsTable } from '@storybook/addon-docs/blocks'; 2 | import { Select } from '../src/Select'; 3 | import { SelectOption } from '../src/SelectOption'; 4 | import { SelectMenu } from '../src/SelectMenu'; 5 | import { SelectInput, SelectInputChip } from '../src/SelectInput'; 6 | 7 | 8 | 9 | # API 10 | 11 | ## [Select](https://github.com/reaviz/reaselct/blob/master/src/Select.tsx) 12 | 13 | 14 | ## [SelectOption](https://github.com/reaviz/reaselct/blob/master/src/SelectOption.tsx) 15 | 16 | 17 | ## [SelectMenu](https://github.com/reaviz/reaselct/blob/master/src/SelectMenu/SelectMenu.tsx) 18 | 19 | 20 | ## [SelectInput](https://github.com/reaviz/reaselct/blob/master/src/SelectInput/SelectInput.tsx) 21 | 22 | 23 | ## [SelectInputChip](https://github.com/reaviz/reaselct/blob/master/src/SelectInput/SelectInputChip.tsx) 24 | 25 | -------------------------------------------------------------------------------- /src/SelectOption.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | 3 | export type SelectValue = SelectOptionProps | SelectOptionProps[] | null; 4 | 5 | export interface SelectOptionProps { 6 | /** 7 | * Value of the option. Usually a string value. 8 | */ 9 | value: any; 10 | 11 | /** 12 | * Default label of the option. 13 | */ 14 | children?: ReactNode | string; 15 | 16 | /** 17 | * Custom input label. 18 | */ 19 | inputLabel?: ReactNode | string; 20 | 21 | /** 22 | * Optional group for the option. 23 | */ 24 | group?: string; 25 | 26 | /** 27 | * Optional menu label. 28 | */ 29 | menuLabel?: ReactNode | string; 30 | 31 | /** 32 | * Optional input prefix. 33 | */ 34 | inputPrefix?: ReactNode | string; 35 | 36 | /** 37 | * Whether the option is selected. 38 | */ 39 | selected?: boolean; 40 | 41 | /** 42 | * Whether the option is disabled. 43 | */ 44 | disabled?: boolean; 45 | } 46 | 47 | export const SelectOption: FC = ({ children }) => 48 | children as any; 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ESNext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "declaration": true, 10 | "declarationDir": "./dist", 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "resolveJsonModule": true, 15 | "rootDirs": ["src", "docs"], 16 | "noImplicitAny": false, 17 | "noImplicitThis": false, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "allowJs": true, 21 | "pretty": true, 22 | "outDir": "./dist", 23 | "skipLibCheck": true, 24 | "sourceMap": true, 25 | "suppressImplicitAnyIndexErrors": true, 26 | "suppressExcessPropertyErrors": true, 27 | "experimentalDecorators": true, 28 | "emitDecoratorMetadata": true 29 | }, 30 | "types": ["node"], 31 | "include": ["src/**/*", "demo/**/*"], 32 | "exclude": ["node_modules", "dist"] 33 | } 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.1.0 - 11/21/22 2 | - [chore] upgrade rdk 3 | - [chore] upgrade framer-motion 4 | 5 | # 2.0.10 - 10/9/22 6 | - [fix] fix issue with creatable 7 | 8 | # 2.0.9 - 9/13/22 9 | - [fix] update rdk 10 | 11 | # 2.0.8 - 9/13/22 12 | - [fix] update rdk 13 | - [fix] cancel click event defaults 14 | 15 | # 2.0.7 - 7/26/22 16 | - [fix] Fix for click event bubbling and causing rdk to close things 17 | 18 | # 2.0.6 - 7/26/22 19 | - [fix] Fix for a createable value when Enter is pressed #6 20 | 21 | # 2.0.5 - 7/18/22 22 | - [fix] Be able to select option with Return key #5 23 | 24 | # 2.0.4 - 7/15/22 25 | - [fix] make select clear honor close prop 26 | 27 | # 2.0.2/3 - 5/10/22 28 | - [fix] fix children missing in props 29 | 30 | # 2.0.1 - 4/6/22 31 | - [chore] bump rdk 32 | - [chore] fix framer-motion imports 33 | 34 | # 2.0.0 - 4/4/22 35 | - [chore] upgrade react 36 | - [chore] upgrade framer-motion 37 | 38 | # 1.0.10 - 3/30/22 39 | - [feature] add global classnames to elements 40 | 41 | # 1.0.9 - 1/31/22 42 | - [fix] Adjusted the framer motion animation to make it appear less stuttery #2 43 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] Tests for the changes have been added (for bug fixes / features) 5 | - [ ] Docs have been added / updated (for bug fixes / features) 6 | 7 | ## PR Type 8 | What kind of change does this PR introduce? 9 | 10 | 11 | ``` 12 | [ ] Bugfix 13 | [ ] Feature 14 | [ ] Code style update (formatting, local variables) 15 | [ ] Refactoring (no functional changes, no api changes) 16 | [ ] Build related changes 17 | [ ] CI related changes 18 | [ ] Documentation content changes 19 | [ ] Other... Please describe: 20 | ``` 21 | 22 | ## What is the current behavior? 23 | 24 | 25 | Issue Number: N/A 26 | 27 | 28 | ## What is the new behavior? 29 | 30 | 31 | ## Does this PR introduce a breaking change? 32 | ``` 33 | [ ] Yes 34 | [ ] No 35 | ``` 36 | 37 | 38 | 39 | 40 | ## Other information 41 | -------------------------------------------------------------------------------- /src/utils/useWidth.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useState } from 'react'; 2 | import { ConnectedOverlayContentRef } from 'rdk'; 3 | 4 | export const useWidth = ( 5 | ref: RefObject, 6 | overlayRef: RefObject 7 | ) => { 8 | const [menuWidth, setMenuWidth] = useState(0); 9 | 10 | const updateWidthInternal = useCallback(() => { 11 | if (ref?.current) { 12 | const { width } = ref.current.getBoundingClientRect(); 13 | if (width !== menuWidth) { 14 | setMenuWidth(width); 15 | return true; 16 | } 17 | } 18 | }, [ref, menuWidth]); 19 | 20 | useEffect(() => { 21 | updateWidthInternal(); 22 | }, [updateWidthInternal]); 23 | 24 | useEffect(() => { 25 | window.addEventListener('resize', updateWidthInternal); 26 | return () => window.removeEventListener('resize', updateWidthInternal); 27 | }, [updateWidthInternal]); 28 | 29 | const updateWidth = useCallback(() => { 30 | if (updateWidthInternal()) { 31 | // trigger event so position is updated 32 | overlayRef.current?.updatePosition(); 33 | } 34 | }, [updateWidthInternal, overlayRef]); 35 | 36 | return [menuWidth, updateWidth] as [number, () => void]; 37 | }; 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pkg/ 8 | .rpt2_cache/ 9 | dist/ 10 | storybook-static/ 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | .DS_Store 67 | src/**/*.scss.d.ts 68 | src/**/*.css.d.ts 69 | -------------------------------------------------------------------------------- /docs/Intro.story.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 |
6 |

reaselct

7 | 8 | 9 | 10 |    11 | 12 | 13 |    14 | 15 | 16 |    17 | 18 | 19 |    20 | 21 | 22 |    23 | 24 | GitHub stars 25 |    26 | 27 | 28 | 29 | 30 | --- 31 | 32 | reaselct is a Select Component for React. 33 |
34 | -------------------------------------------------------------------------------- /src/SelectMenu/SelectMenu.module.css: -------------------------------------------------------------------------------- 1 | .menu { 2 | background: var(--color-select-menu); 3 | text-align: center; 4 | will-change: transform, opacity; 5 | border-radius: 0 0 var(--select-border-radius) var(--select-border-radius); 6 | min-width: 112px; 7 | max-height: 300px; 8 | overflow-y: auto; 9 | text-align: left; 10 | border: solid 1px var(--color-select-menu-border); 11 | border-top: none; 12 | 13 | ul, 14 | li { 15 | list-style: none; 16 | } 17 | 18 | ul { 19 | padding: 0; 20 | margin: 0; 21 | } 22 | 23 | li { 24 | padding: 7px 10px; 25 | 26 | &:not(.disabled) { 27 | color: pointer; 28 | } 29 | } 30 | 31 | .groupItem { 32 | border: none; 33 | padding: 0; 34 | 35 | h3 { 36 | font-size: 12px; 37 | margin: 0; 38 | font-weight: bold; 39 | text-transform: uppercase; 40 | padding: 10px 0 5px 10px; 41 | color: var(--color-select-menu-group); 42 | } 43 | } 44 | 45 | .option { 46 | border: none; 47 | color: var(--color-on-select-menu-item); 48 | font-size: 14px; 49 | cursor: pointer; 50 | 51 | &:hover, 52 | &.active { 53 | color: var(--color-on-select-menu-item-active); 54 | background: var(--color-select-menu-item-active); 55 | } 56 | 57 | &.selected { 58 | color: var(--color-on-select-menu-item-selected); 59 | background: var(--color-select-menu-item-selected); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

reaselct

3 | Select Component for React 4 |

5 | 6 | 7 | 8 |   9 | 10 | 11 |   12 | 13 | 14 |   15 | 16 | 17 |   18 | 19 | 20 |   21 | 22 | GitHub stars 23 |   24 | 25 | 26 | 27 |
28 | 29 | --- 30 | 31 | ## Deprecated 32 | This package is deprecated and part of [https://reablocks.dev](https://reablocks.dev) now. 33 | 34 | ## ❤️ Contributors 35 | Thanks to all our contributors! 36 | 37 | 38 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import sourceMaps from 'rollup-plugin-sourcemaps'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import external from 'rollup-plugin-peer-deps-external'; 5 | import postcss from 'rollup-plugin-postcss-modules'; 6 | import commonjs from 'rollup-plugin-commonjs'; 7 | import pkg from './package.json'; 8 | 9 | export default [ 10 | { 11 | input: pkg.source, 12 | output: [ 13 | { 14 | file: pkg.browser, 15 | format: 'umd', 16 | name: 'reaselect' 17 | }, 18 | { 19 | file: pkg.main, 20 | format: 'cjs', 21 | name: 'reaselect' 22 | }, 23 | { 24 | file: pkg.module, 25 | format: 'esm' 26 | } 27 | ], 28 | plugins: [ 29 | external({ 30 | includeDependencies: true 31 | }), 32 | postcss({ 33 | // extract: true, 34 | modules: true, 35 | // writeDefinitions: true, 36 | plugins: [ 37 | require('postcss-nested'), 38 | require('postcss-preset-env')({ stage: 1 }), 39 | require('autoprefixer') 40 | ] 41 | }), 42 | typescript({ 43 | clean: true, 44 | exclude: [ 45 | '*.scss', 46 | '*.css', 47 | '*.test.js', 48 | '*.test.ts', 49 | '*.test.tsx', 50 | '*.d.ts', 51 | '**/*.d.ts', 52 | '**/*.story.tsx' 53 | ] 54 | }), 55 | resolve(), 56 | commonjs(), 57 | sourceMaps() 58 | ] 59 | } 60 | ]; 61 | -------------------------------------------------------------------------------- /src/utils/grouping.ts: -------------------------------------------------------------------------------- 1 | import { SelectOptionProps } from '../SelectOption'; 2 | import groupBy from 'lodash/groupBy'; 3 | import sumBy from 'lodash/sumBy'; 4 | 5 | export interface GroupOptions { 6 | groups: GroupOption[]; 7 | itemsCount: number; 8 | hasGroups: boolean; 9 | } 10 | 11 | export interface GroupOption { 12 | offset: number; 13 | index: number; 14 | items: SelectOptionProps[]; 15 | name: string; 16 | } 17 | 18 | export function getGroups(options: SelectOptionProps[]): GroupOptions { 19 | if (!options?.length) { 20 | return { 21 | groups: [], 22 | itemsCount: 0, 23 | hasGroups: false 24 | }; 25 | } 26 | 27 | const groupsMap = groupBy(options, t => t.group); 28 | const groupNames = Object.keys(groupsMap); 29 | 30 | if (groupNames.length === 1 && groupNames[0] === 'undefined') { 31 | return { 32 | groups: [], 33 | itemsCount: options.length, 34 | hasGroups: false 35 | }; 36 | } 37 | 38 | let index = 0; 39 | const groups = Object.entries(groupsMap).map(([key, value], index) => ({ 40 | offset: 0, 41 | index, 42 | items: value, 43 | name: key 44 | })); 45 | 46 | for (const group of groups) { 47 | group.offset = index; 48 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 49 | for (const _item of group.items) { 50 | index++; 51 | } 52 | } 53 | 54 | return { 55 | groups, 56 | itemsCount: 57 | groups?.length !== 0 58 | ? sumBy(groups, g => g.items.length) 59 | : options.length, 60 | hasGroups: groups?.length !== 0 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/SelectInput/SelectInputChip.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { SelectOptionProps } from '../SelectOption'; 3 | import ellipsize from 'ellipsize'; 4 | import { CloseIcon } from '../icons/CloseIcon'; 5 | import css from './SelectInputChip.module.css'; 6 | import classNames from 'classnames'; 7 | 8 | export interface SelectInputChipProps { 9 | option: SelectOptionProps; 10 | maxLength?: number; 11 | className?: string; 12 | disabled?: boolean; 13 | clearable?: boolean; 14 | closeIcon?: React.ReactNode; 15 | onTagKeyDown: ( 16 | event: React.KeyboardEvent, 17 | option: SelectOptionProps 18 | ) => void; 19 | onSelectedChange: (option: SelectOptionProps) => void; 20 | } 21 | 22 | export const SelectInputChip: FC> = ({ 23 | option, 24 | disabled, 25 | clearable, 26 | className, 27 | maxLength, 28 | closeIcon, 29 | onTagKeyDown, 30 | onSelectedChange 31 | }) => { 32 | const origLabel = option.inputLabel || option.children; 33 | const label = 34 | typeof origLabel === 'string' ? ellipsize(origLabel, maxLength) : origLabel; 35 | 36 | return ( 37 | onTagKeyDown(event, option)} 42 | > 43 | {label} 44 | {!disabled && clearable && ( 45 | 48 | )} 49 | 50 | ); 51 | }; 52 | 53 | SelectInputChip.defaultProps = { 54 | closeIcon: , 55 | maxLength: 20 56 | }; 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 9 |

10 | [ ] Regression (a behavior that used to work and stopped working in a new release)
11 | [ ] Bug report  
12 | [ ] Performance issue
13 | [ ] Feature request
14 | [ ] Documentation issue or request
15 | [ ] Other... Please describe:
16 | 
17 | 18 | ## Current behavior 19 | 20 | 21 | 22 | ## Expected behavior 23 | 24 | 25 | 26 | ## Minimal reproduction of the problem with instructions 27 | 28 | 37 | 38 | ## What is the motivation / use case for changing the behavior? 39 | 40 | 41 | 42 | ## Environment 43 | 44 |

45 | Libs:
46 | - react version: X.Y.Z
47 | - realayers version: X.Y.Z
48 | 
49 | 
50 | Browser:
51 | - [ ] Chrome (desktop) version XX
52 | - [ ] Chrome (Android) version XX
53 | - [ ] Chrome (iOS) version XX
54 | - [ ] Firefox version XX
55 | - [ ] Safari (desktop) version XX
56 | - [ ] Safari (iOS) version XX
57 | - [ ] IE version XX
58 | - [ ] Edge version XX
59 |  
60 | For Tooling issues:
61 | - Node version: XX  
62 | - Platform:  
63 | 
64 | Others:
65 | 
66 | 
67 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | REASELECT 2 | 73 | -------------------------------------------------------------------------------- /docs/GettingStarted.story.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Getting Started 6 | 7 | ## Installing 8 | 9 | You can install reaselct with [NPM](https://www.npmjs.com/package/reaselct) or Yarn. 10 | 11 | - NPM: `npm install reaselct --save` 12 | - YARN: `yarn add reaselct` 13 | 14 | ## Configuration 15 | reaselct uses [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables) 16 | to configure the colors and styles of the library. Below are the variables 17 | that we use. 18 | 19 | You should include these variables in your CSS file or 20 | on a container element such as the body like: 21 | 22 | ```html 23 | 52 | ``` 53 | 54 | You can make the select use a dark theme by changing the variables to: 55 | 56 | ```html 57 | 86 | ``` 87 | -------------------------------------------------------------------------------- /src/SelectInput/SelectInput.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-wrap: nowrap; 4 | background: var(--color-select-input); 5 | border-radius: var(--select-border-radius); 6 | border: solid 1px var(--color-select-input-border); 7 | align-items: center; 8 | padding: 0 12px; 9 | min-height: 38px; 10 | 11 | &.open { 12 | border-radius: var(--select-border-radius) var(--select-border-radius) 0 0; 13 | } 14 | 15 | &:not(.disabled) { 16 | cursor: text; 17 | } 18 | 19 | &.disabled { 20 | .expand, 21 | .input { 22 | cursor: not-allowed; 23 | } 24 | } 25 | 26 | &.unfilterable { 27 | .input { 28 | caret-color: transparent; 29 | } 30 | } 31 | 32 | &.error { 33 | border: 1px solid var(--color-select-input-error); 34 | } 35 | 36 | .inputContainer { 37 | display: flex; 38 | flex: 1; 39 | align-items: center; 40 | overflow: hidden; 41 | } 42 | 43 | .input { 44 | padding: 0; 45 | background: transparent; 46 | border: none; 47 | font-size: 13px; 48 | color: var(--color-on-select-input); 49 | font-family: inherit; 50 | vertical-align: middle; 51 | 52 | &[disabled] { 53 | color: var(--color-select-input-disabled); 54 | } 55 | 56 | &::placeholder { 57 | color: var(--color-select-input-placeholder); 58 | } 59 | 60 | &:focus { 61 | outline: none; 62 | } 63 | 64 | &:read-only { 65 | cursor: not-allowed; 66 | } 67 | } 68 | 69 | &.single { 70 | .prefix { 71 | padding: 5px 0; 72 | overflow: hidden; 73 | white-space: nowrap; 74 | text-overflow: ellipsis; 75 | max-width: 100%; 76 | } 77 | 78 | .inputContainer { 79 | flex-wrap: nowrap; 80 | 81 | > div, 82 | .input { 83 | max-width: 100%; 84 | } 85 | } 86 | 87 | .input { 88 | width: 100%; 89 | text-overflow: ellipsis; 90 | } 91 | } 92 | 93 | &.multiple { 94 | .prefix { 95 | display: contents; 96 | } 97 | 98 | .inputContainer { 99 | flex-wrap: wrap; 100 | } 101 | } 102 | 103 | .prefix { 104 | align-items: center; 105 | } 106 | 107 | .suffix { 108 | display: flex; 109 | margin-left: auto; 110 | 111 | svg { 112 | height: 20px; 113 | width: 20px; 114 | vertical-align: middle; 115 | } 116 | 117 | .loader { 118 | display: flex; 119 | align-items: center; 120 | margin-right: 10px; 121 | 122 | div { 123 | margin-left: 10px; 124 | border-radius: 50%; 125 | height: 2px; 126 | width: 2px; 127 | background: var(--color-select-input-icon); 128 | } 129 | } 130 | 131 | .btn { 132 | padding: 0; 133 | border: none; 134 | background: none; 135 | 136 | &:not([disabled]) { 137 | cursor: pointer; 138 | } 139 | 140 | svg { 141 | vertical-align: middle; 142 | fill: var(--color-select-input-icon); 143 | } 144 | 145 | &.expand { 146 | svg { 147 | height: 18px; 148 | width: 18px; 149 | } 150 | } 151 | 152 | &.refresh, 153 | &.close { 154 | margin-right: 5px; 155 | 156 | svg { 157 | height: 16px; 158 | width: 16px; 159 | } 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaselct", 3 | "version": "2.1.0", 4 | "description": "Select Component for React", 5 | "scripts": { 6 | "build": "rollup -c", 7 | "prettier": "prettier --loglevel warn --write 'src/**/*.{ts,tsx,js,jsx}'", 8 | "start": "start-storybook -p 9009", 9 | "build-storybook": "build-storybook", 10 | "lint": "eslint --ext js,ts,tsx", 11 | "lint:fix": "eslint --ext js,ts,tsx --fix src", 12 | "lint:prettier": "prettier --loglevel warn --write 'src/**/*.{ts,tsx,js,jsx}'", 13 | "chromatic": "chromatic --project-token=3eedbd9f2b67 --exit-zero-on-changes" 14 | }, 15 | "source": "src/index.ts", 16 | "main": "dist/index.cjs.js", 17 | "module": "dist/index.esm.js", 18 | "browser": "dist/index.js", 19 | "style": "dist/index.css", 20 | "typings": "dist/index.d.ts", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/reaviz/reaselct.git" 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "keywords": [ 29 | "react", 30 | "reactjs", 31 | "select" 32 | ], 33 | "license": "Apache-2.0", 34 | "bugs": { 35 | "url": "https://github.com/reaviz/reaselct/issues" 36 | }, 37 | "homepage": "https://github.com/reaviz/reaselct#readme", 38 | "dependencies": { 39 | "classnames": "^2.3.1", 40 | "ellipsize": "^0.2.0", 41 | "framer-motion": "^7.6.7", 42 | "lodash": "^4.17.21", 43 | "rdk": "^6.1.0", 44 | "react-fast-compare": "^3.2.0", 45 | "react-highlight-words": "^0.17.0", 46 | "react-input-autosize": "^3.0.0", 47 | "react-use-fuzzy": "^1.0.4" 48 | }, 49 | "peerDependencies": { 50 | "react": ">=16", 51 | "react-dom": ">=16" 52 | }, 53 | "devDependencies": { 54 | "@storybook/addon-essentials": "6.4.20", 55 | "@storybook/addon-storysource": "^6.4.20", 56 | "@storybook/addons": "6.4.20", 57 | "@storybook/react": "6.4.20", 58 | "@storybook/theming": "6.4.20", 59 | "@types/classnames": "^2.3.1", 60 | "@types/lodash": "^4.14.178", 61 | "@types/react": "^17.0.43", 62 | "@types/react-dom": "^17.0.14", 63 | "@typescript-eslint/eslint-plugin": "^4.32.0", 64 | "@typescript-eslint/parser": "^4.32.0", 65 | "autoprefixer": "^9", 66 | "chromatic": "^6.5.3", 67 | "eslint": "^7.32.0", 68 | "eslint-config-prettier": "^8.3.0", 69 | "eslint-plugin-react": "^7.28.0", 70 | "eslint-plugin-react-hooks": "^4.3.0", 71 | "eslint-plugin-storybook": "^0.5.7", 72 | "framer-motion": "^4.1.17", 73 | "husky": "^4.2.5", 74 | "lint-staged": "^10.5.4", 75 | "postcss-nested": "^4", 76 | "postcss-preset-env": "^6.7.0", 77 | "prettier": "^2.6.2", 78 | "react": "^18.0.0", 79 | "react-dom": "^18.0.0", 80 | "rollup": "^2.29.0", 81 | "rollup-plugin-commonjs": "10.1.0", 82 | "rollup-plugin-node-resolve": "5.2.0", 83 | "rollup-plugin-peer-deps-external": "2.2.3", 84 | "rollup-plugin-postcss": "3.1.3", 85 | "rollup-plugin-postcss-modules": "2.0.1", 86 | "rollup-plugin-sourcemaps": "0.6.3", 87 | "rollup-plugin-typescript2": "0.26.0", 88 | "storybook-css-modules-preset": "^1.1.1", 89 | "storybook-dark-mode": "^1.0.9", 90 | "typescript": "^4.1.3" 91 | }, 92 | "prettier": { 93 | "semi": true, 94 | "singleQuote": true, 95 | "trailingComma": "none", 96 | "arrowParens": "avoid", 97 | "bracketSpacing": true, 98 | "jsxBracketSameLine": false, 99 | "printWidth": 80 100 | }, 101 | "lint-staged": { 102 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 103 | "prettier --write", 104 | "git add" 105 | ] 106 | }, 107 | "husky": { 108 | "hooks": { 109 | "pre-commit": "lint-staged" 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/SelectMenu/SelectMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment, useCallback } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import classNames from 'classnames'; 4 | import { SelectOptionProps, SelectValue } from '../SelectOption'; 5 | import Highlighter from 'react-highlight-words'; 6 | import { GroupOptions, GroupOption } from '../utils'; 7 | import css from './SelectMenu.module.css'; 8 | 9 | export interface SelectMenuProps { 10 | id?: string; 11 | options: SelectOptionProps[]; 12 | selectedOption?: SelectOptionProps | SelectOptionProps[]; 13 | style?: React.CSSProperties; 14 | disabled?: boolean; 15 | groups?: GroupOptions; 16 | createable?: boolean; 17 | className?: string; 18 | multiple?: boolean; 19 | index: number; 20 | inputSearchText: string; 21 | filterable?: boolean; 22 | loading?: boolean; 23 | onSelectedChange: (option: SelectValue) => void; 24 | } 25 | 26 | export const SelectMenu: FC> = ({ 27 | style, 28 | disabled, 29 | createable, 30 | selectedOption, 31 | options, 32 | loading, 33 | className, 34 | index, 35 | filterable, 36 | groups, 37 | multiple, 38 | inputSearchText, 39 | onSelectedChange 40 | }) => { 41 | const trimmedText = inputSearchText.trim(); 42 | 43 | const checkOptionSelected = useCallback( 44 | (option: SelectOptionProps) => { 45 | if (multiple) { 46 | if (Array.isArray(selectedOption)) { 47 | return selectedOption.find(o => o.value === option.value); 48 | } 49 | 50 | return false; 51 | } 52 | 53 | return (selectedOption as SelectOptionProps)?.value === option.value; 54 | }, 55 | [selectedOption, multiple] 56 | ); 57 | 58 | const renderListItems = useCallback( 59 | (items: SelectOptionProps[], group?: GroupOption) => 60 | items.map((o, i) => ( 61 |
  • { 69 | event.preventDefault(); 70 | event.stopPropagation(); 71 | onSelectedChange(o); 72 | }} 73 | > 74 | {o.menuLabel ? ( 75 | o.menuLabel 76 | ) : ( 77 | 82 | )} 83 |
  • 84 | )), 85 | [checkOptionSelected, disabled, index, inputSearchText, onSelectedChange] 86 | ); 87 | 88 | return ( 89 | 112 |
      113 | {options?.length === 0 && createable && trimmedText && !loading && ( 114 |
    • { 117 | event.preventDefault(); 118 | event.stopPropagation(); 119 | onSelectedChange({ 120 | value: trimmedText.toLowerCase(), 121 | children: trimmedText.toLowerCase() 122 | }); 123 | }} 124 | > 125 | Create option "{trimmedText.toLowerCase()}" 126 |
    • 127 | )} 128 | {options?.length === 0 && 129 | !createable && 130 | filterable && 131 | trimmedText && 132 | !loading && ( 133 |
    • 134 | No option(s) for "{trimmedText}" 135 |
    • 136 | )} 137 | {options?.length === 0 && 138 | !createable && 139 | filterable && 140 | !trimmedText && 141 | !loading && ( 142 |
    • No option(s) available
    • 143 | )} 144 | {groups.hasGroups 145 | ? groups.groups.map(g => ( 146 | 147 | {g.name === 'undefined' ? ( 148 | renderListItems(g.items, g) 149 | ) : ( 150 |
    • 153 |

      {g.name}

      154 |
        {renderListItems(g.items, g)}
      155 |
    • 156 | )} 157 |
      158 | )) 159 | : renderListItems(options)} 160 |
    161 |
    162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /docs/MultiSelect.story.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from 'react'; 2 | import { Select } from '../src/Select'; 3 | import { SelectOption } from '../src/SelectOption'; 4 | import { SelectMenu } from '../src/SelectMenu'; 5 | import { SelectInput, SelectInputChip } from '../src/SelectInput'; 6 | 7 | export default { 8 | title: 'Demos/Multi Select', 9 | component: Select, 10 | subcomponents: { 11 | SelectOption, 12 | SelectMenu, 13 | SelectInput, 14 | SelectInputChip 15 | } 16 | }; 17 | 18 | const options = [ 19 | { value: 'facebook', label: 'Facebook' }, 20 | { value: 'twitter', label: 'Twitter' }, 21 | { value: 'github', label: 'GitHub' }, 22 | { value: 'google', label: 'Google' }, 23 | { value: 'azure', label: 'Azure' }, 24 | ]; 25 | 26 | export const Basic = () => { 27 | const [value, setValue] = useState(null); 28 | return ( 29 |
    30 | 44 |
    45 | ); 46 | }; 47 | 48 | export const Disabled = () => { 49 | const [value, setValue] = useState(['facebook']); 50 | return ( 51 |
    52 | 66 |
    67 | ); 68 | }; 69 | 70 | export const DefaultValue = () => { 71 | const [value, setValue] = useState(['facebook', 'twitter']); 72 | return ( 73 |
    74 | 88 |
    89 | ); 90 | }; 91 | 92 | export const CustomLabels = () => { 93 | const [value, setValue] = useState(['facebook']); 94 | return ( 95 |
    96 | 124 |
    125 | ); 126 | }; 127 | 128 | export const Createable = () => { 129 | const [value, setValue] = useState([]); 130 | const [animals, setAnimals] = useState(['chicken', 'cow', 'mouse']); 131 | return ( 132 |
    133 | 148 |
    149 | ); 150 | }; 151 | 152 | export const LongInputNames = () => { 153 | const [value, setValue] = useState(['dod']); 154 | return ( 155 |
    156 | 172 |
    173 | ); 174 | }; 175 | 176 | export const MultipleValuesOverflow = () => { 177 | const [value, setValue] = useState(['dod', 'dhs', 'soc']); 178 | return ( 179 |
    180 | 196 |
    197 | ); 198 | }; 199 | 200 | export const MultipleValuesFixed = () => { 201 | const [value, setValue] = useState(['dod', 'dhs', 'soc']); 202 | return ( 203 |
    204 | 220 |
    221 | ); 222 | }; 223 | 224 | export const FluidWidth = () => { 225 | const [value, setValue] = useState(['dod']); 226 | return ( 227 |
    228 | 244 |
    245 | ); 246 | }; 247 | 248 | export const Unfilterable = () => { 249 | const [value, setValue] = useState(['dod']); 250 | return ( 251 |
    252 | 263 |
    264 | ); 265 | }; 266 | 267 | export const Error = () => ( 268 |
    269 | setValue(v)} 282 | placeholder="Pick a tool..." 283 | > 284 | facebook 285 | twitter 286 | twitch 287 | 288 |
    289 | ); 290 | }; 291 | 292 | export const CreateableNoOptions = () => { 293 | const [value, setValue] = useState([]); 294 | const [animals, setAnimals] = useState([]); 295 | return ( 296 |
    297 | 315 |
    316 | ); 317 | }; 318 | 319 | export const Async = () => { 320 | const [value, setValue] = useState('github'); 321 | const [loading, setLoading] = useState(false); 322 | const [refreshable, setRefreshable] = useState(false); 323 | const [opts, setOpts] = useState<{ value: string; label: string }[] | null>( 324 | null 325 | ); 326 | 327 | useEffect(() => { 328 | let timeout; 329 | 330 | async function getOptions() { 331 | const next = await new Promise((resolve) => { 332 | timeout = setTimeout(() => { 333 | resolve(options); 334 | }, 1500); 335 | }); 336 | 337 | setOpts(next); 338 | setLoading(false); 339 | setRefreshable(true); 340 | } 341 | 342 | if (opts === null) { 343 | setLoading(true); 344 | setRefreshable(false); 345 | getOptions(); 346 | } 347 | 348 | return () => { 349 | clearTimeout(timeout); 350 | }; 351 | }, [opts]); 352 | 353 | return ( 354 |
    355 | 370 |
    371 | ); 372 | }; 373 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/SelectInput/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | ReactElement, 4 | Ref, 5 | RefObject, 6 | useCallback, 7 | useImperativeHandle, 8 | useMemo, 9 | useRef 10 | } from 'react'; 11 | import classNames from 'classnames'; 12 | import { SelectOptionProps, SelectValue } from '../SelectOption'; 13 | import AutosizeInput from 'react-input-autosize'; 14 | import { DownArrowIcon } from '../icons/DownArrowIcon'; 15 | import { CloseIcon } from '../icons/CloseIcon'; 16 | import { LoadingIcon } from '../icons/LoadingIcon'; 17 | import { RefreshIcon } from '../icons/RefreshIcon'; 18 | import { SelectInputChip, SelectInputChipProps } from './SelectInputChip'; 19 | import { CloneElement } from 'rdk'; 20 | import css from './SelectInput.module.css'; 21 | 22 | export interface SelectInputProps { 23 | id?: string; 24 | name?: string; 25 | required?: boolean; 26 | options: SelectOptionProps[]; 27 | disabled?: boolean; 28 | menuOpen?: boolean; 29 | fontSize?: string | number; 30 | inputText: string; 31 | closeOnSelect?: boolean; 32 | selectedOption?: SelectOptionProps | SelectOptionProps[]; 33 | autoFocus?: boolean; 34 | className?: string; 35 | createable?: boolean; 36 | filterable?: boolean; 37 | multiple?: boolean; 38 | loading?: boolean; 39 | reference?: Ref; 40 | placeholder?: string; 41 | error?: boolean; 42 | clearable?: boolean; 43 | refreshable?: boolean; 44 | menuDisabled?: boolean; 45 | 46 | closeIcon?: React.ReactNode; 47 | refreshIcon?: React.ReactNode; 48 | expandIcon?: React.ReactNode; 49 | loadingIcon?: React.ReactNode; 50 | 51 | chip?: ReactElement; 52 | 53 | onSelectedChange: (option: SelectValue) => void; 54 | onExpandClick: (event: React.MouseEvent) => void; 55 | onKeyDown: (event: React.KeyboardEvent) => void; 56 | onKeyUp: (event: React.KeyboardEvent) => void; 57 | onFocus: ( 58 | event: React.FocusEvent | React.MouseEvent 59 | ) => void; 60 | onBlur: (event: React.FocusEvent) => void; 61 | onInputChange: (event: React.ChangeEvent) => void; 62 | onRefresh?: () => void; 63 | } 64 | 65 | export interface SelectInputRef { 66 | inputRef: RefObject; 67 | containerRef: RefObject; 68 | focus: () => void; 69 | } 70 | 71 | const horiztonalArrowKeys = ['ArrowLeft', 'ArrowRight']; 72 | const verticalArrowKeys = ['ArrowUp', 'ArrowDown']; 73 | const actionKeys = [...verticalArrowKeys, 'Enter', 'Tab', 'Escape']; 74 | 75 | export const SelectInput: FC> = ({ 76 | reference, 77 | autoFocus, 78 | selectedOption, 79 | disabled, 80 | placeholder, 81 | filterable, 82 | fontSize, 83 | id, 84 | name, 85 | className, 86 | inputText, 87 | required, 88 | loading, 89 | clearable, 90 | multiple, 91 | refreshable, 92 | error, 93 | menuDisabled, 94 | menuOpen, 95 | refreshIcon, 96 | closeIcon, 97 | expandIcon, 98 | loadingIcon, 99 | closeOnSelect, 100 | onSelectedChange, 101 | onKeyDown, 102 | onKeyUp, 103 | onExpandClick, 104 | onInputChange, 105 | onFocus, 106 | onBlur, 107 | onRefresh, 108 | chip 109 | }) => { 110 | const containerRef = useRef(null); 111 | const inputRef = useRef(null); 112 | 113 | const hasValue = 114 | (multiple && (selectedOption as SelectOptionProps[])?.length > 0) || 115 | (!multiple && selectedOption); 116 | 117 | const placeholderText = hasValue ? '' : placeholder; 118 | const showClear = clearable && !disabled && hasValue; 119 | 120 | useImperativeHandle(reference, () => ({ 121 | containerRef, 122 | inputRef, 123 | focus: () => focusInput() 124 | })); 125 | 126 | const inputTextValue = useMemo(() => { 127 | if (!inputText && hasValue) { 128 | if (!Array.isArray(selectedOption)) { 129 | const singleOption = selectedOption as SelectOptionProps; 130 | if (!singleOption.inputLabel) { 131 | return singleOption.children as string; 132 | } 133 | } 134 | return ''; 135 | } 136 | 137 | return inputText; 138 | }, [hasValue, inputText, selectedOption]); 139 | 140 | const onClearValues = useCallback( 141 | (event: React.MouseEvent) => { 142 | // Stop propogation to prevent closing the menu 143 | if (closeOnSelect) { 144 | event.stopPropagation(); 145 | } 146 | onSelectedChange(null); 147 | }, 148 | [onSelectedChange, closeOnSelect] 149 | ); 150 | 151 | const focusInput = useCallback(() => { 152 | const input = inputRef.current; 153 | if (input) { 154 | if (input.value) { 155 | const len = input.value.length; 156 | // Handle dom settle 157 | setTimeout(() => input.setSelectionRange(len, len)); 158 | input.focus(); 159 | } else { 160 | input.focus(); 161 | } 162 | } 163 | }, []); 164 | 165 | const onInputFocus = useCallback( 166 | ( 167 | event: 168 | | React.FocusEvent 169 | | React.MouseEvent 170 | ) => { 171 | // On initial focus, move focus to the last character of the value 172 | if (!multiple && filterable && selectedOption) { 173 | // We are handling the selection ourself 174 | event.preventDefault(); 175 | 176 | // Stop parent container click event from double firing 177 | event.stopPropagation(); 178 | 179 | focusInput(); 180 | } 181 | 182 | onFocus?.(event); 183 | }, 184 | [filterable, focusInput, multiple, onFocus, selectedOption] 185 | ); 186 | 187 | const onContainerClick = useCallback( 188 | (event: React.MouseEvent) => { 189 | if (!disabled) { 190 | focusInput(); 191 | } 192 | }, 193 | [disabled, focusInput] 194 | ); 195 | 196 | const removeLastValue = useCallback(() => { 197 | if (multiple) { 198 | const selectedOptions = selectedOption as SelectOptionProps[]; 199 | onSelectedChange(selectedOptions[selectedOptions.length - 1]); 200 | } else { 201 | onSelectedChange(null); 202 | } 203 | }, [multiple, onSelectedChange, selectedOption]); 204 | 205 | const onInputKeyDown = useCallback( 206 | (event: React.KeyboardEvent) => { 207 | const key = event.key; 208 | 209 | const isActionKey = actionKeys.includes(key); 210 | if (isActionKey) { 211 | event.preventDefault(); 212 | event.stopPropagation(); 213 | } 214 | 215 | if (clearable && key === 'Backspace' && hasValue) { 216 | if (!multiple || (multiple && !inputText)) { 217 | event.preventDefault(); 218 | event.stopPropagation(); 219 | removeLastValue(); 220 | } 221 | } 222 | 223 | onKeyDown?.(event); 224 | }, 225 | [clearable, hasValue, inputText, multiple, onKeyDown, removeLastValue] 226 | ); 227 | 228 | const onInputKeyUp = useCallback( 229 | (event: React.KeyboardEvent) => { 230 | const key = event.key; 231 | const isActionKey = actionKeys.includes(key); 232 | const isHorzKey = horiztonalArrowKeys.includes(key); 233 | 234 | if ((!filterable && !isActionKey) || isHorzKey) { 235 | event.preventDefault(); 236 | event.stopPropagation(); 237 | } else { 238 | onKeyUp?.(event); 239 | } 240 | }, 241 | [filterable, onKeyUp] 242 | ); 243 | 244 | const onChange = useCallback( 245 | (event: React.ChangeEvent) => { 246 | if (filterable) { 247 | onInputChange(event); 248 | } 249 | }, 250 | [filterable, onInputChange] 251 | ); 252 | 253 | const onTagKeyDown = useCallback( 254 | ( 255 | event: React.KeyboardEvent, 256 | option: SelectOptionProps 257 | ) => { 258 | const key = event.key; 259 | if (key === 'Backspace' && !disabled && clearable) { 260 | onSelectedChange(option); 261 | } 262 | }, 263 | [clearable, disabled, onSelectedChange] 264 | ); 265 | 266 | const renderPrefix = useCallback(() => { 267 | if (multiple) { 268 | const multipleOptions = selectedOption as SelectOptionProps[]; 269 | if (multipleOptions?.length) { 270 | return ( 271 |
    272 | {multipleOptions.map(option => ( 273 | 274 | element={chip} 275 | key={option.value} 276 | option={option} 277 | clearable={clearable} 278 | disabled={disabled} 279 | closeIcon={closeIcon} 280 | onSelectedChange={onSelectedChange} 281 | onTagKeyDown={onTagKeyDown} 282 | /> 283 | ))} 284 |
    285 | ); 286 | } 287 | } else { 288 | const singleOption = selectedOption as SelectOptionProps; 289 | if (singleOption?.inputLabel && !inputText) { 290 | return ( 291 |
    292 | {singleOption?.inputLabel} 293 |
    294 | ); 295 | } 296 | } 297 | 298 | return null; 299 | }, [ 300 | chip, 301 | clearable, 302 | closeIcon, 303 | disabled, 304 | inputText, 305 | multiple, 306 | onSelectedChange, 307 | onTagKeyDown, 308 | selectedOption 309 | ]); 310 | 311 | return ( 312 |
    324 |
    325 | {renderPrefix()} 326 | (inputRef.current = el)} 328 | id={id} 329 | style={{ fontSize }} 330 | name={name} 331 | disabled={disabled} 332 | required={required} 333 | autoFocus={autoFocus} 334 | placeholder={placeholderText} 335 | inputClassName={classNames(css.input, 'reaselct-input-input')} 336 | value={inputTextValue} 337 | autoCorrect="off" 338 | spellCheck="false" 339 | autoComplete="off" 340 | onKeyDown={onInputKeyDown} 341 | onKeyUp={onInputKeyUp} 342 | onChange={onChange} 343 | onFocus={onInputFocus} 344 | onBlur={onBlur} 345 | /> 346 |
    347 |
    348 | {refreshable && !loading && ( 349 | 362 | )} 363 | {loading &&
    {loadingIcon}
    } 364 | {showClear && ( 365 | 374 | )} 375 | {!menuDisabled && ( 376 | 385 | )} 386 |
    387 |
    388 | ); 389 | }; 390 | 391 | SelectInput.defaultProps = { 392 | fontSize: 13, 393 | expandIcon: , 394 | closeIcon: , 395 | refreshIcon: , 396 | loadingIcon: , 397 | chip: 398 | }; 399 | -------------------------------------------------------------------------------- /src/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | ReactElement, 4 | useCallback, 5 | useEffect, 6 | useLayoutEffect, 7 | useMemo, 8 | useRef, 9 | useState 10 | } from 'react'; 11 | import { 12 | CloneElement, 13 | ConnectedOverlay, 14 | ConnectedOverlayContentRef, 15 | Placement, 16 | useId 17 | } from 'rdk'; 18 | import { SelectInput, SelectInputProps, SelectInputRef } from './SelectInput'; 19 | import { SelectMenu, SelectMenuProps } from './SelectMenu'; 20 | import { SelectOptionProps, SelectValue } from './SelectOption'; 21 | import { useWidth } from './utils/useWidth'; 22 | import { useFuzzy } from 'react-use-fuzzy'; 23 | import { createOptions, getGroups } from './utils'; 24 | import isEqual from 'react-fast-compare'; 25 | 26 | export interface SelectProps { 27 | id?: string; 28 | name?: string; 29 | style?: React.CSSProperties; 30 | className?: string; 31 | disabled?: boolean; 32 | autoFocus?: boolean; 33 | closeOnSelect?: boolean; 34 | value?: string | string[]; 35 | required?: boolean; 36 | multiple?: boolean; 37 | placeholder?: string; 38 | filterable?: boolean; 39 | clearable?: boolean; 40 | loading?: boolean; 41 | refreshable?: boolean; 42 | createable?: boolean; 43 | children?: any; 44 | error?: boolean; 45 | menuPlacement?: Placement; 46 | menuDisabled?: boolean; 47 | onInputKeydown?: (event: React.KeyboardEvent) => void; 48 | onInputKeyUp?: (event: React.KeyboardEvent) => void; 49 | onFocus?: ( 50 | event: React.FocusEvent | React.MouseEvent 51 | ) => void; 52 | onBlur?: (event: React.FocusEvent) => void; 53 | onInputChange?: (event: React.ChangeEvent) => void; 54 | onRefresh?: () => void; 55 | onChange?: (value) => void; 56 | onOptionsChange?: (options: SelectOptionProps[]) => void; 57 | input?: ReactElement; 58 | menu?: ReactElement; 59 | } 60 | 61 | export const Select: FC> = ({ 62 | id, 63 | name, 64 | autoFocus, 65 | clearable, 66 | filterable, 67 | menuPlacement, 68 | closeOnSelect, 69 | menuDisabled, 70 | refreshable, 71 | placeholder, 72 | disabled, 73 | createable, 74 | loading, 75 | multiple, 76 | error, 77 | className, 78 | children, 79 | value, 80 | required, 81 | input, 82 | menu, 83 | onRefresh, 84 | onChange, 85 | onBlur: onInputBlur, 86 | onFocus: onInputFocus, 87 | onInputKeydown, 88 | onInputKeyUp, 89 | onOptionsChange, 90 | onInputChange 91 | }) => { 92 | const overlayRef = useRef(null); 93 | const inputRef = useRef(null); 94 | const [internalValue, setInternalValue] = useState( 95 | value 96 | ); 97 | const [open, setOpen] = useState(false); 98 | const [index, setIndex] = useState(-1); 99 | const internalId = useId(id); 100 | const [menuWidth, updateMenuWidth] = useWidth( 101 | inputRef.current?.containerRef, 102 | overlayRef 103 | ); 104 | const [options, setOptions] = useState( 105 | createOptions(children) 106 | ); 107 | 108 | useEffect(() => { 109 | const opts = createOptions(children); 110 | if (!isEqual(opts, options)) { 111 | setOptions(opts); 112 | } 113 | }, [children, options]); 114 | 115 | const { result, keyword, search, resetSearch } = useFuzzy( 116 | options, 117 | { 118 | keys: ['children', 'group'] 119 | } 120 | ); 121 | 122 | const groups = useMemo(() => getGroups(result), [result]); 123 | 124 | const selectedOption: SelectValue = useMemo(() => { 125 | if (multiple) { 126 | if (internalValue || internalValue === '') { 127 | return options.filter(o => 128 | (internalValue as string[]).includes(o.value) 129 | ); 130 | } 131 | 132 | return []; 133 | } else if (internalValue || internalValue === '') { 134 | return options.find(o => o.value === internalValue); 135 | } 136 | 137 | return null; 138 | }, [options, multiple, internalValue]); 139 | 140 | useLayoutEffect(() => { 141 | updateMenuWidth(); 142 | }, [internalValue, updateMenuWidth]); 143 | 144 | useEffect(() => { 145 | // This is needed to alllow a select to have a 146 | // starting variable that is set from state 147 | if (!isEqual(value, internalValue)) { 148 | setInternalValue(value); 149 | } 150 | }, [value, internalValue]); 151 | 152 | useEffect(() => { 153 | if (internalValue && createable) { 154 | if (multiple) { 155 | for (const v of internalValue) { 156 | const newOptions = []; 157 | 158 | const has = options.find(o => o.value === v); 159 | if (!has) { 160 | newOptions.push({ 161 | children: v, 162 | value: v 163 | }); 164 | } 165 | 166 | if (newOptions.length) { 167 | const updatedOptions = [...options, ...newOptions]; 168 | 169 | onOptionsChange?.(updatedOptions); 170 | } 171 | } 172 | } else { 173 | const has = options.find(o => o.value === internalValue); 174 | if (!has) { 175 | const updatedOptions = [ 176 | ...options, 177 | { 178 | children: internalValue, 179 | value: internalValue 180 | } 181 | ]; 182 | 183 | onOptionsChange?.(updatedOptions); 184 | } 185 | } 186 | } 187 | }, [createable, internalValue, multiple, options, onOptionsChange]); 188 | 189 | const resetInput = useCallback(() => { 190 | setIndex(-1); 191 | resetSearch(); 192 | }, [resetSearch]); 193 | 194 | const resetSelect = useCallback(() => { 195 | setOpen(false); 196 | resetInput(); 197 | }, [resetInput]); 198 | 199 | const onArrowUpKeyUp = useCallback( 200 | (event: React.KeyboardEvent) => { 201 | event.preventDefault(); 202 | setIndex(Math.max(index - 1, -1)); 203 | }, 204 | [index] 205 | ); 206 | 207 | const onArrowDownKeyUp = useCallback( 208 | (event: React.KeyboardEvent) => { 209 | event.preventDefault(); 210 | setIndex(Math.min(index + 1, groups.itemsCount - 1)); 211 | }, 212 | [groups.itemsCount, index] 213 | ); 214 | 215 | const onInputFocused = useCallback( 216 | ( 217 | event: 218 | | React.FocusEvent 219 | | React.MouseEvent 220 | ) => { 221 | if (!disabled && !menuDisabled) { 222 | setOpen(true); 223 | } 224 | 225 | onInputFocus?.(event); 226 | }, 227 | [disabled, menuDisabled, onInputFocus] 228 | ); 229 | 230 | const onInputExpanded = useCallback( 231 | (event: React.MouseEvent) => { 232 | event.stopPropagation(); 233 | 234 | if (!disabled && !menuDisabled) { 235 | setOpen(!open); 236 | } 237 | }, 238 | [disabled, menuDisabled, open] 239 | ); 240 | 241 | const onInputChanged = useCallback( 242 | (event: React.ChangeEvent) => { 243 | const value = event.target.value; 244 | search(value); 245 | onInputChange?.(event); 246 | }, 247 | [onInputChange, search] 248 | ); 249 | 250 | const toggleSelectedMultiOption = useCallback( 251 | (selections: SelectOptionProps | SelectOptionProps[]) => { 252 | const newOptions: SelectOptionProps[] = []; 253 | let newSelectedOptions = selectedOption as SelectOptionProps[]; 254 | 255 | if (!selections) { 256 | newSelectedOptions = []; 257 | } else { 258 | if (!Array.isArray(selections)) { 259 | selections = [selections]; 260 | } 261 | 262 | for (const next of selections) { 263 | const hasOption = options.find(o => o.value === next.value); 264 | const has = (internalValue || []).includes(next.value); 265 | if (has) { 266 | newSelectedOptions = newSelectedOptions.filter( 267 | o => o.value !== next.value 268 | ); 269 | } else { 270 | newSelectedOptions = [...newSelectedOptions, next]; 271 | } 272 | 273 | if (!hasOption && createable) { 274 | newOptions.push(next); 275 | } 276 | } 277 | } 278 | 279 | return { 280 | newValue: newSelectedOptions.map(o => o.value), 281 | newSelectedOptions, 282 | newOptions 283 | }; 284 | }, 285 | [createable, internalValue, options, selectedOption] 286 | ); 287 | 288 | const toggleSelectedOption = useCallback( 289 | (option: SelectValue) => { 290 | let newValue: string | string[] | null; 291 | 292 | if (multiple) { 293 | const result = toggleSelectedMultiOption(option); 294 | newValue = result.newValue; 295 | if (result.newOptions?.length) { 296 | onOptionsChange?.([...options, ...result.newOptions]); 297 | } 298 | 299 | if (closeOnSelect) { 300 | setOpen(false); 301 | } 302 | } else { 303 | const singleOption = option as SelectOptionProps; 304 | const hasOption = options.find(o => o.value === singleOption?.value); 305 | newValue = singleOption?.value; 306 | const hasValue = newValue !== undefined && newValue !== null; 307 | 308 | if (createable && !hasOption && hasValue) { 309 | onOptionsChange?.([...options, singleOption]); 310 | } 311 | 312 | if (closeOnSelect && hasOption) { 313 | setOpen(false); 314 | } 315 | } 316 | 317 | setInternalValue(newValue); 318 | resetInput(); 319 | onChange?.(newValue); 320 | }, 321 | [ 322 | closeOnSelect, 323 | createable, 324 | multiple, 325 | onChange, 326 | onOptionsChange, 327 | options, 328 | resetInput, 329 | toggleSelectedMultiOption 330 | ] 331 | ); 332 | 333 | const onEnterKeyUp = useCallback( 334 | (event: React.KeyboardEvent) => { 335 | const inputElement = event.target as HTMLInputElement; 336 | const inputValue = inputElement.value.trim().toLowerCase(); 337 | 338 | if (index === -1 && createable && !inputValue) { 339 | return; 340 | } 341 | 342 | if (index > -1 || createable) { 343 | const newSelection = { 344 | value: createable && result[index] ? result[index].value : inputValue, 345 | children: 346 | createable && result[index] ? result[index].value : inputValue 347 | }; 348 | 349 | toggleSelectedOption(newSelection); 350 | } 351 | }, 352 | [createable, index, result, toggleSelectedOption] 353 | ); 354 | 355 | const onInputKeyedUp = useCallback( 356 | (event: React.KeyboardEvent) => { 357 | const key = event.key; 358 | 359 | if (key === 'ArrowUp') { 360 | onArrowUpKeyUp(event); 361 | } else if (key === 'ArrowDown') { 362 | onArrowDownKeyUp(event); 363 | } else if (key === 'Escape') { 364 | resetSelect(); 365 | } else if (key === 'Enter' || key === 'Tab') { 366 | onEnterKeyUp(event); 367 | } 368 | 369 | onInputKeyUp?.(event); 370 | }, 371 | [onArrowDownKeyUp, onArrowUpKeyUp, onEnterKeyUp, onInputKeyUp, resetSelect] 372 | ); 373 | 374 | const onInputBlured = useCallback( 375 | (event: React.FocusEvent) => { 376 | const inputElement = event.target as HTMLInputElement; 377 | const inputValue = inputElement.value.trim(); 378 | if (menuDisabled && createable && inputValue) { 379 | const newSelection = { 380 | value: inputValue, 381 | children: inputValue 382 | }; 383 | 384 | toggleSelectedOption(newSelection); 385 | } 386 | 387 | onInputBlur?.(event); 388 | }, 389 | [createable, menuDisabled, onInputBlur, toggleSelectedOption] 390 | ); 391 | 392 | const onMenuSelectedChange = useCallback( 393 | (option: SelectValue) => { 394 | toggleSelectedOption(option); 395 | 396 | if (closeOnSelect) { 397 | setOpen(false); 398 | } else { 399 | inputRef.current?.focus(); 400 | } 401 | }, 402 | [closeOnSelect, toggleSelectedOption] 403 | ); 404 | 405 | const onOverlayClose = useCallback(() => { 406 | const inputValue = keyword.trim(); 407 | if (createable && inputValue) { 408 | const newSelection = { 409 | value: inputValue, 410 | children: inputValue 411 | }; 412 | 413 | toggleSelectedOption(newSelection); 414 | } 415 | 416 | resetSelect(); 417 | }, [createable, keyword, resetSelect, toggleSelectedOption]); 418 | 419 | return ( 420 | ( 430 | 431 | element={menu} 432 | id={`${internalId}-menu`} 433 | style={{ width: menuWidth }} 434 | selectedOption={selectedOption} 435 | createable={createable} 436 | disabled={disabled} 437 | options={result} 438 | groups={groups} 439 | index={index} 440 | multiple={multiple} 441 | inputSearchText={keyword} 442 | loading={loading} 443 | filterable={filterable} 444 | onSelectedChange={onMenuSelectedChange} 445 | /> 446 | )} 447 | > 448 | 449 | element={input} 450 | id={`${internalId}-input`} 451 | name={name} 452 | disabled={disabled} 453 | reference={inputRef} 454 | menuOpen={open} 455 | autoFocus={autoFocus} 456 | options={options} 457 | error={error} 458 | closeOnSelect={closeOnSelect} 459 | inputText={keyword} 460 | multiple={multiple} 461 | createable={createable} 462 | filterable={filterable} 463 | refreshable={refreshable} 464 | className={className} 465 | required={required} 466 | loading={loading} 467 | placeholder={placeholder} 468 | selectedOption={selectedOption} 469 | clearable={clearable} 470 | menuDisabled={menuDisabled} 471 | onSelectedChange={toggleSelectedOption} 472 | onExpandClick={onInputExpanded} 473 | onKeyDown={onInputKeydown} 474 | onKeyUp={onInputKeyedUp} 475 | onInputChange={onInputChanged} 476 | onBlur={onInputBlured} 477 | onFocus={onInputFocused} 478 | onRefresh={onRefresh} 479 | /> 480 | 481 | ); 482 | }; 483 | 484 | Select.defaultProps = { 485 | clearable: true, 486 | filterable: true, 487 | menuPlacement: 'bottom-start', 488 | closeOnSelect: true, 489 | menuDisabled: false, 490 | refreshable: false, 491 | input: , 492 | menu: 493 | }; 494 | -------------------------------------------------------------------------------- /docs/SingleSelect.story.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from 'react'; 2 | import { Select } from '../src/Select'; 3 | import { SelectOption } from '../src/SelectOption'; 4 | import { SelectMenu } from '../src/SelectMenu'; 5 | import range from 'lodash/range'; 6 | import { SelectInput, SelectInputChip } from '../src/SelectInput'; 7 | 8 | export default { 9 | title: 'Demos/Single Select', 10 | component: Select, 11 | subcomponents: { 12 | SelectOption, 13 | SelectMenu, 14 | SelectInput, 15 | SelectInputChip 16 | } 17 | }; 18 | 19 | const options = [ 20 | { value: 'facebook', label: 'Facebook' }, 21 | { value: 'twitter', label: 'Twitter' }, 22 | { value: 'github', label: 'GitHub' }, 23 | { value: 'google', label: 'Google' }, 24 | { value: 'azure', label: 'Azure' }, 25 | ]; 26 | 27 | export const Basic = () => { 28 | const [value, setValue] = useState(null); 29 | return ( 30 |
    31 | 43 |
    44 | ); 45 | }; 46 | 47 | export const Fonts = () => { 48 | const [value, setValue] = useState(null); 49 | return ( 50 |
    51 | 63 |
    64 | ); 65 | }; 66 | 67 | export const NoOptions = () => ( 68 |
    69 | { 83 | setValue(v); 84 | console.log('onChange', v); 85 | }} 86 | > 87 | {options.map((o) => ( 88 | 89 | {`Option ${o}`} 90 | 91 | ))} 92 | 93 |
    94 | ); 95 | }; 96 | 97 | export const DefaultValue = () => { 98 | const [value, setValue] = useState('facebook'); 99 | return ( 100 |
    101 | 110 |
    111 | ); 112 | }; 113 | 114 | export const InvalidValues = () => { 115 | const [value, setValue] = useState('gop'); 116 | return ( 117 |
    118 | 127 |
    128 | ); 129 | }; 130 | 131 | export const OptionsArray = () => { 132 | const [value, setValue] = useState('github'); 133 | return ( 134 |
    135 | 142 |
    143 | ); 144 | }; 145 | 146 | export const Autofocus = () => { 147 | const [value, setValue] = useState('facebook'); 148 | return ( 149 |
    150 | 155 |
    156 | ); 157 | }; 158 | 159 | export const LongInputNames = () => { 160 | const [value, setValue] = useState('dod'); 161 | return ( 162 |
    163 | 174 |
    175 | ); 176 | }; 177 | 178 | export const FluidWidth = () => { 179 | const [value, setValue] = useState('dod'); 180 | return ( 181 |
    182 | 193 |
    194 | ); 195 | }; 196 | 197 | export const Groups = () => { 198 | const [value, setValue] = useState('twitch'); 199 | return ( 200 |
    201 | 218 |
    219 | ); 220 | }; 221 | 222 | export const LongGroupNames = () => { 223 | const [value, setValue] = useState('twitch'); 224 | return ( 225 |
    226 | 258 |
    259 | ); 260 | }; 261 | 262 | export const MixedGroups = () => { 263 | const [value, setValue] = useState('twitch'); 264 | return ( 265 |
    266 | 281 |
    282 | ); 283 | }; 284 | 285 | export const LoadingIcon = () => ( 286 |
    287 | 294 |
    295 | ); 296 | 297 | export const RefreshIcon = () => ( 298 |
    299 | setValue(v)} 345 | onRefresh={() => setOpts(null)} 346 | > 347 | {opts?.map((o) => ( 348 | 349 | {o.label} 350 | 351 | ))} 352 | 353 |
    354 | ); 355 | }; 356 | 357 | export const AsyncDefaultValue = () => { 358 | const [value, setValue] = useState('github'); 359 | const [loading, setLoading] = useState(false); 360 | const [refreshable, setRefreshable] = useState(false); 361 | const [opts, setOpts] = useState<{ value: string; label: string }[] | null>( 362 | null 363 | ); 364 | 365 | useEffect(() => { 366 | let timeout; 367 | 368 | async function getOptions() { 369 | const next = await new Promise((resolve) => { 370 | timeout = setTimeout(() => { 371 | resolve([...options]); 372 | }, 1500); 373 | }); 374 | 375 | setOpts(next); 376 | setLoading(false); 377 | setRefreshable(true); 378 | } 379 | 380 | if (opts === null) { 381 | setLoading(true); 382 | setRefreshable(false); 383 | getOptions(); 384 | } 385 | 386 | return () => { 387 | clearTimeout(timeout); 388 | }; 389 | }, [opts]); 390 | 391 | return ( 392 |
    393 | 407 |
    408 | ); 409 | }; 410 | 411 | export const CustomLabels = () => { 412 | const [value, setValue] = useState('facebook'); 413 | return ( 414 |
    415 | 442 |
    443 | ); 444 | }; 445 | 446 | export const CustomLongLabels = () => { 447 | const [value, setValue] = useState('facebook'); 448 | return ( 449 |
    450 | 489 |
    490 | ); 491 | }; 492 | 493 | export const Disabled = () => ( 494 |
    495 | 500 |
    501 | ); 502 | 503 | export const Unfilterable = () => { 504 | const [value, setValue] = useState('facebook'); 505 | return ( 506 |
    507 | 512 |
    513 | ); 514 | }; 515 | 516 | export const Createable = () => { 517 | const [value, setValue] = useState(null); 518 | const [animals, setAnimals] = useState(['chicken', 'cow', 'mouse']); 519 | return ( 520 |
    521 | 534 |
    535 | ); 536 | }; 537 | --------------------------------------------------------------------------------