diff --git a/assets/index.less b/assets/index.less index f916507..adab276 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,4 +1,8 @@ .rc-input { + &-out-of-range { + color: red; + } + &-affix-wrapper { padding: 2px 8px; overflow: hidden; diff --git a/docs/examples/show-count.tsx b/docs/examples/show-count.tsx index 42d590e..91be3a2 100644 --- a/docs/examples/show-count.tsx +++ b/docs/examples/show-count.tsx @@ -1,10 +1,69 @@ +import Input from 'rc-input'; import type { FC } from 'react'; import React from 'react'; import '../../assets/index.less'; -import Input from 'rc-input'; + +const sharedHeadStyle: React.CSSProperties = { + margin: 0, + padding: 0, +}; const Demo: FC = () => { - return ; + return ( +
+

Native

+ + +

Count

+

Only Max

+ +

Customize strategy

+ + [...new (Intl as any).Segmenter().segment(val)].length, + }} + /> +

Customize exceedFormatter

+ { + const segments = [...new (Intl as any).Segmenter().segment(val)]; + + return segments + .filter((seg) => seg.index + seg.segment.length <= max) + .map((seg) => seg.segment) + .join(''); + }, + }} + /> +
+ ); }; export default Demo; diff --git a/package.json b/package.json index aeb89af..71d25f2 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "pretty-quick": "pretty-quick", "lint-staged": "lint-staged", - "test": "umi-test test", - "coverage": "father test --coverage", + "test": "rc-test", + "coverage": "rc-test --coverage", "prepare": "husky install" }, "dependencies": { @@ -69,10 +69,10 @@ "np": "^7.0.0", "prettier": "^2.0.5", "pretty-quick": "^3.0.0", + "rc-test": "^7.0.15", "react": "^18.0.0", "react-dom": "^18.0.0", - "typescript": "^4.0.5", - "umi-test": "^1.9.7" + "typescript": "^4.0.5" }, "peerDependencies": { "react": ">=16.0.0", diff --git a/src/Input.tsx b/src/Input.tsx index 6a164aa..7351f4e 100644 --- a/src/Input.tsx +++ b/src/Input.tsx @@ -9,13 +9,10 @@ import React, { useState, } from 'react'; import BaseInput from './BaseInput'; +import useCount from './hooks/useCount'; import type { InputProps, InputRef } from './interface'; import type { InputFocusOptions } from './utils/commonUtils'; -import { - fixControlledValue, - resolveOnChange, - triggerFocus, -} from './utils/commonUtils'; +import { resolveOnChange, triggerFocus } from './utils/commonUtils'; const Input = forwardRef((props, ref) => { const { @@ -32,6 +29,7 @@ const Input = forwardRef((props, ref) => { maxLength, suffix, showCount, + count, type = 'text', classes, classNames, @@ -39,10 +37,8 @@ const Input = forwardRef((props, ref) => { ...rest } = props; - const [value, setValue] = useMergedState(props.defaultValue, { - value: props.value, - }); const [focused, setFocused] = useState(false); + const compositionRef = React.useRef(false); const inputRef = useRef(null); @@ -52,6 +48,21 @@ const Input = forwardRef((props, ref) => { } }; + // ====================== Value ======================= + const [value, setValue] = useMergedState(props.defaultValue, { + value: props.value, + }); + const formatValue = + value === undefined || value === null ? '' : String(value); + + // ====================== Count ======================= + const countConfig = useCount(count, showCount); + const mergedMax = countConfig.max || maxLength; + const valueLength = countConfig.strategy(formatValue); + + const isOutOfRange = !!mergedMax && valueLength > mergedMax; + + // ======================= Ref ======================== useImperativeHandle(ref, () => ({ focus, blur: () => { @@ -74,15 +85,40 @@ const Input = forwardRef((props, ref) => { setFocused((prev) => (prev && disabled ? false : prev)); }, [disabled]); - const handleChange = (e: React.ChangeEvent) => { - if (props.value === undefined) { - setValue(e.target.value); + const triggerChange = ( + e: + | React.ChangeEvent + | React.CompositionEvent, + currentValue: string, + ) => { + let cutValue = currentValue; + + if ( + !compositionRef.current && + countConfig.exceedFormatter && + countConfig.max && + countConfig.strategy(currentValue) > countConfig.max + ) { + cutValue = countConfig.exceedFormatter(currentValue, { + max: countConfig.max, + }); } + setValue(cutValue); + if (inputRef.current) { - resolveOnChange(inputRef.current, e, onChange); + resolveOnChange(inputRef.current, e, onChange, cutValue); } }; + const onInternalChange: React.ChangeEventHandler = (e) => { + triggerChange(e, e.target.value); + }; + + const onCompositionEnd = (e: React.CompositionEvent) => { + compositionRef.current = false; + triggerChange(e, e.currentTarget.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (onPressEnter && e.key === 'Enter') { onPressEnter(e); @@ -126,6 +162,7 @@ const Input = forwardRef((props, ref) => { // specify either the value prop, or the defaultValue prop, but not both. 'defaultValue', 'showCount', + 'count', 'classes', 'htmlSize', 'styles', @@ -136,7 +173,7 @@ const Input = forwardRef((props, ref) => { ((props, ref) => { prefixCls, { [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-out-of-range`]: isOutOfRange, }, classNames?.input, )} @@ -151,25 +189,30 @@ const Input = forwardRef((props, ref) => { ref={inputRef} size={htmlSize} type={type} + onCompositionStart={() => { + compositionRef.current = true; + }} + onCompositionEnd={onCompositionEnd} /> ); }; const getSuffix = () => { // Max length value - const hasMaxLength = Number(maxLength) > 0; + const hasMaxLength = Number(mergedMax) > 0; - if (suffix || showCount) { - const val = fixControlledValue(value); - const valueLength = [...val].length; - const dataCount = - typeof showCount === 'object' - ? showCount.formatter({ value: val, count: valueLength, maxLength }) - : `${valueLength}${hasMaxLength ? ` / ${maxLength}` : ''}`; + if (suffix || countConfig.show) { + const dataCount = countConfig.showFormatter + ? countConfig.showFormatter({ + value: formatValue, + count: valueLength, + maxLength: mergedMax, + }) + : `${valueLength}${hasMaxLength ? ` / ${mergedMax}` : ''}`; return ( <> - {!!showCount && ( + {countConfig.show && ( ((props, ref) => { return null; }; + // ====================== Render ====================== return ( ((props, ref) => { className={className} inputElement={getInputElement()} handleReset={handleReset} - value={fixControlledValue(value)} + value={formatValue} focused={focused} triggerFocus={focus} suffix={getSuffix()} diff --git a/src/hooks/useCount.ts b/src/hooks/useCount.ts new file mode 100644 index 0000000..a2d4451 --- /dev/null +++ b/src/hooks/useCount.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; +import type { InputProps } from '..'; +import type { CountConfig, ShowCountFormatter } from '../interface'; + +type ForcedCountConfig = Omit & + Pick, 'strategy'> & { + show: boolean; + showFormatter?: ShowCountFormatter; + }; + +/** + * Cut `value` by the `count.max` prop. + */ +export function inCountRange(value: string, countConfig: ForcedCountConfig) { + if (!countConfig.max) { + return true; + } + + const count = countConfig.strategy(value); + return count <= countConfig.max; +} + +export default function useCount( + count?: CountConfig, + showCount?: InputProps['showCount'], +) { + return React.useMemo(() => { + let mergedConfig = count; + + if (!count) { + mergedConfig = { + show: + typeof showCount === 'object' && showCount.formatter + ? showCount.formatter + : !!showCount, + }; + } + + const { show, ...rest } = mergedConfig!; + + return { + ...rest, + show: !!show, + showFormatter: typeof show === 'function' ? show : undefined, + strategy: rest.strategy || ((value) => value.length), + }; + }, [count, showCount]); +} diff --git a/src/interface.ts b/src/interface.ts index f7dac5c..ecb4b7a 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -60,12 +60,23 @@ export interface BaseInputProps extends CommonInputProps { }; } -export interface ShowCountProps { - formatter: (args: { - value: string; - count: number; - maxLength?: number; - }) => ReactNode; +export type ShowCountFormatter = (args: { + value: string; + count: number; + maxLength?: number; +}) => ReactNode; + +export type ExceedFormatter = ( + value: string, + config: { max: number }, +) => string; + +export interface CountConfig { + max?: number; + strategy?: (value: string) => number; + show?: boolean | ShowCountFormatter; + /** Trigger when content larger than the `max` limitation */ + exceedFormatter?: ExceedFormatter; } export interface InputProps @@ -103,7 +114,12 @@ export interface InputProps string >; onPressEnter?: KeyboardEventHandler; - showCount?: boolean | ShowCountProps; + /** @deprecated Use `count` instead */ + showCount?: + | boolean + | { + formatter: ShowCountFormatter; + }; autoComplete?: string; htmlSize?: number; classNames?: CommonInputProps['classNames'] & { @@ -114,6 +130,7 @@ export interface InputProps input?: CSSProperties; count?: CSSProperties; }; + count?: CountConfig; } export interface InputRef { diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index 1e492ca..a1a9e30 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -1,5 +1,5 @@ -import type { BaseInputProps, InputProps } from '../interface'; import type React from 'react'; +import type { BaseInputProps, InputProps } from '../interface'; export function hasAddon(props: BaseInputProps | InputProps) { return !!(props.addonBefore || props.addonAfter); @@ -96,10 +96,3 @@ export function triggerFocus( } } } - -export function fixControlledValue(value: T) { - if (typeof value === 'undefined' || value === null) { - return ''; - } - return String(value); -} diff --git a/tests/__snapshots__/index.test.tsx.snap b/tests/__snapshots__/index.test.tsx.snap index 024b277..cb35b43 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -130,7 +130,7 @@ exports[`Input allowClear should change type when click 1`] = ` exports[`Input allowClear should change type when click 2`] = `
[ + ...new (Intl as any).Segmenter().segment(val), +]; + +describe('Input.Count', () => { + it('basic emoji take length', () => { + const { container } = render(); + expect( + container.querySelector('.rc-input-show-count-suffix')?.textContent, + ).toEqual('11'); + }); + + it('strategy', () => { + const { container } = render( + getSegments(val).length }} + value="๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + />, + ); + expect( + container.querySelector('.rc-input-show-count-suffix')?.textContent, + ).toEqual('1'); + }); + + it('exceed style', () => { + const { container } = render( + , + ); + expect(container.querySelector('.rc-input-out-of-range')).toBeTruthy(); + }); + + it('show formatter', () => { + const { container } = render( + + `${value}_${count}_${maxLength}`, + }} + value="๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + maxLength={5} + />, + ); + expect( + container.querySelector('.rc-input-show-count-suffix')?.textContent, + ).toEqual('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ_11_5'); + }); + + it('exceedFormatter', () => { + const { container } = render( + + getSegments(val) + .filter((seg) => seg.index + seg.segment.length <= max) + .map((seg) => seg.segment) + .join(''), + }} + />, + ); + + // Allow input + fireEvent.compositionStart(container.querySelector('input')!); + fireEvent.change(container.querySelector('input')!, { + target: { + value: '๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ', + }, + }); + expect(container.querySelector('input')?.value).toEqual('๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ'); + + // Fallback + fireEvent.compositionEnd(container.querySelector('input')!); + expect(container.querySelector('input')?.value).toEqual('๐Ÿ”ฅ'); + }); +}); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 060c608..44a7bfa 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -102,35 +102,6 @@ describe('should support showCount', () => { ).toBe('8 / 5'); }); - describe('emoji', () => { - it('should minimize value between emoji length and maxLength', () => { - const { container } = render( - , - ); - expect(container.querySelector('input')?.value).toBe('๐Ÿ‘€'); - expect( - container.querySelector('.rc-input-show-count-suffix')?.innerHTML, - ).toBe('1 / 1'); - - const { container: container1 } = render( - , - ); - expect( - container1.querySelector('.rc-input-show-count-suffix')?.innerHTML, - ).toBe('1 / 2'); - }); - - it('slice emoji', () => { - const { container } = render( - , - ); - expect(container.querySelector('input')?.value).toBe('1234๐Ÿ˜‚'); - expect( - container.querySelector('.rc-input-show-count-suffix')?.innerHTML, - ).toBe('5 / 5'); - }); - }); - it('count formatter', () => { const { container } = render(