Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.rc-input {
&-out-of-range {
color: red;
}

&-affix-wrapper {
padding: 2px 8px;
overflow: hidden;
Expand Down
63 changes: 61 additions & 2 deletions docs/examples/show-count.tsx
Original file line number Diff line number Diff line change
@@ -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 <Input prefixCls="rc-input" showCount />;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
alignItems: 'start',
}}
>
<h3 style={sharedHeadStyle}>Native</h3>
<Input prefixCls="rc-input" showCount defaultValue="👨‍👩‍👧‍👦" />
<Input prefixCls="rc-input" showCount defaultValue="👨‍👩‍👧‍👦" maxLength={20} />
<h3 style={sharedHeadStyle}>Count</h3>
<h4 style={sharedHeadStyle}>Only Max</h4>
<Input
placeholder="count.max"
prefixCls="rc-input"
defaultValue="🔥"
count={{
show: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个不应该默认是 true 吗,因为外层 count={{}} 是真值

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不是的,原本是 showCount 默认为真。现在改名叫 count 是做计数使用。可以配置 strategy + max 仅做限制而不展示数字。

max: 5,
}}
/>
<h4 style={sharedHeadStyle}>Customize strategy</h4>
<Input
placeholder="Emoji count 1"
prefixCls="rc-input"
defaultValue="🔥"
count={{
show: true,
max: 5,
strategy: (val) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

一定要用专业名词吗,可以简单点吗,比如 getCount

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

一定要用专业名词吗,可以简单点吗,比如 getCount

其实习惯了就好了 😂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

词不达意才更麻烦. 词越精确越好, 只不过我们非英语母语而已.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个和 @MadCcc 讨论下来,count 本身就是属性名里面再套一个 count 就重复了。所以复用了 Tree TreeSelect Cascader 的 strategy 的 API 名。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个可以参考 echarts,每个配置都有 formatter 自定义返回,对于图表或者组件,自定义方法的使用场景非常多,api 应该有个规范

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复用没问题,关键复用前第一次这个地方用的合不合适,比如 Table 的 dataIndex 对应的 render,这个就没定义成 strategy

按照 Naming Standard,render 系列命名需要带上 render 或为 xxxRender。其次,在固定规则外,复用现有命名 API,不额外创造。这个是符合我们的命名规范的。

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

另外, 自定义计数和 formatter 是没有强相关的。开发者可以配置为 max + strategy 仅做超出警示,而不需要去做裁剪。参考一下这个截图:
截屏2023-09-27 15 00 31

Copy link
Contributor

@crazyair crazyair Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感觉这个有问题啊,当 {max: 2} 用户只能输入2个字符,当 {max: 2, strategy:()=> 2},用户应该一个字符都不能输入

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

用户自己写了 2 那就认为当前输入的值长度为 2,自然是可以继续输入的。因为永远都不会超出长度。可以看一下 ant-design/ant-design#37733 过一下为什么要交给用户处理的上下文。

[...new (Intl as any).Segmenter().segment(val)].length,
}}
/>
<h4 style={sharedHeadStyle}>Customize exceedFormatter</h4>
<Input
placeholder="Emoji count 1"
prefixCls="rc-input"
defaultValue="🔥"
count={{
show: true,
max: 5,
exceedFormatter: (val, { max }) => {
Copy link
Contributor

@crazyair crazyair Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exceed 只体现了超出,没说明超出哪个,api 应该加上 max

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个感觉没必要,exceed 本来就是超出,能超出的自然只有 max

Copy link
Contributor

@crazyair crazyair Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个strategy会写到文档里,或者还会增加一个 demo,用户能认知strategy 可以解决什么问题,在这基础上,如果 api 名称为 getMax: (value) => number 则是 max 的自定义方法,一举两得。

如果要通过自定义方法的返回控制用户是否可以继续输入,或者右侧显示 1/2内容,都可以收成一个方法

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// 当超出 max 范围时处理逻辑,允许开发者额外定制裁剪逻辑。该方法会略过 composition 事件以防止被输入法卡住的情况。
exceedFormatter?: ExceedFormatter;

最早就是一个,后来发现有输入法收不起来,所以拆了一个裁剪方法。

composition 阶段,用户输入超出长度是正常的。裁剪只有在 composition 结束时才能处理。比如 你好 限制 2,在输入 nihao 的时候长度是 5 ,你不能拦住它输入。输入完后才能裁剪。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clone 本地测试了了下,体验来说没问题

const segments = [...new (Intl as any).Segmenter().segment(val)];

return segments
.filter((seg) => seg.index + seg.segment.length <= max)
.map((seg) => seg.segment)
.join('');
},
}}
/>
</div>
);
};

export default Demo;
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
90 changes: 67 additions & 23 deletions src/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputRef, InputProps>((props, ref) => {
const {
Expand All @@ -32,17 +29,16 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
maxLength,
suffix,
showCount,
count,
type = 'text',
classes,
classNames,
styles,
...rest
} = props;

const [value, setValue] = useMergedState(props.defaultValue, {
value: props.value,
});
const [focused, setFocused] = useState<boolean>(false);
const compositionRef = React.useRef(false);

const inputRef = useRef<HTMLInputElement>(null);

Expand All @@ -52,6 +48,21 @@ const Input = forwardRef<InputRef, InputProps>((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: () => {
Expand All @@ -74,15 +85,40 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
setFocused((prev) => (prev && disabled ? false : prev));
}, [disabled]);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (props.value === undefined) {
setValue(e.target.value);
const triggerChange = (
e:
| React.ChangeEvent<HTMLInputElement>
| React.CompositionEvent<HTMLInputElement>,
currentValue: string,
) => {
let cutValue = currentValue;

if (
!compositionRef.current &&
countConfig.exceedFormatter &&
countConfig.max &&
countConfig.strategy(currentValue) > countConfig.max
) {
cutValue = countConfig.exceedFormatter(currentValue, {
max: countConfig.max,
});
}
Comment on lines +96 to 105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这部分没有抽离到 useCount 吗

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCount 里是合成 countConfig 的,它本身不处理 change 逻辑~

setValue(cutValue);

if (inputRef.current) {
resolveOnChange(inputRef.current, e, onChange);
resolveOnChange(inputRef.current, e, onChange, cutValue);
}
};

const onInternalChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
triggerChange(e, e.target.value);
};

const onCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
compositionRef.current = false;
triggerChange(e, e.currentTarget.value);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (onPressEnter && e.key === 'Enter') {
onPressEnter(e);
Expand Down Expand Up @@ -126,6 +162,7 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
// specify either the value prop, or the defaultValue prop, but not both.
'defaultValue',
'showCount',
'count',
'classes',
'htmlSize',
'styles',
Expand All @@ -136,40 +173,46 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
<input
autoComplete={autoComplete}
{...otherProps}
onChange={handleChange}
onChange={onInternalChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={clsx(
prefixCls,
{
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-out-of-range`]: isOutOfRange,
},
classNames?.input,
)}
style={styles?.input}
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 && (
<span
className={clsx(
`${prefixCls}-show-count-suffix`,
Expand All @@ -192,14 +235,15 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
return null;
};

// ====================== Render ======================
return (
<BaseInput
{...rest}
prefixCls={prefixCls}
className={className}
inputElement={getInputElement()}
handleReset={handleReset}
value={fixControlledValue(value)}
value={formatValue}
focused={focused}
triggerFocus={focus}
suffix={getSuffix()}
Expand Down
48 changes: 48 additions & 0 deletions src/hooks/useCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from 'react';
import type { InputProps } from '..';
import type { CountConfig, ShowCountFormatter } from '../interface';

type ForcedCountConfig = Omit<CountConfig, 'show'> &
Pick<Required<CountConfig>, '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<ForcedCountConfig>(() => {
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]);
}
31 changes: 24 additions & 7 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,7 +114,12 @@ export interface InputProps
string
>;
onPressEnter?: KeyboardEventHandler<HTMLInputElement>;
showCount?: boolean | ShowCountProps;
/** @deprecated Use `count` instead */
showCount?:
| boolean
| {
formatter: ShowCountFormatter;
};
autoComplete?: string;
htmlSize?: number;
classNames?: CommonInputProps['classNames'] & {
Expand All @@ -114,6 +130,7 @@ export interface InputProps
input?: CSSProperties;
count?: CSSProperties;
};
count?: CountConfig;
}

export interface InputRef {
Expand Down
Loading