diff --git a/src/components/DatePicker.js b/src/components/DatePicker.js index 1e18a383..dc08ceea 100644 --- a/src/components/DatePicker.js +++ b/src/components/DatePicker.js @@ -122,9 +122,9 @@ function DatePicker(props) { const [auxId] = useState(() => `date-picker-aux-${nanoid()}`); const isEmpty = useCallback( (value) => - (day === false || value.day === "") && - value.month === "" && - value.year === "", + (day === false || value.day.trim() === "") && + value.month.trim() === "" && + value.year.trim() === "", [day] ); const data = useMemo( @@ -173,9 +173,10 @@ function DatePicker(props) { { const monthInput = screen.getByPlaceholderText("MM"); const yearInput = screen.getByPlaceholderText("YYYY"); - expect(dayInput).toHaveAttribute("type", "number"); - expect(monthInput).toHaveAttribute("type", "number"); - expect(yearInput).toHaveAttribute("type", "number"); + expect(dayInput).toHaveAttribute("inputmode", "numeric"); + expect(dayInput).toHaveAttribute("maxlength", "2"); + + expect(monthInput).toHaveAttribute("inputmode", "numeric"); + expect(monthInput).toHaveAttribute("maxlength", "2"); + + expect(yearInput).toHaveAttribute("inputmode", "numeric"); + expect(yearInput).toHaveAttribute("maxlength", "4"); }); it("doesn't render the day field when day={false}", () => { diff --git a/src/components/Form.test.js b/src/components/Form.test.js index d9404731..a567917d 100644 --- a/src/components/Form.test.js +++ b/src/components/Form.test.js @@ -136,10 +136,7 @@ describe("Form", () => { likeIceCream: ["Must be checked"], name: ["Required"], relationshipStatus: ["Please make a selection."], - salary: [ - "Please enter a valid amount.", - "Please select a frequency.", - ], + salary: ["Please enter an amount.", "Please select a frequency."], birthDate: [ "Day must be within 1-31.", "Month must be within 1-12.", diff --git a/src/components/Frequency.js b/src/components/Frequency.js index 407021df..5e2eabc0 100644 --- a/src/components/Frequency.js +++ b/src/components/Frequency.js @@ -32,7 +32,7 @@ const ALL_FREQUENCY_OPTIONS = [ }, ]; -const { COLORS } = InternalInput; +const { COLORS, NUMERIC_REGEX } = InternalInput; const MODES = ["radio-group", "select"]; function isFrequencySelected(frequency, frequencyPropsMap) { @@ -57,7 +57,11 @@ const DEFAULT_PROPS = { const errors = []; if (isInputEmpty(value.amount)) { - errors.push("Please enter a valid amount."); + errors.push("Please enter an amount."); + } + + if (NUMERIC_REGEX.test(value.amount) === false) { + errors.push("Amount can contain only digits."); } if (isFrequencyEmpty(value.frequency)) { @@ -86,6 +90,8 @@ function Frequency(props) { monthly: (monthly) => typeof monthly === "boolean", fortnightly: (fortnightly) => typeof fortnightly === "boolean", weekly: (weekly) => typeof weekly === "boolean", + amountPrefix: (amountPrefix) => + typeof amountPrefix === "string" && amountPrefix.length > 0, disabled: (disabled) => typeof disabled === "boolean", optional: (optional) => typeof optional === "boolean", } @@ -99,11 +105,12 @@ function Frequency(props) { monthly, fortnightly, weekly, - optional, + amountPrefix, amountPlaceholder, selectPlaceholder, helpText, disabled, + optional, validate, validateData, testId, @@ -132,7 +139,7 @@ function Frequency(props) { [frequencyPropsMap] ); const isInputEmpty = useCallback((amount) => { - return amount === ""; + return amount.trim() === ""; }, []); const isFrequencyEmpty = useCallback( (frequency) => { @@ -174,8 +181,9 @@ function Frequency(props) { { const amountInput = screen.getByPlaceholderText("0.00"); - expect(amountInput).toHaveAttribute("type", "number"); + expect(amountInput).toHaveAttribute("inputmode", "numeric"); expect(screen.getByText("Annually")).toBeInTheDocument(); expect(screen.getByText("Quarterly")).toBeInTheDocument(); @@ -82,7 +82,7 @@ describe("Frequency", () => { amountInput.focus(); amountInput.blur(); - await screen.findByText("Please enter a valid amount."); + await screen.findByText("Please enter an amount."); await screen.findByText("Please select a frequency."); await waitFor(() => { expect(screen.queryByText("Some help text")).not.toBeInTheDocument(); diff --git a/src/components/Input.js b/src/components/Input.js index 94b22af4..60966af6 100644 --- a/src/components/Input.js +++ b/src/components/Input.js @@ -6,24 +6,28 @@ import { mergeProps } from "../utils/component"; import Field from "./internal/Field"; import InternalInput from "./internal/InternalInput"; -const { TYPES, COLORS } = InternalInput; +const { VARIANTS, COLORS, NUMERIC_REGEX } = InternalInput; const DEFAULT_PROPS = { + variant: InternalInput.DEFAULT_PROPS.variant, color: InternalInput.DEFAULT_PROPS.color, - type: InternalInput.DEFAULT_PROPS.type, disabled: false, pasteAllowed: true, optional: false, - validate: (value, { isEmpty }) => { + validate: (value, { isEmpty, variant }) => { if (isEmpty(value)) { return "Required"; } + if (variant === "numeric" && NUMERIC_REGEX.test(value) === false) { + return "Only 0-9 are allowed"; + } + return null; }, }; -Input.TYPES = TYPES; +Input.VARIANTS = VARIANTS; Input.COLORS = COLORS; Input.DEFAULT_PROPS = DEFAULT_PROPS; @@ -33,17 +37,12 @@ function Input(props) { DEFAULT_PROPS, {}, { + variant: (variant) => VARIANTS.includes(variant), + numericPrefix: (numericPrefix) => + typeof numericPrefix === "string" && numericPrefix.length > 0, + numericSuffix: (numericSuffix) => + typeof numericSuffix === "string" && numericSuffix.length > 0, color: (color) => COLORS.includes(color), - type: (type) => TYPES.includes(type), - min: (min) => - props.type === "number" && - (typeof min === "number" || typeof min === "string"), - max: (max) => - props.type === "number" && - (typeof max === "number" || typeof max === "string"), - step: (step) => - props.type === "number" && - (typeof step === "number" || typeof step === "string"), disabled: (disabled) => typeof disabled === "boolean", pasteAllowed: (pasteAllowed) => typeof pasteAllowed === "boolean", optional: (optional) => typeof optional === "boolean", @@ -51,10 +50,9 @@ function Input(props) { ); const { name, - type, - min, - max, - step, + variant, + numericPrefix, + numericSuffix, label, placeholder, helpText, @@ -72,9 +70,10 @@ function Input(props) { const data = useMemo( () => ({ isEmpty, + variant, ...(validateData && { data: validateData }), }), - [isEmpty, validateData] + [isEmpty, variant, validateData] ); const { value, errors, hasErrors, onFocus, onBlur, onChange } = useField( "Input", @@ -101,10 +100,9 @@ function Input(props) { { const label = screen.getByText("First name"); const input = screen.getByLabelText("First name"); + const inputContainer = input.parentElement; expect(label.tagName).toBe("LABEL"); expect(input.tagName).toBe("INPUT"); @@ -30,31 +31,88 @@ describe("Input", () => { const inputId = input.getAttribute("id"); expect(inputId).toBeTruthy(); + expect(input).toHaveAttribute("type", "text"); expect(label).toHaveAttribute("for", inputId); - expect(input).toHaveStyle({ - boxSizing: "border-box", + expect(label).toHaveStyle({ + display: "flex", + fontFamily: "'Roboto',sans-serif", + fontSize: "16px", + fontWeight: "500", + lineHeight: "24px", + color: "#414141", + marginBottom: "8px", + }); + + expect(inputContainer).toHaveStyle({ fontSize: "16px", fontWeight: "300", lineHeight: "24px", fontFamily: "'Roboto',sans-serif", - padding: "0 16px", color: "#000000", + }); + + expect(input).toHaveStyle({ + boxSizing: "border-box", + fontSize: "inherit", + fontWeight: "inherit", + lineHeight: "inherit", + fontFamily: "inherit", + color: "inherit", + padding: "0px 16px 0px 16px", width: "100%", height: "48px", border: "0", margin: "0", backgroundColor: "#f2f2f2", }); + }); - expect(label).toHaveStyle({ - display: "flex", - fontFamily: "'Roboto',sans-serif", - fontSize: "16px", - fontWeight: "500", - lineHeight: "24px", - color: "#414141", - marginBottom: "8px", + it("numeric variant", () => { + render(); + + const input = screen.getByLabelText("New credit limit"); + + expect(input).toHaveAttribute("type", "text"); + expect(input).toHaveAttribute("inputmode", "numeric"); + expect(input).toHaveAttribute("pattern", "[0-9]*"); + }); + + it("with numericPrefix", () => { + render( + + ); + + const input = screen.getByLabelText("New credit limit"); + + expect(input).toHaveStyle({ + paddingTop: 0, + paddingBottom: 0, + paddingLeft: "calc(16px + 2ch)", + paddingRight: "16px", + }); + }); + + it("with numericSuffix", () => { + render( + + ); + + const input = screen.getByLabelText("New credit limit"); + + expect(input).toHaveStyle({ + paddingTop: 0, + paddingBottom: 0, + paddingLeft: "16px", + paddingRight: "calc(16px + 9ch)", }); }); diff --git a/src/components/TimeSpan.js b/src/components/TimeSpan.js index cf6b136b..c0e33829 100644 --- a/src/components/TimeSpan.js +++ b/src/components/TimeSpan.js @@ -105,7 +105,7 @@ function TimeSpan(props) { const [labelId] = useState(() => `time-span-${nanoid()}`); const [auxId] = useState(() => `time-span-aux-${nanoid()}`); const isEmpty = useCallback( - (value) => value.years === "" && value.months === "", + (value) => value.years.trim() === "" && value.months.trim() === "", [] ); const data = useMemo( @@ -151,8 +151,8 @@ function TimeSpan(props) { { const yearsInput = screen.getByPlaceholderText("Years"); const monthsInput = screen.getByPlaceholderText("Months"); - expect(yearsInput).toHaveAttribute("type", "number"); - expect(monthsInput).toHaveAttribute("type", "number"); + expect(yearsInput).toHaveAttribute("inputmode", "numeric"); + expect(monthsInput).toHaveAttribute("inputmode", "numeric"); }); it("renders default help text", () => { diff --git a/src/components/internal/InternalInput.js b/src/components/internal/InternalInput.js index 19d34d53..4f5076ed 100644 --- a/src/components/internal/InternalInput.js +++ b/src/components/internal/InternalInput.js @@ -4,11 +4,13 @@ import useTheme from "../../hooks/useTheme"; import useBackground from "../../hooks/useBackground"; import useResponsivePropsCSS from "../../hooks/useResponsivePropsCSS"; -const TYPES = ["text", "number"]; +const VARIANTS = ["text", "numeric"]; const COLORS = ["grey.t05", "white"]; +const NUMERIC_REGEX = /^\d*$/; + const DEFAULT_PROPS = { - type: "text", + variant: "text", color: "grey.t05", disabled: false, pasteAllowed: true, @@ -16,8 +18,9 @@ const DEFAULT_PROPS = { __internal__focus: false, }; -InternalInput.TYPES = TYPES; +InternalInput.VARIANTS = VARIANTS; InternalInput.COLORS = COLORS; +InternalInput.NUMERIC_REGEX = NUMERIC_REGEX; InternalInput.DEFAULT_PROPS = DEFAULT_PROPS; function InternalInput(_props) { @@ -27,10 +30,10 @@ function InternalInput(_props) { parentName, id, placeholder, - type, - min, - max, - step, + variant, + numericPrefix, + numericSuffix, + maxLength, disabled, pasteAllowed, isValid, @@ -59,41 +62,70 @@ function InternalInput(_props) { }, [pasteAllowed] ); + const hasPrefix = variant === "numeric" && numericPrefix; + const hasSuffix = variant === "numeric" && numericSuffix; return ( - + > + + ); } @@ -102,10 +134,10 @@ InternalInput.propTypes = { parentName: PropTypes.string, id: PropTypes.string, placeholder: PropTypes.string, - type: PropTypes.oneOf(TYPES), - min: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - step: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + variant: PropTypes.oneOf(VARIANTS), + numericPrefix: PropTypes.string, + numericSuffix: PropTypes.string, + maxLength: PropTypes.string, color: PropTypes.oneOf(COLORS), disabled: PropTypes.bool, pasteAllowed: PropTypes.bool, diff --git a/src/themes/default/input.js b/src/themes/default/input.js index ef5ddaec..f4688179 100644 --- a/src/themes/default/input.js +++ b/src/themes/default/input.js @@ -1,26 +1,31 @@ export default (theme) => ({ - input: { - boxSizing: "border-box", + inputContainer: { + position: "relative", fontSize: theme.fontSizes[1], fontWeight: theme.fontWeights.light, lineHeight: theme.lineHeights[2], fontFamily: theme.fonts.body, - padding: `0 ${theme.space[4]}`, color: theme.colors.black, + }, + input: { + boxSizing: "border-box", width: "100%", height: "48px", border: 0, margin: 0, - MozAppearance: "textfield", // Hides the input="number" spin buttons in Firefox + paddingTop: 0, + paddingBottom: 0, + fontSize: "inherit", + fontWeight: "inherit", + lineHeight: "inherit", + fontFamily: "inherit", + color: "inherit", }, "input:focus": { outline: 0, borderRadius: theme.radii[0], boxShadow: theme.shadows.focus, }, - "input.webkitSpinButton": { - display: "none", // Hides the input="number" spin buttons in Chrome - }, "input.default": { backgroundColor: theme.colors.grey.t05, }, diff --git a/website/src/components/kitchen-sink/Frequency.js b/website/src/components/kitchen-sink/Frequency.js index 1cdb016e..0694b851 100644 --- a/website/src/components/kitchen-sink/Frequency.js +++ b/website/src/components/kitchen-sink/Frequency.js @@ -7,6 +7,7 @@ import KitchenSinkForm from "./KitchenSinkForm"; function FormWithFrequency({ label, optional, + mode, annually, quarterly, monthly, @@ -16,6 +17,7 @@ function FormWithFrequency({ amount: "", frequency: "", }, + amountPrefix, amountPlaceholder, selectPlaceholder, disabled, @@ -30,11 +32,13 @@ function FormWithFrequency({ name="salary" label={label} optional={optional} + mode={mode} annually={annually} quarterly={quarterly} monthly={monthly} fortnightly={fortnightly} weekly={weekly} + amountPrefix={amountPrefix} amountPlaceholder={amountPlaceholder} selectPlaceholder={selectPlaceholder} disabled={disabled} @@ -56,6 +60,7 @@ FormWithFrequency.propTypes = { amount: PropTypes.string.isRequired, frequency: PropTypes.string.isRequired, }), + amountPrefix: PropTypes.string, amountPlaceholder: PropTypes.string, selectPlaceholder: PropTypes.string, disabled: PropTypes.bool, @@ -111,10 +116,22 @@ function KitchenSinkFrequency() { submitOnMount /> + + - + @@ -85,19 +91,40 @@ function KitchenSinkInput() { /> - + + + + + diff --git a/website/src/pages/components/input/index.js b/website/src/pages/components/input/index.js index 161c76f0..1bd2bf6a 100644 --- a/website/src/pages/components/input/index.js +++ b/website/src/pages/components/input/index.js @@ -8,9 +8,10 @@ import RadioGroupSetting, { import { formatCode, nonDefaultProps } from "../../../utils/formatting"; const { useTheme, Input } = allDesignSystem; -const { COLORS, DEFAULT_PROPS } = Input; +const { VARIANTS, COLORS, DEFAULT_PROPS } = Input; const scope = allDesignSystem; +const variantOptions = getRadioOptions(VARIANTS); const colorOptions = getRadioOptions(COLORS); const isOptionalOptions = getCheckboxOptions(); const hasPlaceholderOptions = getCheckboxOptions(); @@ -19,6 +20,7 @@ const isDisabledOptions = getCheckboxOptions(); function InputPage() { const theme = useTheme(); + const [variant, setVariant] = useState(DEFAULT_PROPS.variant); const [color, setColor] = useState(DEFAULT_PROPS.color); const [optional, setIsOptional] = useState(DEFAULT_PROPS.optional); const [hasPlaceholder, setHasPlaceholder] = useState( @@ -31,7 +33,7 @@ function InputPage() { const code = formatCode(` function App() { const initialValues = { - name: "" + ${variant === "numeric" ? "newCreditLimit" : "name"}: "" }; return ( @@ -39,8 +41,21 @@ function InputPage() { + ({ color: theme.colors.black, borderColor: theme.colors.black, }, - "input.webkitSpinButton": { - display: "none", // Hides the input="number" spin buttons in Chrome - }, "input.default": { color: theme.colors.grey.t65, backgroundColor: "transparent",