diff --git a/.dumirc.ts b/.dumirc.ts new file mode 100644 index 000000000..cc7fe090a --- /dev/null +++ b/.dumirc.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'dumi'; + +export default defineConfig({ + favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], + themeConfig: { + name: 'Select', + logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', + }, +}); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..fcd98ab24 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "36 13 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cc5fc111a..8dfa0e0dc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: setup: @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: '12' + node-version: '18' - name: cache package-lock.json uses: actions/cache@v2 @@ -24,7 +24,7 @@ jobs: key: lock-${{ github.sha }} - name: create package-lock.json - run: npm i --package-lock-only + run: npm i --package-lock-only --ignore-scripts - name: hack for singe file run: | @@ -43,7 +43,7 @@ jobs: - name: install if: steps.node_modules_cache_id.outputs.cache-hit != 'true' run: npm ci - + lint: runs-on: ubuntu-latest steps: @@ -69,7 +69,7 @@ jobs: run: npm run tsc needs: setup - + compile: runs-on: ubuntu-latest steps: @@ -92,7 +92,7 @@ jobs: run: npm run compile needs: setup - + coverage: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 68d284393..e6b43584f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,8 @@ src/*.map tslint.json tsconfig.test.json .prettierignore -.storybook -storybook/index.js .doc -.umi \ No newline at end of file +.umi +.dumi/tmp +.dumi/tmp-test +.dumi/tmp-production \ No newline at end of file diff --git a/README.md b/README.md index baa4e3ece..7a9c56f9d 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ React Select + [![NPM version][npm-image]][npm-url] [![npm download][download-image]][download-url] [![build status][github-actions-image]][github-actions-url] -[![Test coverage][codecov-image]][codecov-url] +[![Codecov][codecov-image]][codecov-url] [![bundle size][bundlephobia-image]][bundlephobia-url] +[![dumi][dumi-image]][dumi-url] [npm-image]: http://img.shields.io/npm/v/rc-select.svg?style=flat-square [npm-url]: http://npmjs.org/package/rc-select @@ -26,6 +28,8 @@ React Select [download-url]: https://npmjs.org/package/rc-select [bundlephobia-url]: https://bundlephobia.com/package/rc-select [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-select +[dumi-url]: https://github.com/umijs/dumi +[dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square ## Screenshots @@ -50,6 +54,7 @@ React Select ```jsx | pure import Select, { Option } from 'rc-select'; +import 'rc-select/assets/index.css'; export default () => ( node.parentNode} + options={new Array(3).fill(null).map((_, index) => ({ + value: index, + label: `long_label_${index}`, + }))} + {...props} + /> +); class Test extends React.Component { state = { @@ -11,9 +24,7 @@ class Test extends React.Component { destroy: false, }; - getPopupContainer = node => node.parentNode; - - setVisible = open => { + setVisible = (open) => { this.setState({ open, }); @@ -38,6 +49,7 @@ class Test extends React.Component { if (destroy) { return null; } + return (
- +
+
+

Transform: 150%

+ + + + +
); } diff --git a/docs/examples/multiple.tsx b/docs/examples/multiple.tsx index 80c11fe6d..6e9a0a410 100644 --- a/docs/examples/multiple.tsx +++ b/docs/examples/multiple.tsx @@ -15,7 +15,7 @@ for (let i = 10; i < 36; i += 1) { class Test extends React.Component { state = { useAnim: false, - showArrow: false, + suffixIcon: null, loading: false, value: ['a10'], searchValue: "", @@ -44,7 +44,7 @@ class Test extends React.Component { showArrow = e => { this.setState({ - showArrow: e.target.checked, + suffixIcon: e.target.checked ?
arrow
: null, }); }; @@ -61,7 +61,7 @@ class Test extends React.Component { } render() { - const { useAnim, showArrow, loading, value } = this.state; + const { useAnim, loading, value, suffixIcon } = this.state; return (

multiple select(scroll the menu)

@@ -74,7 +74,7 @@ class Test extends React.Component {

@@ -93,7 +93,7 @@ class Test extends React.Component { style={{ width: 500 }} mode="multiple" loading={loading} - showArrow={showArrow} + suffixIcon={suffixIcon} allowClear optionFilterProp="children" optionLabelProp="children" diff --git a/docs/examples/option-render.tsx b/docs/examples/option-render.tsx new file mode 100644 index 000000000..216fd1101 --- /dev/null +++ b/docs/examples/option-render.tsx @@ -0,0 +1,17 @@ +/* eslint-disable no-console */ +import Select from 'rc-select'; +import '../../assets/index.less'; + +export default () => { + return ( + { + setOptions(genData(options.length + 5)); + }} + /> + ), + value: 'loading', + }, + ]} + /> + ); +}; diff --git a/docs/examples/single.tsx b/docs/examples/single.tsx index d23188b27..5afce64f2 100644 --- a/docs/examples/single.tsx +++ b/docs/examples/single.tsx @@ -60,14 +60,13 @@ class Test extends React.Component {

Single Select

-
+
} - showArrow={false} + suffixIcon={null} notFoundContent="" onChange={this.fetchData} onSelect={this.onSelect} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..16fa9cb4a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +--- +hero: + title: rc-select + description: React Select Component +--- + + diff --git a/now.json b/now.json index 1a2d15cf4..9340a6875 100644 --- a/now.json +++ b/now.json @@ -5,7 +5,9 @@ { "src": "package.json", "use": "@now/static-build", - "config": { "distDir": ".doc" } + "config": { + "distDir": "dist" + } } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index 97ee181b1..dda5e5985 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-select", - "version": "14.2.0", + "version": "14.10.0", "description": "React Select", "engines": { "node": ">=8.x" @@ -32,7 +32,8 @@ "scripts": { "start": "dumi dev", "build": "dumi build", - "compile": "father build", + "prepare": "dumi setup", + "compile": "father build && lessc assets/index.less assets/index.css", "prepublishOnly": "npm run compile && np --yolo --no-publish", "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", "test": "rc-test", @@ -45,12 +46,12 @@ }, "dependencies": { "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^1.5.0", "classnames": "2.x", "rc-motion": "^2.0.1", - "rc-overflow": "^1.0.0", - "rc-trigger": "^5.0.4", + "rc-overflow": "^1.3.1", "rc-util": "^5.16.1", - "rc-virtual-list": "^3.4.13" + "rc-virtual-list": "^3.5.2" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.5", @@ -59,18 +60,23 @@ "@types/jest": "^26.0.24", "@types/react": "^17.0.15", "@types/react-dom": "^17.0.3", + "babel-jest": "^29.6.1", "cross-env": "^7.0.0", - "dumi": "^1.1.32", + "dumi": "^2.2.13", "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.15.7", "enzyme-to-json": "^3.4.0", - "eslint": "^7.1.0", + "eslint": "^8.55.0", + "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-unicorn": "^49.0.0", "father": "^4.0.0", "jsonp": "^0.2.1", + "less": "^4.2.0", "np": "^7.5.0", "prettier": "^2.7.1", + "querystring": "^0.2.1", "rc-dialog": "^9.0.0", "rc-test": "^7.0.9", - "typescript": "^4.2.3" + "typescript": "^5.2.2" } } diff --git a/src/BaseSelect.tsx b/src/BaseSelect.tsx index 715530e7e..21375ab1c 100644 --- a/src/BaseSelect.tsx +++ b/src/BaseSelect.tsx @@ -1,5 +1,5 @@ +import type { AlignType, BuildInPlacements } from '@rc-component/trigger/lib/interface'; import classNames from 'classnames'; -import type { AlignType } from 'rc-trigger/lib/interface'; import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; import isMobile from 'rc-util/lib/isMobile'; @@ -7,10 +7,20 @@ import KeyCode from 'rc-util/lib/KeyCode'; import { useComposeRef } from 'rc-util/lib/ref'; import type { ScrollConfig, ScrollTo } from 'rc-virtual-list/lib/List'; import * as React from 'react'; +import { useAllowClear } from './hooks/useAllowClear'; import { BaseSelectContext } from './hooks/useBaseProps'; import useDelayReset from './hooks/useDelayReset'; import useLock from './hooks/useLock'; import useSelectTriggerControl from './hooks/useSelectTriggerControl'; +import type { + DisplayInfoType, + DisplayValueType, + Mode, + Placement, + RawValueType, + RenderDOMFunc, + RenderNode, +} from './interface'; import type { RefSelectorProps } from './Selector'; import Selector from './Selector'; import type { RefTriggerProps } from './SelectTrigger'; @@ -18,6 +28,16 @@ import SelectTrigger from './SelectTrigger'; import TransBtn from './TransBtn'; import { getSeparatedContent } from './utils/valueUtil'; +export type { + DisplayInfoType, + DisplayValueType, + Mode, + Placement, + RenderDOMFunc, + RenderNode, + RawValueType, +}; + const DEFAULT_OMIT_PROPS = [ 'value', 'onChange', @@ -32,19 +52,6 @@ const DEFAULT_OMIT_PROPS = [ 'onPopupScroll', 'tabIndex', ] as const; - -export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode); - -export type RenderDOMFunc = (props: any) => HTMLElement; - -export type Mode = 'multiple' | 'tags' | 'combobox'; - -export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; - -export type RawValueType = string | number; - -export type DisplayInfoType = 'add' | 'remove' | 'clear'; - export interface RefOptionListProps { onKeyDown: React.KeyboardEventHandler; onKeyUp: React.KeyboardEventHandler; @@ -59,14 +66,6 @@ export type CustomTagProps = { closable: boolean; }; -export interface DisplayValueType { - key?: React.Key; - value?: RawValueType; - label?: React.ReactNode; - title?: string | number; - disabled?: boolean; -} - export interface BaseSelectRef { focus: () => void; blur: () => void; @@ -126,6 +125,7 @@ export type BaseSelectPropsWithoutPrivate = Omit React.ReactElement; direction?: 'ltr' | 'rtl'; @@ -167,10 +167,12 @@ export interface BaseSelectProps extends BaseSelectPrivateProps, React.AriaAttri tokenSeparators?: string[]; // >>> Icons - allowClear?: boolean; - showArrow?: boolean; - inputIcon?: RenderNode; - /** Clear all icon */ + allowClear?: boolean | { clearIcon?: RenderNode }; + suffixIcon?: RenderNode; + /** + * Clear all icon + * @deprecated Please use `allowClear` instead + **/ clearIcon?: RenderNode; /** Selector remove icon */ removeIcon?: RenderNode; @@ -184,6 +186,7 @@ export interface BaseSelectProps extends BaseSelectPrivateProps, React.AriaAttri dropdownRender?: (menu: React.ReactElement) => React.ReactElement; dropdownAlign?: AlignType; placement?: Placement; + builtinPlacements?: BuildInPlacements; getPopupContainer?: RenderDOMFunc; // >>> Focus @@ -253,8 +256,7 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref; + } as Omit; DEFAULT_OMIT_PROPS.forEach((propName) => { delete domProps[propName]; @@ -313,6 +316,7 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref(null); const selectorRef = React.useRef(null); const listRef = React.useRef(null); + const blurRef = React.useRef(false); /** Used for component focused management */ const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); @@ -350,12 +354,18 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref(undefined, { + // SSR not support Portal which means we need delay `open` for the first time render + const [rendered, setRendered] = React.useState(false); + useLayoutEffect(() => { + setRendered(true); + }, []); + + const [innerOpen, setInnerOpen] = useMergedState(false, { defaultValue: defaultOpen, value: open, }); - let mergedOpen = innerOpen; + let mergedOpen = rendered ? innerOpen : false; // Not trigger `open` in `combobox` when `notFoundContent` is empty const emptyListContent = !notFoundContent && emptyOptions; @@ -442,7 +452,8 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref = (...args) => { + blurRef.current = true; + setMockFocused(false, () => { focusRef.current = false; + blurRef.current = false; onToggleOpen(false); }); @@ -614,23 +628,12 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref { - if (triggerOpen) { - const newWidth = Math.ceil(containerRef.current?.offsetWidth); - if (containerWidth !== newWidth && !Number.isNaN(newWidth)) { - setContainerWidth(newWidth); - } - } - }, [triggerOpen]); - // Used for raw custom input trigger let onTriggerVisibleChange: null | ((newOpen: boolean) => void); if (customizeRawInputElement) { @@ -667,17 +670,16 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref = () => { onClear?.(); @@ -703,22 +704,17 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref - × - - ); - } + const { allowClear: mergedAllowClear, clearIcon: clearNode } = useAllowClear( + prefixCls, + onClearMouseDown, + displayValues, + allowClear, + clearIcon, + disabled, + + mergedSearchValue, + mode, + ); // =========================== OptionList =========================== const optionList = ; @@ -729,7 +725,7 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref selectorDomRef.current} @@ -830,9 +826,8 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref )} {selectorNode} - {arrowNode} - {clearNode} + {mergedAllowClear && clearNode}
); } diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 6fc3a1d51..a9eee236b 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import useMemo from 'rc-util/lib/hooks/useMemo'; import KeyCode from 'rc-util/lib/KeyCode'; +import useMemo from 'rc-util/lib/hooks/useMemo'; import omit from 'rc-util/lib/omit'; import pickAttrs from 'rc-util/lib/pickAttrs'; import type { ListRef } from 'rc-virtual-list'; @@ -8,11 +8,11 @@ import List from 'rc-virtual-list'; import type { ScrollConfig } from 'rc-virtual-list/lib/List'; import * as React from 'react'; import { useEffect } from 'react'; -import useBaseProps from './hooks/useBaseProps'; -import type { FlattenOptionData } from './interface'; import type { BaseOptionType, RawValueType } from './Select'; import SelectContext from './SelectContext'; import TransBtn from './TransBtn'; +import useBaseProps from './hooks/useBaseProps'; +import type { FlattenOptionData } from './interface'; import { isPlatformMac } from './utils/platformUtil'; // export interface OptionListProps { @@ -53,8 +53,10 @@ const OptionList: React.ForwardRefRenderFunction = (_, r rawValues, fieldNames, virtual, + direction, listHeight, listItemHeight, + optionRender, } = React.useContext(SelectContext); const itemPrefixCls = `${prefixCls}-item`; @@ -297,6 +299,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, r onMouseDown={onListMouseDown} onScroll={onPopupScroll} virtual={virtual} + direction={direction} innerProps={virtual ? null : a11yProps} > {(item, itemIndex) => { @@ -364,13 +367,21 @@ const OptionList: React.ForwardRefRenderFunction = (_, r }} style={style} > -
{content}
+
+ {typeof optionRender === 'function' + ? optionRender(item, { index: itemIndex }) + : content} +
{React.isValidElement(menuItemSelectedIcon) || selected} {iconVisible && ( {selected ? '✓' : null} diff --git a/src/Select.tsx b/src/Select.tsx index 51e36359d..1b2dc54ec 100644 --- a/src/Select.tsx +++ b/src/Select.tsx @@ -41,16 +41,17 @@ import type { RenderNode, } from './BaseSelect'; import BaseSelect, { isMultiple } from './BaseSelect'; +import OptGroup from './OptGroup'; +import Option from './Option'; +import OptionList from './OptionList'; +import SelectContext from './SelectContext'; import useCache from './hooks/useCache'; import useFilterOptions from './hooks/useFilterOptions'; import useId from './hooks/useId'; import useOptions from './hooks/useOptions'; import useRefFunc from './hooks/useRefFunc'; -import OptGroup from './OptGroup'; -import Option from './Option'; -import OptionList from './OptionList'; -import SelectContext from './SelectContext'; -import { hasValue, toArray } from './utils/commonUtil'; +import type { FlattenOptionData } from './interface'; +import { hasValue, isComboNoValue, toArray } from './utils/commonUtil'; import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil'; import warningProps, { warningNullOptions } from './utils/warningPropsUtil'; @@ -83,6 +84,7 @@ export type FilterFunc = (inputValue: string, option?: OptionType) = export interface FieldNames { value?: string; label?: string; + groupLabel?: string; options?: string; } @@ -137,8 +139,13 @@ export interface SelectProps, + info: { index: number }, + ) => React.ReactNode; defaultActiveFirstOption?: boolean; virtual?: boolean; + direction?: 'ltr' | 'rtl'; listHeight?: number; listItemHeight?: number; @@ -182,10 +189,12 @@ const Select = React.forwardRef( optionFilterProp, optionLabelProp, options, + optionRender, children, defaultActiveFirstOption, menuItemSelectedIcon, virtual, + direction, listHeight = 200, listItemHeight = 20, @@ -272,7 +281,12 @@ const Select = React.forwardRef( // Warning if label not same as provided if (process.env.NODE_ENV !== 'production' && !optionLabelProp) { const optionLabel = option?.[mergedFieldNames.label]; - if (optionLabel !== undefined && optionLabel !== rawLabel) { + if ( + optionLabel !== undefined && + !React.isValidElement(optionLabel) && + !React.isValidElement(rawLabel) && + optionLabel !== rawLabel + ) { warning(false, '`label` of `value` is not same as `label` in Select options.'); } } @@ -299,8 +313,8 @@ const Select = React.forwardRef( const rawLabeledValues = React.useMemo(() => { const values = convert2LabelValues(internalValue); - // combobox no need save value when it's no value - if (mode === 'combobox' && !values[0]?.value) { + // combobox no need save value when it's no value (exclude value equal 0) + if (mode === 'combobox' && isComboNoValue(values[0]?.value)) { return []; } @@ -395,10 +409,20 @@ const Select = React.forwardRef( ) { return filteredOptions; } - + // ignore when search value equal select input value + if (filteredOptions.some((item) => item[mergedFieldNames.value] === mergedSearchValue)) { + return filteredOptions; + } // Fill search value as option return [createTagOption(mergedSearchValue), ...filteredOptions]; - }, [createTagOption, optionFilterProp, mode, filteredOptions, mergedSearchValue]); + }, [ + createTagOption, + optionFilterProp, + mode, + filteredOptions, + mergedSearchValue, + mergedFieldNames, + ]); const orderedFilteredOptions = React.useMemo(() => { if (!filterSort) { @@ -410,7 +434,10 @@ const Select = React.forwardRef( const displayOptions = React.useMemo( () => - flattenOptions(orderedFilteredOptions, { fieldNames: mergedFieldNames, childrenAsData }), + flattenOptions(orderedFilteredOptions, { + fieldNames: mergedFieldNames, + childrenAsData, + }), [orderedFilteredOptions, mergedFieldNames, childrenAsData], ); @@ -580,9 +607,11 @@ const Select = React.forwardRef( rawValues, fieldNames: mergedFieldNames, virtual: realVirtual, + direction, listHeight, listItemHeight, childrenAsData, + optionRender, }; }, [ parsedOptions, @@ -598,6 +627,7 @@ const Select = React.forwardRef( listHeight, listItemHeight, childrenAsData, + optionRender, ]); // ========================== Warning =========================== @@ -622,6 +652,8 @@ const Select = React.forwardRef( // >>> Values displayValues={displayValues} onDisplayValuesChange={onDisplayValuesChange} + // >>> Trigger + direction={direction} // >>> Search searchValue={mergedSearchValue} onSearch={onInternalSearch} diff --git a/src/SelectContext.ts b/src/SelectContext.ts index ed9ffea25..cd7d5f720 100644 --- a/src/SelectContext.ts +++ b/src/SelectContext.ts @@ -1,11 +1,18 @@ import * as React from 'react'; import type { RawValueType, RenderNode } from './BaseSelect'; +import type { + BaseOptionType, + FieldNames, + OnActiveValue, + OnInternalSelect, + SelectProps, +} from './Select'; import type { FlattenOptionData } from './interface'; -import type { BaseOptionType, FieldNames, OnActiveValue, OnInternalSelect } from './Select'; // Use any here since we do not get the type during compilation export interface SelectContextProps { options: BaseOptionType[]; + optionRender?: SelectProps['optionRender']; flattenOptions: FlattenOptionData[]; onActiveValue: OnActiveValue; defaultActiveFirstOption?: boolean; @@ -14,6 +21,7 @@ export interface SelectContextProps { rawValues: Set; fieldNames?: FieldNames; virtual?: boolean; + direction?: 'ltr' | 'rtl'; listHeight?: number; listItemHeight?: number; childrenAsData?: boolean; diff --git a/src/SelectTrigger.tsx b/src/SelectTrigger.tsx index 530be5ec0..33c6038a7 100644 --- a/src/SelectTrigger.tsx +++ b/src/SelectTrigger.tsx @@ -1,10 +1,12 @@ -import * as React from 'react'; -import Trigger from 'rc-trigger'; -import type { AlignType } from 'rc-trigger/lib/interface'; +import Trigger from '@rc-component/trigger'; +import type { AlignType, BuildInPlacements } from '@rc-component/trigger/lib/interface'; import classNames from 'classnames'; +import * as React from 'react'; import type { Placement, RenderDOMFunc } from './BaseSelect'; -const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { +const getBuiltInPlacements = ( + dropdownMatchSelectWidth: number | boolean, +): Record => { // Enable horizontal overflow auto-adjustment when a custom dropdown width is provided const adjustX = dropdownMatchSelectWidth === true ? 0 : 1; return { @@ -15,6 +17,7 @@ const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { adjustX, adjustY: 1, }, + htmlRegion: 'scroll', }, bottomRight: { points: ['tr', 'br'], @@ -23,6 +26,7 @@ const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { adjustX, adjustY: 1, }, + htmlRegion: 'scroll', }, topLeft: { points: ['bl', 'tl'], @@ -31,6 +35,7 @@ const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { adjustX, adjustY: 1, }, + htmlRegion: 'scroll', }, topRight: { points: ['br', 'tr'], @@ -39,6 +44,7 @@ const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { adjustX, adjustY: 1, }, + htmlRegion: 'scroll', }, }; }; @@ -56,8 +62,8 @@ export interface SelectTriggerProps { animation?: string; transitionName?: string; - containerWidth: number; placement?: Placement; + builtinPlacements?: BuildInPlacements; dropdownStyle: React.CSSProperties; dropdownClassName: string; direction: string; @@ -83,13 +89,13 @@ const SelectTrigger: React.RefForwardingComponent getBuiltInPlacements(dropdownMatchSelectWidth), - [dropdownMatchSelectWidth], + const mergedBuiltinPlacements = React.useMemo( + () => builtinPlacements || getBuiltInPlacements(dropdownMatchSelectWidth), + [builtinPlacements, dropdownMatchSelectWidth], ); // ===================== Motion ====================== const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; + // =================== Popup Width =================== + const isNumberPopupWidth = typeof dropdownMatchSelectWidth === 'number'; + + const stretch = React.useMemo(() => { + if (isNumberPopupWidth) { + return null; + } + + return dropdownMatchSelectWidth === false ? 'minWidth' : 'width'; + }, [dropdownMatchSelectWidth, isNumberPopupWidth]); + + let popupStyle = dropdownStyle; + + if (isNumberPopupWidth) { + popupStyle = { + ...popupStyle, + width: dropdownMatchSelectWidth, + }; + } + // ======================= Ref ======================= const popupRef = React.useRef(null); @@ -123,24 +149,13 @@ const SelectTrigger: React.RefForwardingComponent popupRef.current, })); - const popupStyle: React.CSSProperties = { - minWidth: containerWidth, - ...dropdownStyle, - }; - - if (typeof dropdownMatchSelectWidth === 'number') { - popupStyle.width = dropdownMatchSelectWidth; - } else if (dropdownMatchSelectWidth) { - popupStyle.width = containerWidth; - } - return ( } + stretch={stretch} popupAlign={dropdownAlign} popupVisible={visible} getPopupContainer={getPopupContainer} diff --git a/src/Selector/Input.tsx b/src/Selector/Input.tsx index 498608c84..1c37c4d1f 100644 --- a/src/Selector/Input.tsx +++ b/src/Selector/Input.tsx @@ -90,12 +90,12 @@ const Input: React.RefForwardingComponent = ( className: classNames(`${prefixCls}-selection-search-input`, inputNode?.props?.className), role: 'combobox', - 'aria-expanded': open, + 'aria-expanded': open || false, 'aria-haspopup': 'listbox', 'aria-owns': `${id}_list`, 'aria-autocomplete': 'list', 'aria-controls': `${id}_list`, - 'aria-activedescendant': activeDescendantId, + 'aria-activedescendant': open ? activeDescendantId : undefined, ...attrs, value: editable ? value : '', maxLength, diff --git a/src/Selector/SingleSelector.tsx b/src/Selector/SingleSelector.tsx index d45805e6c..b1f867c07 100644 --- a/src/Selector/SingleSelector.tsx +++ b/src/Selector/SingleSelector.tsx @@ -36,6 +36,7 @@ const SingleSelector: React.FC = (props) => { onInputPaste, onInputCompositionStart, onInputCompositionEnd, + title, } = props; const [inputChanged, setInputChanged] = React.useState(false); @@ -58,16 +59,19 @@ const SingleSelector: React.FC = (props) => { // Not show text when closed expect combobox mode const hasTextInput = mode !== 'combobox' && !open && !showSearch ? false : !!inputValue; - // Get title - const title = getTitle(item); + // Get title of selection item + const selectionTitle = title === undefined ? getTitle(item) : title; const renderPlaceholder = () => { if (item) { return null; } - const hiddenStyle = hasTextInput ? { visibility: 'hidden' as const } : undefined; + const hiddenStyle = hasTextInput ? { visibility: 'hidden' } as React.CSSProperties : undefined; return ( - + {placeholder} ); @@ -104,11 +108,18 @@ const SingleSelector: React.FC = (props) => { {/* Display value */} - {!combobox && item && !hasTextInput && ( - + {(!combobox && item) ? ( + {item.label} - )} + ) : null} {/* Display placeholder */} {renderPlaceholder()} diff --git a/src/Selector/index.tsx b/src/Selector/index.tsx index 20b18cd53..3f80b1581 100644 --- a/src/Selector/index.tsx +++ b/src/Selector/index.tsx @@ -22,6 +22,7 @@ export interface InnerSelectorProps { prefixCls: string; id: string; mode: Mode; + title?: string; inputRef: React.Ref; placeholder?: React.ReactNode; diff --git a/src/hooks/useAllowClear.tsx b/src/hooks/useAllowClear.tsx new file mode 100644 index 000000000..cc81e5e8d --- /dev/null +++ b/src/hooks/useAllowClear.tsx @@ -0,0 +1,48 @@ +import TransBtn from '../TransBtn'; +import type { DisplayValueType, Mode } from '../interface'; +import type { ReactNode } from 'react'; +import React from 'react'; + +export function useAllowClear( + prefixCls, + onClearMouseDown, + displayValues: DisplayValueType[], + allowClear?: boolean | { clearIcon?: ReactNode }, + clearIcon?: ReactNode, + disabled = false, + mergedSearchValue?: string, + mode?: Mode +) { + const mergedClearIcon = React.useMemo(() => { + if (typeof allowClear === "object") { + return allowClear.clearIcon; + } + if (!!clearIcon) return clearIcon; + }, [allowClear, clearIcon]); + + + const mergedAllowClear = React.useMemo(() => { + if ( + !disabled && + !!allowClear && + (displayValues.length || mergedSearchValue) && + !(mode === 'combobox' && mergedSearchValue === '') + ) { + return true; + } + return false; + }, [allowClear, disabled, displayValues.length, mergedSearchValue, mode]); + + return { + allowClear: mergedAllowClear, + clearIcon: ( + + × + + ) + }; +} diff --git a/src/interface.ts b/src/interface.ts index a24aa372d..938ea604a 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,6 +1,6 @@ import type * as React from 'react'; -import type { RawValueType } from './BaseSelect'; +export type RawValueType = string | number; export interface FlattenOptionData { label?: React.ReactNode; data: OptionType; @@ -9,3 +9,22 @@ export interface FlattenOptionData { groupOption?: boolean; group?: boolean; } + +export interface DisplayValueType { + key?: React.Key; + value?: RawValueType; + label?: React.ReactNode; + title?: string | number; + disabled?: boolean; +} + +export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode); + +export type RenderDOMFunc = (props: any) => HTMLElement; + +export type Mode = 'multiple' | 'tags' | 'combobox'; + +export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + +export type DisplayInfoType = 'add' | 'remove' | 'clear'; + diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts index a1bb59a8f..ac948e3e1 100644 --- a/src/utils/commonUtil.ts +++ b/src/utils/commonUtil.ts @@ -17,6 +17,11 @@ export function hasValue(value) { return value !== undefined && value !== null; } +/** combo mode no value judgment function */ +export function isComboNoValue(value) { + return !value && value !== 0; +} + function isTitleType(title: any) { return ['string', 'number'].includes(typeof title); } diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index ca18015f5..ea3320153 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -21,12 +21,14 @@ function getKey(data: BaseOptionType, index: number) { } export function fillFieldNames(fieldNames: FieldNames | undefined, childrenAsData: boolean) { - const { label, value, options } = fieldNames || {}; + const { label, value, options, groupLabel } = fieldNames || {}; + const mergedLabel = label || (childrenAsData ? 'children' : 'label'); return { - label: label || (childrenAsData ? 'children' : 'label'), + label: mergedLabel, value: value || 'value', options: options || 'options', + groupLabel: groupLabel || mergedLabel, }; } @@ -45,12 +47,11 @@ export function flattenOptions { - const label = data[fieldLabel]; - if (isGroupOption || !(fieldOptions in data)) { const value = data[fieldValue]; @@ -59,11 +60,11 @@ export function flattenOptions(() => (
Popup
@@ -58,4 +58,29 @@ describe('BaseSelect', () => { ).toBe('absolute'); jest.useRealTimers(); }); + + it('customize builtinPlacements should override default one', () => { + const { container } = render( + {}} + searchValue="" + onSearch={() => {}} + OptionList={OptionList} + emptyOptions + open + // Test content + builtinPlacements={{ + // placement not exist in `builtinPlacements`, + // which means this will be same as empty one. + // It's safe to test in other way if refactor + fallback: {}, + }} + />, + ); + + expect(container.querySelector('.rc-select-dropdown-placement-fallback')).toBeTruthy(); + }); }); diff --git a/tests/Combobox.test.tsx b/tests/Combobox.test.tsx index b382befce..c063f8faa 100644 --- a/tests/Combobox.test.tsx +++ b/tests/Combobox.test.tsx @@ -74,7 +74,7 @@ describe('Select.Combobox', () => { expect(wrapper.find('input').props().value).toBe(''); expect(wrapper.find('.rc-select-selection-placeholder').text()).toEqual('placeholder'); wrapper.find('input').simulate('change', { target: { value: '1' } }); - expect(wrapper.find('.rc-select-selection-placeholder').length).toBeFalsy(); + expect(wrapper.find('.rc-select-selection-placeholder').length).toBe(0); expect(wrapper.find('input').props().value).toBe('1'); }); @@ -600,4 +600,10 @@ describe('Select.Combobox', () => { jest.useRealTimers(); }); + + // https://github.com/ant-design/ant-design/issues/43936 + it('combobox mode not show 0 value', () => { + const wrapper = mount(, ); - expect(wrapper.find('.rc-select-arrow-icon').length).toBeFalsy(); + expect(wrapper.find('.rc-select-arrow').length).toBeFalsy(); wrapper.setProps({ - showArrow: true, + suffixIcon:
arrow
, }); - expect(wrapper.find('.rc-select-arrow-icon').length).toBeTruthy(); + expect(wrapper.find('.rc-select-arrow').length).toBeTruthy(); }); it('block input when fast backspace', () => { diff --git a/tests/Select.test.tsx b/tests/Select.test.tsx index 44f4f848e..3ff82ec77 100644 --- a/tests/Select.test.tsx +++ b/tests/Select.test.tsx @@ -1,7 +1,7 @@ +import { fireEvent, render as testingRender } from '@testing-library/react'; import { mount, render } from 'enzyme'; -import { render as testingRender, fireEvent } from '@testing-library/react'; import KeyCode from 'rc-util/lib/KeyCode'; -import { spyElementPrototype } from 'rc-util/lib/test/domHook'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import { resetWarned } from 'rc-util/lib/warning'; import VirtualList from 'rc-virtual-list'; import type { ScrollConfig } from 'rc-virtual-list/lib/List'; @@ -42,7 +42,6 @@ describe('Select.Basic', () => { className="select-test" value="2" placeholder="Select a number" - showArrow allowClear showSearch {...props} @@ -66,8 +65,8 @@ describe('Select.Basic', () => { }); it('renders dropdown correctly', () => { - const wrapper = render(genSelect({ open: true })); - expect(wrapper).toMatchSnapshot(); + const { container } = testingRender(genSelect({ open: true })); + expect(container.querySelector('.rc-select-dropdown')).toMatchSnapshot(); }); it('renders disabled select correctly', () => { @@ -104,6 +103,66 @@ describe('Select.Basic', () => { ); expect(wrapper).toMatchSnapshot(); }); + + it('should support fieldName', () => { + // groupLabel > fieldNames > self-label + function genOpts(OptLabelName, groupLabel) { + return [ + { + [groupLabel]: 'groupLabel', + options: [ + { + value: 'value', + [OptLabelName]: 'label', + }, + ], + }, + ]; + } + + const { container: containerFirst } = testingRender( + , + ); + const { container: containerThird } = testingRender( + , + ); + expect(container.querySelector('.rc-select-item-option-content').innerHTML).toBe('itemLabel'); }); it('convert value to array', () => { @@ -207,6 +266,41 @@ describe('Select.Basic', () => { , ); expect(wrapper2.find('.rc-select-clear-icon').length).toBeFalsy(); + + const wrapper3 = mount( + , + ); + expect(wrapper3.find('.custom-clear-icon').length).toBeTruthy(); + expect(wrapper3.find('.custom-clear-icon').text()).toBe('x'); + + const wrapper4 = mount( + , + ); + expect(wrapper4.find('.custom-clear-icon').length).toBeFalsy(); + + resetWarned(); + const wrapper5 = mount( + , + ); + expect(wrapper5.find('.custom-clear-icon').length).toBeTruthy(); + expect(wrapper5.find('.custom-clear-icon').text()).toBe('x'); + + const wrapper6 = mount( + , + ); + expect(wrapper6.find('.custom-clear-icon').length).toBeFalsy(); }); it('should direction rtl', () => { @@ -353,7 +447,7 @@ describe('Select.Basic', () => { }); it('should contain falsy children', () => { - const wrapper = render( + const { container } = testingRender( , ); - expect(wrapper).toMatchSnapshot(); + expect(container.querySelector('.rc-select-dropdown')).toMatchSnapshot(); }); it('open dropdown on down key press', () => { @@ -595,18 +689,16 @@ describe('Select.Basic', () => { expect(wrapper.find('.rc-select').getDOMNode().className).toContain('-focus'); }); - it('click placeholder should trigger onFocus', () => { - const wrapper2 = mount( + it('focus input when placeholder is clicked', () => { + const wrapper = mount( , ); - - const inputSpy = jest.spyOn(wrapper2.find('input').instance(), 'focus' as any); - - wrapper2.find('.rc-select-selection-placeholder').simulate('mousedown'); - wrapper2.find('.rc-select-selection-placeholder').simulate('click'); + const inputSpy = jest.spyOn(wrapper.find('input').instance(), 'focus' as any); + wrapper.find('.rc-select-selection-placeholder').simulate('mousedown'); + wrapper.find('.rc-select-selection-placeholder').simulate('click'); expect(inputSpy).toHaveBeenCalled(); }); }); @@ -816,19 +908,6 @@ describe('Select.Basic', () => { expectOpen(wrapper, false); }); - it('focus input when placeholder is clicked', () => { - const wrapper = mount( - , - ); - - const focusSpy = jest.spyOn(wrapper.find('input').instance(), 'focus' as any); - wrapper.find('.rc-select-selection-placeholder').simulate('mousedown'); - wrapper.find('.rc-select-selection-placeholder').simulate('click'); - expect(focusSpy).toHaveBeenCalled(); - }); - describe('combobox could customize input element', () => { it('work', () => { const onKeyDown = jest.fn(); @@ -1038,25 +1117,25 @@ describe('Select.Basic', () => { }); it('filterOption could be true as described in default value', () => { - const wrapper = mount( + const { container } = testingRender( , ); - expect(wrapper.render()).toMatchSnapshot(); + expect(container.querySelector('.rc-select-dropdown')).toMatchSnapshot(); }); it('does not filter when filterOption value is false', () => { - const wrapper = render( + const { container } = testingRender( , ); - expect(wrapper).toMatchSnapshot(); + expect(container.querySelector('.rc-select-dropdown')).toMatchSnapshot(); }); it('backfill', () => { @@ -1130,7 +1209,7 @@ describe('Select.Basic', () => { }); it('should render custom dropdown correctly', () => { - const wrapper = mount( + const { container } = testingRender( , ); - expect(wrapper.render()).toMatchSnapshot(); + expect(container.querySelector('.rc-select-dropdown')).toMatchSnapshot(); }); it('should trigger click event in custom node', () => { @@ -1279,8 +1358,10 @@ describe('Select.Basic', () => { let domHook; beforeAll(() => { - domHook = spyElementPrototype(HTMLElement, 'offsetWidth', { - get: () => 1000, + domHook = spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: 1000, + }), }); }); @@ -1407,6 +1488,7 @@ describe('Select.Basic', () => { it('dropdown selection item customize icon', () => { const menuItemSelectedIcon = jest.fn(); + mount( , + ); + expect(menuItemSelectedIcon).toHaveBeenCalledWith({ + value: '2', + disabled: true, + isSelected: false, + }); }); it('keyDown & KeyUp event', () => { @@ -1432,17 +1531,55 @@ describe('Select.Basic', () => { expect(onKeyUp).toHaveBeenCalled(); }); - it('warning if label not same as option', () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount( - , - ); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: `label` of `value` is not same as `label` in Select options.', - ); - errorSpy.mockRestore(); + describe('warning if label not same as option', () => { + it('should work', () => { + resetWarned(); + + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mount( + , + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `label` of `value` is not same as `label` in Select options.', + ); + errorSpy.mockRestore(); + }); + + it('not warning for react node', () => { + resetWarned(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const Demo = () => { + const [, setVal] = React.useState(0); + + return ( + ); - expect( - wrapper.find('.rc-select-selection-placeholder').getDOMNode().hasAttribute('style'), - ).toBe(false); - toggleOpen(wrapper); - expect( - (wrapper.find('.rc-select-selection-placeholder').getDOMNode() as HTMLSpanElement).style - .visibility, - ).toBe('hidden'); - }); - - it('when value is null', () => { - const wrapper = mount(, - ); - expect(wrapper.find('.rc-select-selection-placeholder').length).toBeFalsy(); - }); - }); - it('Remove options can keep the cache', () => { const wrapper = mount(, - ); - expect( - (wrapper.find('.rc-select-selection-placeholder').getDOMNode() as HTMLSpanElement).style - .visibility, - ).toBe('hidden'); - }); - it('no warning for non-dom attr', () => { const wrapper = mount( ); + expect(wrapper1.find('.rc-select').prop('title')).toBe(undefined); + expect(wrapper1.find('.rc-select-selection-item').prop('title')).toBe('lucy'); + const wrapper2 = mount(); + expect(wrapper3.find('.rc-select').prop('title')).toBe('title'); + expect(wrapper3.find('.rc-select-selection-item').prop('title')).toBe('title'); + }); + + it('scrollbar should be left position with rtl direction', () => { + const options = new Array(10).fill(null).map((_, value) => ({ value })); + + const { container } = testingRender( { + return `${option.label} - ${index}`; + }} + />, + ); + expect(container.querySelector('.rc-select-item-option-content').innerHTML).toEqual( + 'test1 - 0', + ); + }); }); diff --git a/tests/Tags.test.tsx b/tests/Tags.test.tsx index 8b5a4d26a..86e1a6d83 100644 --- a/tests/Tags.test.tsx +++ b/tests/Tags.test.tsx @@ -1,4 +1,5 @@ import { mount } from 'enzyme'; +import { render } from '@testing-library/react'; import KeyCode from 'rc-util/lib/KeyCode'; import classNames from 'classnames'; import * as React from 'react'; @@ -406,9 +407,8 @@ describe('Select.Tags', () => { ); it('renders correctly', () => { - const wrapper = mount(createSelect({ value: ['jack', 'foo'] })); - toggleOpen(wrapper); - expect(wrapper.render()).toMatchSnapshot(); + const { container } = render(createSelect({ value: ['jack', 'foo'], open: true })); + expect(container.firstChild).toMatchSnapshot(); }); it('renders inputValue correctly', () => { @@ -483,4 +483,26 @@ describe('Select.Tags', () => { expect(preventDefault).toHaveBeenCalled(); }); + + // https://github.com/ant-design/ant-design/issues/43954 + it('when insert a same input is one of options value, should not create a tag option', () => { + const errSpy = jest.spyOn(console, 'error'); + + const wrapper = mount( + -
- - - -
-
-
-
- 1 -
-
- 2 -
-
-
+
+ 1 +
+
+ 2 +
+
+
+
+
-
+
-
-
- 1 -
-
-
-
- 2 -
-
+ 1
+
+
+
+ 2 +
+
-
`; exports[`Select.Basic filterOption could be true as described in default value 1`] = ` `; exports[`Select.Basic no search 1`] = `
-
`; @@ -244,7 +148,7 @@ exports[`Select.Basic render renders aria-attributes correctly 1`] = ` -
`; -exports[`Select.Basic render renders dropdown correctly 1`] = ` +exports[`Select.Basic render renders dropdown correctly 1`] = `null`; + +exports[`Select.Basic render renders role prop correctly 1`] = `