├── 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 |
14 | );
15 |
--------------------------------------------------------------------------------
/src/icons/CloseIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 |
3 | export const CloseIcon: FC = () => (
4 |
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 |
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 |
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 |
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 |
270 |
271 | );
272 |
273 | export const InvalidValues = () => {
274 | const [value, setValue] = useState(['gop']);
275 | return (
276 |
277 |
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 |
70 |
71 | );
72 |
73 | export const ManyOptions = () => {
74 | const [value, setValue] = useState(null);
75 | const options = range(0, 300);
76 |
77 | return (
78 |
79 |
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 |
288 |
289 | );
290 |
291 | export const Error = () => (
292 |
293 |
294 |
295 | );
296 |
297 | export const RefreshIcon = () => (
298 |
299 |
300 |
301 | );
302 |
303 | export const Async = () => {
304 | const [value, setValue] = useState('github');
305 | const [loading, setLoading] = useState(false);
306 | const [refreshable, setRefreshable] = useState(false);
307 | const [opts, setOpts] = useState<{ value: string; label: string }[] | null>(
308 | null
309 | );
310 |
311 | useEffect(() => {
312 | let timeout;
313 |
314 | async function getOptions() {
315 | const next = await new Promise((resolve) => {
316 | timeout = setTimeout(() => {
317 | resolve(options);
318 | }, 1500);
319 | });
320 |
321 | setOpts(next);
322 | setLoading(false);
323 | setRefreshable(true);
324 | }
325 |
326 | if (opts === null) {
327 | setLoading(true);
328 | setRefreshable(false);
329 | getOptions();
330 | }
331 |
332 | return () => {
333 | clearTimeout(timeout);
334 | };
335 | }, [opts]);
336 |
337 | return (
338 |
339 |
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 |
--------------------------------------------------------------------------------