Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/inputs improvement #194

Merged
merged 3 commits into from
Jul 8, 2024
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
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@
"@react-stately/numberfield": "^3.9.3",
"@swc/helpers": "^0.5.11",
"@types/animejs": "^3.1.12",
"@types/node": "^20.14.6",
"@types/node": "^20.14.9",
"@types/react": "latest",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vanilla-extract/dynamic": "^2.1.1",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/babel-preset-app": "^5.0.8",
Expand All @@ -74,7 +74,7 @@
"glob": "^10.4.2",
"gluegun": "^5.1.6",
"husky": "^9.0.11",
"lerna": "^8.1.3",
"lerna": "^8.1.5",
"lerna-changelog": "^2.2.0",
"listr2": "^5.0.8",
"lodash": "^4.17.21",
Expand All @@ -89,24 +89,24 @@
"replace": "^1.2.2",
"replace-in-file": "^6.3.5",
"rimraf": "^5.0.7",
"type-fest": "^4.20.1",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"type-fest": "^4.21.0",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vue": "^3.4.21",
"vue-tsc": "^2.0.21"
"vue-tsc": "^2.0.26"
},
"dependencies": {
"@floating-ui/core": "^1.6.2",
"@floating-ui/dom": "^1.6.5",
"@floating-ui/core": "^1.6.4",
"@floating-ui/dom": "^1.6.7",
"@vanilla-extract/css": "^1.15.3",
"@vanilla-extract/css-utils": "^0.1.4",
"@vanilla-extract/recipes": "^0.5.3",
"animejs": "^3.2.2",
"bignumber.js": "^9.1.2",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"vue": "^3.4.29",
"zustand": "^4.5.2"
"vue": "^3.4.31",
"zustand": "^4.5.4"
},
"changelog": {
"labels": {
Expand Down
44 changes: 22 additions & 22 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@
"packageExports": true
},
"dependencies": {
"@floating-ui/core": "^1.6.2",
"@floating-ui/dom": "^1.6.5",
"@floating-ui/react": "^0.26.17",
"@floating-ui/react-dom": "^2.1.0",
"@floating-ui/utils": "^0.2.2",
"@floating-ui/core": "^1.6.4",
"@floating-ui/dom": "^1.6.7",
"@floating-ui/react": "^0.26.19",
"@floating-ui/react-dom": "^2.1.1",
"@floating-ui/utils": "^0.2.4",
"@formkit/auto-animate": "^0.8.2",
"@react-aria/listbox": "^3.12.1",
"@react-aria/overlays": "^3.22.1",
Expand All @@ -75,7 +75,7 @@
"rainbow-sprinkles": "^0.17.2",
"react-aria": "^3.33.1",
"react-stately": "^3.31.1",
"zustand": "^4.5.2"
"zustand": "^4.5.4"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
Expand Down Expand Up @@ -111,11 +111,11 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.15.3",
"@chain-registry/client": "^1.48.3",
"@chain-registry/osmosis": "^1.62.3",
"@chain-registry/client": "^1.48.16",
"@chain-registry/osmosis": "^1.62.16",
"@chain-registry/types": "^0.18.19",
"@chain-registry/utils": "^1.46.3",
"@chromatic-com/storybook": "^1.5.0",
"@chain-registry/utils": "^1.46.16",
"@chromatic-com/storybook": "^1.6.1",
"@parcel/config-default": "^2.12.0",
"@parcel/core": "^2.12.0",
"@parcel/optimizer-swc": "^2.12.0",
Expand All @@ -128,26 +128,26 @@
"@parcel/transformer-typescript-tsc": "^2.12.0",
"@parcel/transformer-typescript-types": "^2.12.0",
"@react-types/combobox": "^3.11.1",
"@storybook/addon-essentials": "^8.1.10",
"@storybook/addon-interactions": "^8.1.10",
"@storybook/addon-links": "^8.1.10",
"@storybook/addon-viewport": "^8.1.10",
"@storybook/blocks": "^8.1.10",
"@storybook/react": "^8.1.10",
"@storybook/react-vite": "^8.1.10",
"@storybook/test": "^8.1.10",
"@storybook/addon-essentials": "^8.1.11",
"@storybook/addon-interactions": "^8.1.11",
"@storybook/addon-links": "^8.1.11",
"@storybook/addon-viewport": "^8.1.11",
"@storybook/blocks": "^8.1.11",
"@storybook/react": "^8.1.11",
"@storybook/react-vite": "^8.1.11",
"@storybook/test": "^8.1.11",
"@types/react": "latest",
"@vanilla-extract/parcel-transformer": "^1.0.6",
"@vanilla-extract/vite-plugin": "^4.0.11",
"@vanilla-extract/parcel-transformer": "^1.0.7",
"@vanilla-extract/vite-plugin": "^4.0.13",
"@vitejs/plugin-react": "^4.3.1",
"match-sorter": "^6.3.4",
"parcel": "^2.12.0",
"parcel-optimizer-unlink-css": "link:../parcel-optimizer-unlink-css",
"parcel-resolver-ts-base-url": "^1.3.1",
"prop-types": "^15.8.1",
"storybook": "^8.1.10",
"storybook": "^8.1.11",
"storybook-react-rsbuild": "^0.0.5",
"type-fest": "^4.20.1",
"type-fest": "^4.21.0",
"vite-plugin-replace": "^0.1.1"
},
"gitHead": "bf66300d7fe3621b4de7ccbd4ef7593c8a84867d"
Expand Down
186 changes: 159 additions & 27 deletions packages/react/scaffolds/number-field/number-field.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useId, forwardRef } from "react";
import React, { useState, useEffect, useId, forwardRef, useMemo } from "react";
import { useNumberFieldState } from "react-stately";
import { useNumberField, useLocale } from "react-aria";
import { useNumberField, useLocale, AriaNumberFieldProps } from "react-aria";
import { mergeRefs } from "@react-aria/utils";

import clx from "clsx";
Expand All @@ -19,6 +19,20 @@ import useTheme from "../hooks/use-theme";
import * as styles from "./number-field.css";
import type { NumberInputProps } from "./number-field.types";

function usePrevious<T>(value: T): T {
const ref = React.useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}

const defaultFormatOptions: AriaNumberFieldProps["formatOptions"] = {
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 20,
};

const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(props, forwardedRef) => {
const {
Expand All @@ -27,10 +41,22 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
isDisabled,
size = "sm",
intent = "default",
clampValueOnBlur = true,
formatOptions = defaultFormatOptions,
} = props;

const { theme } = useTheme();
const { locale } = useLocale();
const [internalValue, setInternalValue] = useState<number | null>(
() => props.value ?? props.defaultValue ?? null,
);
const lastValidValue = usePrevious(internalValue);

const [strValue, setStrValue] = useState<string>(() =>
(props.value && props.defaultValue) == null
? ""
: String(props.value ?? props.defaultValue),
);
const [isFocused, setIsFocused] = useState<boolean>(false);

const state = useNumberFieldState({
Expand All @@ -41,6 +67,12 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
},
});

useEffect(() => {
if (props.value !== undefined) {
setInternalValue(props.value);
}
}, [props.value]);

const inputRef = React.useRef(null);
const handleRef = mergeRefs(inputRef, forwardedRef);

Expand All @@ -50,29 +82,124 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
inputProps,
incrementButtonProps,
decrementButtonProps,
} = useNumberField(
{
...props,
onFocus(event) {
// Clears input if 0
if (Number(state.inputValue) === 0) {
state.setInputValue("");
}
} = useNumberField(props, state, inputRef);

const formatValue = (value: number | null): string => {
if (value === null) return "";
return new Intl.NumberFormat(locale, formatOptions).format(value);
};

const parseValue = (value: string): number | null => {
if (value === "") {
return null;
}

// Remove all non-numeric characters except decimal point and minus sign
const numericValue = value.replace(/[^\d.-]/g, "");
return parseFloat(numericValue);
};

const applyFormatting = (val: number) => {
if (inputRef.current) {
state.setInputValue(formatValue(val));
inputRef.current.value = formatValue(val);
}
};

const updateValue = (val: number) => {
setInternalValue(val);
setStrValue(formatValue(val));
state.setNumberValue(val);
props.onChange?.(val);
};

const getClampedValue = (val: number) => {
const {
minValue = Number.NEGATIVE_INFINITY,
maxValue = Number.POSITIVE_INFINITY,
} = props;

if (typeof val !== "number") {
val = 0;
}

props.onFocus?.(event);
},
onBlur(event) {
if (state.inputValue === "") {
// If not a number, reset to min value
state.setInputValue(`${state.minValue ?? "0"}`);
return Math.min(Math.max(val, minValue), maxValue);
};

const isNotNumeric = (valStr: string) => {
return isNaN(parseValue(valStr) ?? undefined);
};

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let newValue = internalValue;

// Default mode, fallback to react-aria logic
if (clampValueOnBlur) {
newValue = state.numberValue;
applyFormatting(newValue);
inputProps.onBlur?.(e);
return;
}

if (!clampValueOnBlur) {
// Snap back to the last valid numeric value
// if the input is empty or invalid
if (isNotNumeric(strValue)) {
newValue = getClampedValue(0);

if (newValue !== lastValidValue) {
updateValue(newValue);
}
applyFormatting(newValue);
inputProps.onBlur?.(e);
return;
} else {
newValue = getClampedValue(parseValue(strValue));
if (newValue !== lastValidValue) {
updateValue(newValue);
}
applyFormatting(newValue);
inputProps.onBlur?.(e);
}
}
};

props.onBlur?.(event);
},
},
state,
inputRef
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsedValue = parseValue(e.target.value);
const isNotNumeric = isNaN(parsedValue ?? undefined);

// String representation of the value, always update
setStrValue(e.target.value);

// If the value is incomplete/invalid, don't update the state
// wait til it's valid and update onBlur
if (isNotNumeric) {
return;
}

if (parsedValue == null) {
setInternalValue(props.minValue ?? 0);
} else {
setInternalValue(parsedValue);
}

state.setInputValue(formatValue(parsedValue));
state.setNumberValue(parsedValue);
props.onChange?.(parsedValue ?? props.minValue ?? 0);
};

const inputValue = useMemo(() => {
if (clampValueOnBlur) {
return state.inputValue;
} else if (internalValue !== null) {
if (isNotNumeric(strValue)) {
return strValue;
}
return formatValue(internalValue);
} else {
return strValue;
}
}, [state.inputValue, formatValue, internalValue, strValue]);

return (
<Box className={props.className} {...props.attributes}>
Expand All @@ -87,7 +214,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
props.isDisabled
? inputRootIntent.disabled
: inputRootIntent[props.intent],
props.inputContainer
props.inputContainer,
)}
>
{props.canDecrement && React.isValidElement(props.decrementButton)
Expand All @@ -96,8 +223,13 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(

<Box
as="input"
attributes={inputProps}
ref={handleRef}
attributes={{
...inputProps,
value: inputValue,
onChange: clampValueOnBlur ? inputProps.onChange : handleChange,
onBlur: clampValueOnBlur ? inputProps.onBlur : handleBlur,
}}
boxRef={handleRef}
textAlign={props.textAlign}
fontSize={props.fontSize}
className={clx(
Expand All @@ -111,7 +243,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
isDisabled && props.incrementButton
? styles.withIncrementButton
: null,
props.borderless ? styles.borderless : null
props.borderless ? styles.borderless : null,
)}
/>

Expand All @@ -122,7 +254,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
</Stack>
</Box>
);
}
},
);

export default NumberInput;
Loading
Loading