From 311ab35dc5008f86d951ca0d2c23e3568804e8c6 Mon Sep 17 00:00:00 2001 From: AruSeito Date: Tue, 6 Feb 2024 11:05:37 +0800 Subject: [PATCH 1/2] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index c6810e0c1..0d63a9c56 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ ![cover](./.github/assets/images/design-cover.png) [![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://discord.gg/illacloud) -[![Follow on Twitter](https://img.shields.io/badge/Twitter-1DA1F2?logo=twitter&logoColor=white)](https://twitter.com/illacloudHQ) [![storybook](./.github/assets/images/storybook.svg)](https://design.illafamily.com) [![codecov](https://codecov.io/gh/illacloud/illa-design/branch/main/graph/badge.svg?token=GR2SOLBWQN)](https://codecov.io/gh/illacloud/illa-design) [![license](https://img.shields.io/github/license/illacloud/illa-design)](./LICENSE) From 3e1775f5a2a1c797dd227b805eff780b7a47aec1 Mon Sep 17 00:00:00 2001 From: AruSeito Date: Sun, 18 Feb 2024 15:51:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=F0=9F=90=9B=20input=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../input-number/input-number.stories.tsx | 4 +- packages/input-number/src/Decimal.ts | 261 ++++++++++++++++ packages/input-number/src/input-number.tsx | 278 +++++++++--------- packages/input-number/src/interface.ts | 5 +- packages/input-number/src/utils.ts | 122 ++++++++ 5 files changed, 530 insertions(+), 140 deletions(-) create mode 100644 packages/input-number/src/Decimal.ts create mode 100644 packages/input-number/src/utils.ts diff --git a/apps/storybook/stories/input-number/input-number.stories.tsx b/apps/storybook/stories/input-number/input-number.stories.tsx index 10ca4bce6..42f9a04c6 100644 --- a/apps/storybook/stories/input-number/input-number.stories.tsx +++ b/apps/storybook/stories/input-number/input-number.stories.tsx @@ -28,9 +28,11 @@ export const Basic: StoryFn = (props) => { /> { + console.log("v", v) setCurrentValue(v) }} mode="button" diff --git a/packages/input-number/src/Decimal.ts b/packages/input-number/src/Decimal.ts new file mode 100644 index 000000000..966ceeee4 --- /dev/null +++ b/packages/input-number/src/Decimal.ts @@ -0,0 +1,261 @@ +import { + toSafeString, + trimNumber, + validateNumber, + getNumberPrecision, + supportBigInt, +} from "./utils" + +export class BigIntDecimal { + readonly isEmpty: boolean = false + + readonly isNaN: boolean = false + + private readonly isNegative: boolean = false + + private readonly origin: string = "" + + private readonly integer!: bigint + + private readonly decimal!: bigint + + private readonly decimalLen!: number + + constructor(value: string | number) { + this.origin = String(value) + + if ((!value && value !== 0) || !this.origin.trim()) { + this.isEmpty = true + return + } + + if (value === "-") { + this.isNaN = true + return + } + + const safeValueString = toSafeString(value) + if (validateNumber(safeValueString)) { + const { negative, trimStr } = trimNumber(safeValueString) + const [integerStr, decimalStr = "0"] = trimStr.split(".") + this.isNegative = negative + this.integer = BigInt(integerStr) + this.decimal = BigInt(decimalStr) + this.decimalLen = decimalStr.length + } else { + this.isNaN = true + } + } + + get isInvalid() { + return this.isEmpty || this.isNaN + } + + private getMark() { + return this.isNegative ? "-" : "" + } + + private getIntegerStr() { + return this.integer.toString() + } + + private getDecimalStr() { + return this.decimal.toString().padStart(this.decimalLen, "0") + } + + private alignDecimal(decimalLength: number): bigint { + return BigInt( + `${this.getMark()}${this.getIntegerStr()}${this.getDecimalStr().padEnd( + decimalLength, + "0", + )}`, + ) + } + + negate() { + const numStr = this.toString() + return new BigIntDecimal( + numStr.startsWith("-") ? numStr.slice(1) : `-${numStr}`, + ) + } + + add(value: string | number): BigIntDecimal { + const offset = new BigIntDecimal(value) + + if (offset.isInvalid) { + return this + } + + if (this.isInvalid) { + return offset + } + + const maxDecimalLength = Math.max(this.decimalLen, offset.decimalLen) + const thisAlignedDecimal = this.alignDecimal(maxDecimalLength) + const offsetAlignedDecimal = offset.alignDecimal(maxDecimalLength) + const valueStr = (thisAlignedDecimal + offsetAlignedDecimal).toString() + const { negativeStr, trimStr } = trimNumber(valueStr) + const hydrateValueStr = `${negativeStr}${trimStr.padStart( + maxDecimalLength + 1, + "0", + )}` + + return new BigIntDecimal( + `${hydrateValueStr.slice(0, -maxDecimalLength)}.${hydrateValueStr.slice( + -maxDecimalLength, + )}`, + ) + } + + equals(target: BigIntDecimal) { + return this.toString() === target?.toString() + } + + less(target: BigIntDecimal) { + return this.isInvalid || target.isInvalid + ? false + : this.add(target.negate().toString()).toNumber() < 0 + } + + toNumber(): number { + return this.isNaN ? NaN : Number(this.toString()) + } + + toString( + options: { safe: boolean; precision?: number } = { safe: true }, + ): string { + const { safe, precision } = options + const result = safe + ? this.isInvalid + ? "" + : trimNumber( + `${this.getMark()}${this.getIntegerStr()}.${this.getDecimalStr()}`, + ).fullStr + : this.origin + return typeof precision === "number" ? toFixed(result, precision) : result + } +} + +export class NumberDecimal { + readonly isEmpty: boolean = false + + readonly isNaN: boolean = false + + private readonly origin: string = "" + + private readonly number: number + + constructor(value: string | number) { + this.origin = String(value) + this.number = Number(value) + + if ((!value && value !== 0) || !this.origin.trim()) { + this.isEmpty = true + } else { + this.isNaN = Number.isNaN(this.number) + } + } + + get isInvalid() { + return this.isEmpty || this.isNaN + } + + negate() { + return new NumberDecimal(-this.toNumber()) + } + + equals(target: NumberDecimal) { + return this.toNumber() === target?.toNumber() + } + + less(target: NumberDecimal) { + return this.isInvalid || target.isInvalid + ? false + : this.add(target.negate().toString()).toNumber() < 0 + } + + add(value: string | number): NumberDecimal { + const offset = new NumberDecimal(value) + + if (offset.isInvalid) { + return this + } + + if (this.isInvalid) { + return offset + } + + const result = this.number + offset.number + if (result > Number.MAX_SAFE_INTEGER) { + return new NumberDecimal(Number.MAX_SAFE_INTEGER) + } + + if (result < Number.MIN_SAFE_INTEGER) { + return new NumberDecimal(Number.MIN_SAFE_INTEGER) + } + + const maxPrecision = Math.max( + getNumberPrecision(this.number), + getNumberPrecision(offset.number), + ) + return new NumberDecimal(result.toFixed(maxPrecision)) + } + + toNumber() { + return this.number + } + + toString(options: { safe: boolean; precision?: number } = { safe: true }) { + const { safe, precision } = options + const result = safe + ? this.isInvalid + ? "" + : toSafeString(this.number) + : this.origin + return typeof precision === "number" ? toFixed(result, precision) : result + } +} + +export function getDecimal(value: string | number) { + return supportBigInt() ? new BigIntDecimal(value) : new NumberDecimal(value) +} + +/** + * Replace String.prototype.toFixed like Math.round + * If cutOnly is true, just slice the tail + * e.g. Decimal.toFixed(0.15) will return 0.2, not 0.1 + */ +export function toFixed( + numStr: string, + precision?: number, + cutOnly = false, +): string { + if (numStr === "") { + return "" + } + + const separator = "." + const { negativeStr, integerStr, decimalStr } = trimNumber(numStr) + const precisionDecimalStr = `${separator}${decimalStr}` + const numberWithoutDecimal = `${negativeStr}${integerStr}` + + if (precision && precision >= 0) { + const advancedNum = Number(decimalStr[precision]) + if (advancedNum >= 5 && !cutOnly) { + const advancedDecimal = getDecimal(numStr).add( + `${negativeStr}0.${"0".repeat(precision)}${10 - advancedNum}`, + ) + return toFixed(advancedDecimal.toString(), precision, cutOnly) + } + + return precision === 0 + ? numberWithoutDecimal + : `${numberWithoutDecimal}${separator}${decimalStr + .padEnd(precision, "0") + .slice(0, precision)}` + } + + return `${numberWithoutDecimal}${ + precisionDecimalStr === ".0" ? "" : precisionDecimalStr + }` +} diff --git a/packages/input-number/src/input-number.tsx b/packages/input-number/src/input-number.tsx index 7bb9683dc..648105037 100644 --- a/packages/input-number/src/input-number.tsx +++ b/packages/input-number/src/input-number.tsx @@ -1,15 +1,24 @@ -import { forwardRef, MutableRefObject, useCallback, useRef } from "react" +import { + forwardRef, + MutableRefObject, + useCallback, + useMemo, + useRef, + useState, + FocusEvent, +} from "react" import { InputNumberProps } from "./interface" import { Input } from "@illa-design/input" import { DownIcon, MinusIcon, PlusIcon, UpIcon } from "@illa-design/icon" import { Space } from "@illa-design/space" -import { isNumber, mergeRefs, useMergeValue } from "@illa-design/system" +import { isNumber, mergeRefs } from "@illa-design/system" import { applyActionIconStyle, applyControlBlockStyle, controlContainerStyle, hoverControlStyle, } from "./style" +import { BigIntDecimal, getDecimal, NumberDecimal } from "./Decimal" export const InputNumber = forwardRef( (props, ref) => { @@ -31,165 +40,171 @@ export const InputNumber = forwardRef( prefix, suffix, defaultValue, - value, icons, inputRef, formatter, parser, onChange, + onInput, ...otherProps } = props - const [finalValue, setFinalValue] = useMergeValue("", { - value, - defaultValue, + const mergedPrecision = (() => { + if (isNumber(precision)) { + const decimal = `${step}`.split(".")[1] + const stepPrecision = (decimal && decimal.length) || 0 + return Math.max(stepPrecision, precision) + } + return null + })() + + const [innerValue, setInnerValue] = useState(() => { + return getDecimal( + "value" in props + ? props.value! + : "defaultValue" in props + ? defaultValue! + : "", + ) }) + const [isUserTyping, setIsUserTyping] = useState(false) + + const [inputValue, setInputValue] = useState("") + + const value = useMemo(() => { + return "value" in props ? getDecimal(props.value!) : innerValue + }, [props, innerValue]) + + const [maxDecimal, minDecimal] = useMemo(() => { + return [getDecimal(max), getDecimal(min)] + }, [max, min]) + + const setValue = useCallback( + (newValue: BigIntDecimal | NumberDecimal) => { + setInnerValue(newValue) + // @ts-ignore + if (!newValue.equals(value) && onChange) { + const newValueStr = newValue.toString({ + safe: true, + precision: mergedPrecision ?? undefined, + }) + onChange( + newValue.isEmpty + ? undefined + : newValue.isNaN + ? NaN + : Number(newValueStr), + ) + } + }, + [mergedPrecision, onChange, value], + ) + + const getLegalValue = useCallback< + (value: BigIntDecimal | NumberDecimal) => BigIntDecimal | NumberDecimal + >( + (changedValue) => { + let finalValue = changedValue + + // @ts-ignore + if (finalValue.less(minDecimal)) { + finalValue = minDecimal + // @ts-ignore + } else if (maxDecimal.less(finalValue)) { + finalValue = maxDecimal + } + + return finalValue + }, + [minDecimal, maxDecimal], + ) + const currentInputRef = useRef() as MutableRefObject const plusStep = useCallback((): void => { - const currentNumber = Number(finalValue) - - if (!isNumber(currentNumber)) { - if (0 <= max && 0 >= min) { - if (value === undefined) { - setFinalValue(0) - } - onChange?.(0) - } else { - if (value === undefined) { - setFinalValue(min) - } - onChange?.(min) - } - return - } + const finalValue = value.isInvalid + ? getDecimal(min === -Infinity || (min <= 0 && max >= 0) ? 0 : min) + : value.add(step) + + setValue(getLegalValue(finalValue)) + currentInputRef.current && currentInputRef.current.focus() + }, [getLegalValue, max, min, setValue, step, value]) - if (currentNumber + step <= max && currentNumber + step >= min) { - if (value === undefined) { - setFinalValue(currentNumber + step) - } - onChange?.(currentNumber + step) - } - }, [finalValue, max, min, onChange, setFinalValue, step, value]) const minusStep = useCallback((): void => { - const currentNumber = Number(finalValue) - - if (!isNumber(currentNumber)) { - if (0 <= max && 0 >= min) { - if (value === undefined) { - setFinalValue(0) - } - onChange?.(0) - } else { - if (value === undefined) { - setFinalValue(min) - } - onChange?.(min) - } - return - } + const finalValue = value.isInvalid + ? getDecimal(min === -Infinity || (min <= 0 && max >= 0) ? 0 : min) + : value.add(-step) - if (currentNumber - step <= max && currentNumber - step >= min) { - if (value === undefined) { - setFinalValue(currentNumber - step) - } - onChange?.(currentNumber - step) - } - }, [finalValue, max, min, onChange, setFinalValue, step, value]) + setValue(getLegalValue(finalValue)) + currentInputRef.current && currentInputRef.current.focus() + }, [getLegalValue, max, min, setValue, step, value]) const control = (
-
{ - currentInputRef.current.focus() - plusStep() - }} - > +
{icons?.up ?? }
-
{ - currentInputRef.current.focus() - minusStep() - }} - > +
{icons?.down ?? }
) - const dealNumber = useCallback( - (num: string | number) => { - if (!isNumber(Number(num))) { - if (0 <= max && 0 >= min) { - return 0 - } else { - return min - } - } - if (precision !== undefined) { - let formatNum = Number(Number(num).toFixed(precision)) - formatNum = Math.max(formatNum, min) - formatNum = Math.min(formatNum, max) - return formatNum - } else { - let formatNum = Number(num) - formatNum = Math.max(formatNum, min) - formatNum = Math.min(formatNum, max) - return formatNum - } - }, - [max, min, precision], - ) + const handleOnChange = (v: string) => { + setIsUserTyping(true) + const rawText = v.trim().replace(/。/g, ".") + const parsedValue = parser ? parser(rawText) : rawText + if ( + isNumber(+parsedValue) || + parsedValue === "-" || + !parsedValue || + parsedValue === "." + ) { + setInputValue(rawText) + setValue(getLegalValue(getDecimal(parsedValue))) + } + } + + const displayedInputValue = useMemo(() => { + let _value: string + if (isUserTyping) { + _value = parser ? `${parser(inputValue)}` : inputValue + } else if (isNumber(mergedPrecision)) { + _value = value.toString({ safe: true, precision: mergedPrecision }) + } else if (value.isInvalid) { + _value = "" + } else { + _value = value.toString() + } + + return formatter ? `${formatter(_value)}` : _value + }, [value, inputValue, isUserTyping, mergedPrecision, parser, formatter]) + + const handleOnBlur = (e: FocusEvent) => { + setValue(getLegalValue(value)) + setIsUserTyping(false) + onBlur?.(e) + } return ( { - const rawValue = parser ? parser(e) : e - if (isNumber(Number(rawValue))) { - if (value === undefined) { - setFinalValue(formatter ? formatter(rawValue) : rawValue) - } - } else { - if (value === undefined) { - setFinalValue(e) - } - } - onChange?.(dealNumber(rawValue)) - }} - onPressEnter={() => { - const rawValue = parser - ? parser(currentInputRef.current.value) - : currentInputRef.current.value - - const dealNum = dealNumber(rawValue) - - if (value === undefined) { - setFinalValue(formatter ? formatter(dealNum) : dealNum) - } - onChange?.(dealNum) - }} - onBlur={(e) => { - const rawValue = parser ? parser(e.target.value) : e.target.value - const dealNum = dealNumber(rawValue) - if (value === undefined) { - setFinalValue(formatter ? formatter(dealNum) : dealNum) - } - onChange?.(dealNum) - onBlur?.(e) - }} + value={displayedInputValue} + onChange={handleOnChange} + onBlur={handleOnBlur} onFocus={(e) => { + setInputValue(currentInputRef.current?.value) onFocus?.(e) }} + onPressEnter={() => { + currentInputRef.current && currentInputRef.current.blur() + }} readOnly={readOnly} placeholder={placeholder} prefix={prefix} @@ -205,24 +220,14 @@ export const InputNumber = forwardRef( } addBefore={ mode === "button" ? ( - { - minusStep() - }} - > + {icons?.minus ?? } ) : undefined } addAfter={ mode === "button" ? ( - { - plusStep() - }} - > + {icons?.plus ?? } ) : undefined @@ -230,7 +235,6 @@ export const InputNumber = forwardRef( colorScheme={colorScheme} disabled={disabled} error={error} - {...otherProps} /> ) }, diff --git a/packages/input-number/src/interface.ts b/packages/input-number/src/interface.ts index b19446458..157933524 100644 --- a/packages/input-number/src/interface.ts +++ b/packages/input-number/src/interface.ts @@ -22,7 +22,7 @@ export type InputNumberColorScheme = export interface InputNumberProps extends Omit< InputHTMLAttributes, - "prefix" | "size" | "onChange" | "value" | "defaultValue" + "prefix" | "size" | "onChange" | "value" | "defaultValue" | "onInput" >, BoxProps { size?: InputNumberSize @@ -49,7 +49,8 @@ export interface InputNumberProps } inputRef?: Ref onChange?: (value: number | undefined) => void + onInput?: (value: string) => void onKeyDown?: (e: SyntheticEvent) => void parser?: (value: number | string) => number - formatter?: (value: number | string) => string | number + formatter?: (value: number | string) => string } diff --git a/packages/input-number/src/utils.ts b/packages/input-number/src/utils.ts new file mode 100644 index 000000000..5bd319e4b --- /dev/null +++ b/packages/input-number/src/utils.ts @@ -0,0 +1,122 @@ +/** + * Judge whether a number is scientific notation + */ +export function isE(number: string | number) { + return !Number.isNaN(Number(number)) && String(number).includes("e") +} + +/** + * Judge whether BigInt is supported by current env + */ +export function supportBigInt() { + return typeof BigInt === "function" +} + +/** + * Get precision of a number, include scientific notation like 1e-10 + */ +export function getNumberPrecision(number: string | number) { + const numStr: string = String(number) + + if (isE(number)) { + let precision = Number(numStr.slice(numStr.indexOf("e-") + 2)) + numStr.replace(/\.(\d+)/, (_, $1) => { + precision += $1.length + return _ + }) + return precision + } + + return numStr.includes(".") && validateNumber(numStr) + ? numStr.length - numStr.indexOf(".") - 1 + : 0 +} + +/** + * Convert number to non-scientific notation + */ +export function toSafeString(number: number | string): string { + let nativeNumberStr: string = String(number) + + if (isE(number)) { + // @ts-ignore + if (number < Number.MIN_SAFE_INTEGER) { + return supportBigInt() + ? BigInt(number).toString() + : Number.MIN_SAFE_INTEGER.toString() + } + + // @ts-ignore + if (number > Number.MAX_SAFE_INTEGER) { + return supportBigInt() + ? BigInt(number).toString() + : Number.MAX_SAFE_INTEGER.toString() + } + + // This may lose precision, but foFixed must accept argument in the range 0-100 + const precision = getNumberPrecision(nativeNumberStr) + nativeNumberStr = Number(number).toFixed(Math.min(100, precision)) + } + + return trimNumber(nativeNumberStr).fullStr +} + +/** + * Judge whether a number is valid + */ +export function validateNumber(num: string | number) { + if (typeof num === "number") { + return !Number.isNaN(num) + } + + if (!num) { + return false + } + + return ( + // 1.1 + /^\s*-?\d+(\.\d+)?\s*$/.test(num) || + // 1. + /^\s*-?\d+\.\s*$/.test(num) || + // .1 + /^\s*-?\.\d+\s*$/.test(num) + ) +} + +export function trimNumber(numStr: string) { + let str = numStr.trim() + let negative = false + + str = str + // Remove negative-label(-) at head. + .replace(/^-/, () => { + negative = true + return "" + }) + // Remove useless 0 at decimal end. `1.00100` => `1.001` + .replace(/(\.\d*[^0])0*$/, "$1") + // Remove useless decimal. + .replace(/\.0*$/, "") + // Remove useless 0 at head. + .replace(/^0+/, "") + // Add 0 before empty dot. `.1` => `0.1` + .replace(/^\./, "0.") + + const trimStr = str || "0" + const [integerStr = "0", decimalStr = "0"] = trimStr.split(".") + + if (integerStr === "0" && decimalStr === "0") { + negative = false + } + + const negativeStr = negative ? "-" : "" + + return { + negative, + negativeStr, + trimStr, + integerStr, + decimalStr, + fullStr: `${negativeStr}${trimStr}`, + } +}