From e424884669e2ed1976a4e9604739351ba3c32d08 Mon Sep 17 00:00:00 2001 From: Achilleas Mitrotasios <58337217+mitrotasios@users.noreply.github.com> Date: Sat, 22 Jul 2023 21:25:10 -0700 Subject: [PATCH] feat: add number input and email/url types to text input (#575) (#577) number input Co-authored-by: RubensRafael Co-authored-by: Thiago Nascimbeni Co-authored-by: Rubens Rafael <70234898+RubensRafael@users.noreply.github.com> Co-authored-by: Nitesh Singh Co-authored-by: Severin Landolt Co-authored-by: gitstart curve type Co-authored-by: Thomas McInnis Co-authored-by: tryanmac <88759292+tryanmac@users.noreply.github.com> Co-authored-by: GitStart <1501599+gitstart@users.noreply.github.com> --- src/assets/MinusIcon.tsx | 17 ++ src/assets/PlusIcon.tsx | 17 ++ src/assets/index.ts | 2 + src/components/input-elements/BaseInput.tsx | 157 ++++++++++++++++++ .../NumberInput/NumberInput.tsx | 132 +++++++++++++++ .../input-elements/NumberInput/index.ts | 2 + .../input-elements/TextInput/TextInput.tsx | 145 ++-------------- src/components/input-elements/index.ts | 1 + src/lib/inputTypes.ts | 2 +- .../input-elements/NumberInput.stories.tsx | 106 ++++++++++++ .../input-elements/TextInput.stories.tsx | 10 ++ src/styles.css | 2 +- src/tests/input-elements/NumberInput.test.tsx | 39 +++++ src/tests/input-elements/TextInput.test.tsx | 21 +++ 14 files changed, 516 insertions(+), 137 deletions(-) create mode 100644 src/assets/MinusIcon.tsx create mode 100644 src/assets/PlusIcon.tsx create mode 100644 src/components/input-elements/BaseInput.tsx create mode 100644 src/components/input-elements/NumberInput/NumberInput.tsx create mode 100644 src/components/input-elements/NumberInput/index.ts create mode 100644 src/stories/input-elements/NumberInput.stories.tsx create mode 100644 src/tests/input-elements/NumberInput.test.tsx create mode 100644 src/tests/input-elements/TextInput.test.tsx diff --git a/src/assets/MinusIcon.tsx b/src/assets/MinusIcon.tsx new file mode 100644 index 000000000..df566d7c9 --- /dev/null +++ b/src/assets/MinusIcon.tsx @@ -0,0 +1,17 @@ +/* eslint-disable max-len */ +import React from "react"; + +const MinusIcon = ({ ...props }) => ( + + + +); + +export default MinusIcon; diff --git a/src/assets/PlusIcon.tsx b/src/assets/PlusIcon.tsx new file mode 100644 index 000000000..5d7292d6c --- /dev/null +++ b/src/assets/PlusIcon.tsx @@ -0,0 +1,17 @@ +/* eslint-disable max-len */ +import React from "react"; + +const PlusIcon = ({ ...props }) => ( + + + +); + +export default PlusIcon; diff --git a/src/assets/index.ts b/src/assets/index.ts index 4e180cbf3..a41ad531a 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -14,3 +14,5 @@ export { default as ExclamationFilledIcon } from "./ExclamationFilledIcon"; export { default as LoadingSpinner } from "./LoadingSpinner"; export { default as SearchIcon } from "./SearchIcon"; export { default as XCircleIcon } from "./XCircleIcon"; +export { default as PlusIcon } from "./PlusIcon"; +export { default as MinusIcon } from "./MinusIcon"; diff --git a/src/components/input-elements/BaseInput.tsx b/src/components/input-elements/BaseInput.tsx new file mode 100644 index 000000000..04191cc4c --- /dev/null +++ b/src/components/input-elements/BaseInput.tsx @@ -0,0 +1,157 @@ +"use client"; +import React, { ReactNode, useRef, useState } from "react"; +import { border, mergeRefs, sizing, spacing, tremorTwMerge } from "lib"; +import { ExclamationFilledIcon } from "assets"; +import { getSelectButtonColors, hasValue } from "components/input-elements/selectUtils"; + +export interface BaseInputProps extends React.InputHTMLAttributes { + type?: "text" | "password" | "email" | "url" | "number"; + defaultValue?: string | number; + value?: string | number; + icon?: React.ElementType | React.JSXElementConstructor; + error?: boolean; + errorMessage?: string; + disabled?: boolean; + stepper?: ReactNode; + makeInputClassName: (className: string) => string; +} + +const BaseInput = React.forwardRef((props, ref) => { + const { + value, + defaultValue, + type, + placeholder = "Type...", + icon, + error = false, + errorMessage, + disabled = false, + stepper, + makeInputClassName, + className, + ...other + } = props; + const [isFocused, setIsFocused] = useState(false); + + const Icon = icon; + const inputRef = useRef(null); + + const hasSelection = hasValue(value || defaultValue); + + const handleFocusChange = (isFocused: boolean) => { + if (isFocused === false) { + inputRef.current?.blur(); + } else { + inputRef.current?.focus(); + } + setIsFocused(isFocused); + }; + + return ( + <> +
{ + if (!disabled) { + handleFocusChange(true); + } + }} + onFocus={() => { + handleFocusChange(true); + }} + onBlur={() => { + handleFocusChange(false); + }} + > + {Icon ? ( + + ) : null} + + {error ? ( + + ) : null} + {stepper ?? null} +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + ); +}); + +BaseInput.displayName = "BaseInput"; + +export default BaseInput; diff --git a/src/components/input-elements/NumberInput/NumberInput.tsx b/src/components/input-elements/NumberInput/NumberInput.tsx new file mode 100644 index 000000000..58f37e522 --- /dev/null +++ b/src/components/input-elements/NumberInput/NumberInput.tsx @@ -0,0 +1,132 @@ +import React, { useRef } from "react"; +import { makeClassName, mergeRefs, tremorTwMerge } from "lib"; +import { PlusIcon, MinusIcon } from "assets"; +import BaseInput, { BaseInputProps } from "../BaseInput"; + +export interface NumberInputProps + extends Omit { + step?: string; + enableStepper?: boolean; + onSubmit?: (value: number) => void; + onValueChange?: (value: number) => void; +} + +const baseArrowClasses = + "flex mx-auto text-tremor-content-subtle dark:text-dark-tremor-content-subtle"; + +const enabledArrowClasses = + "cursor-pointer hover:text-tremor-content dark:hover:text-dark-tremor-content"; + +const NumberInput = React.forwardRef((props, ref) => { + const { onSubmit, enableStepper = true, disabled, onValueChange, onChange, ...other } = props; + + const inputRef = useRef(null); + + const [isArrowDownPressed, setIsArrowDownPressed] = React.useState(false); + const handleArrowDownPress = React.useCallback(() => { + setIsArrowDownPressed(true); + }, []); + const handleArrowDownRelease = React.useCallback(() => { + setIsArrowDownPressed(false); + }, []); + + const [isArrowUpPressed, setIsArrowUpPressed] = React.useState(false); + const handleArrowUpPress = React.useCallback(() => { + setIsArrowUpPressed(true); + }, []); + const handleArrowUpRelease = React.useCallback(() => { + setIsArrowUpPressed(false); + }, []); + + return ( + { + if (e.key === "Enter" && !e.ctrlKey && !e.altKey && !e.shiftKey) { + const value = inputRef.current?.value; + onSubmit?.(parseFloat(value ?? "")); + } + if (e.key === "ArrowDown") { + handleArrowDownPress(); + } + if (e.key === "ArrowUp") { + handleArrowUpPress(); + } + }} + onKeyUp={(e) => { + if (e.key === "ArrowDown") { + handleArrowDownRelease(); + } + if (e.key === "ArrowUp") { + handleArrowUpRelease(); + } + }} + onChange={(e) => { + if (disabled) return; + + onValueChange?.(parseFloat(e.target.value)); + onChange?.(e); + }} + stepper={ + enableStepper ? ( +
+
e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} + onTouchStart={(e) => e.preventDefault()} + onMouseUp={() => { + if (disabled) return; + inputRef.current?.stepDown(); + onValueChange?.(parseFloat(inputRef.current?.value ?? "")); + }} + className={tremorTwMerge( + !disabled && enabledArrowClasses, + baseArrowClasses, + "group py-[10px] px-2.5 border-l border-tremor-border dark:border-dark-tremor-border", + )} + > + +
+
e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} + onTouchStart={(e) => e.preventDefault()} + onMouseUp={() => { + if (disabled) return; + inputRef.current?.stepUp(); + onValueChange?.(parseFloat(inputRef.current?.value ?? "")); + }} + className={tremorTwMerge( + !disabled && enabledArrowClasses, + baseArrowClasses, + "group py-[10px] px-2.5 border-l border-tremor-border dark:border-dark-tremor-border", + )} + > + +
+
+ ) : null + } + {...other} + /> + ); +}); + +NumberInput.displayName = "NumberInput"; + +export default NumberInput; diff --git a/src/components/input-elements/NumberInput/index.ts b/src/components/input-elements/NumberInput/index.ts new file mode 100644 index 000000000..cebb2e9a3 --- /dev/null +++ b/src/components/input-elements/NumberInput/index.ts @@ -0,0 +1,2 @@ +export { default as NumberInput } from "./NumberInput"; +export type { NumberInputProps } from "./NumberInput"; diff --git a/src/components/input-elements/TextInput/TextInput.tsx b/src/components/input-elements/TextInput/TextInput.tsx index 7c032773e..649d9de13 100644 --- a/src/components/input-elements/TextInput/TextInput.tsx +++ b/src/components/input-elements/TextInput/TextInput.tsx @@ -1,148 +1,23 @@ "use client"; -import React, { useRef, useState } from "react"; -import { tremorTwMerge } from "lib"; +import React from "react"; +import { makeClassName } from "lib"; +import BaseInput, { BaseInputProps } from "../BaseInput"; -import { border, makeClassName, mergeRefs, sizing, spacing } from "lib"; -import { ExclamationFilledIcon } from "assets"; -import { getSelectButtonColors, hasValue } from "components/input-elements/selectUtils"; - -const makeTextInputClassName = makeClassName("TextInput"); - -export interface TextInputProps extends React.InputHTMLAttributes { - type?: "text" | "password"; +export type TextInputProps = Omit & { + type?: "text" | "password" | "email" | "url"; defaultValue?: string; value?: string; icon?: React.ElementType | React.JSXElementConstructor; error?: boolean; errorMessage?: string; disabled?: boolean; -} - -const TextInput = React.forwardRef((props, ref) => { - const { - type = "text", - placeholder = "Type...", - icon, - error = false, - errorMessage, - disabled = false, - className, - ...other - } = props; - const [isFocused, setIsFocused] = useState(false); +}; - const Icon = icon; - const inputRef = useRef(null); - - const hasSelection = hasValue(props.value || props.defaultValue); - - const handleFocusChange = (isFocused: boolean) => { - if (isFocused === false) { - inputRef.current?.blur(); - } else { - inputRef.current?.focus(); - } - setIsFocused(isFocused); - }; +const makeTextInputClassName = makeClassName("TextInput"); - return ( - <> -
{ - if (!disabled) { - handleFocusChange(true); - } - }} - onFocus={() => { - handleFocusChange(true); - }} - onBlur={() => { - handleFocusChange(false); - }} - > - {Icon ? ( - - ) : null} - - {error ? ( - - ) : null} -
- {errorMessage ? ( -

- {errorMessage} -

- ) : null} - - ); +const TextInput = React.forwardRef((props, ref) => { + const { type = "text", ...other } = props; + return ; }); TextInput.displayName = "TextInput"; diff --git a/src/components/input-elements/index.ts b/src/components/input-elements/index.ts index 8efa219f3..fe58f6f2e 100644 --- a/src/components/input-elements/index.ts +++ b/src/components/input-elements/index.ts @@ -6,3 +6,4 @@ export * from "./Button"; export * from "./DatePicker"; export * from "./DateRangePicker"; export * from "./TextInput"; +export * from "./NumberInput"; diff --git a/src/lib/inputTypes.ts b/src/lib/inputTypes.ts index dc93fcd85..43e660cfb 100644 --- a/src/lib/inputTypes.ts +++ b/src/lib/inputTypes.ts @@ -2,7 +2,7 @@ export type ValueFormatter = { (value: number): string; }; -export type CurveType = "linear" | "natural" | "step"; +export type CurveType = "linear" | "natural" | "monotone" | "step"; const iconVariantValues = ["simple", "light", "shadow", "solid", "outlined"] as const; diff --git a/src/stories/input-elements/NumberInput.stories.tsx b/src/stories/input-elements/NumberInput.stories.tsx new file mode 100644 index 000000000..254a268a2 --- /dev/null +++ b/src/stories/input-elements/NumberInput.stories.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; + +import { ComponentMeta, ComponentStory } from "@storybook/react"; + +import { Button, Card, Text, NumberInput } from "components"; +import { CalendarIcon } from "assets"; + +// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +export default { + title: "Tremor/InputElements/NumberInput", + component: NumberInput, +} as ComponentMeta; +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args + +const Template: ComponentStory = (args) => { + const [value, setValue] = useState(0); + return ( + +
{ + e.preventDefault(); + }} + onReset={() => setValue(0)} + > + Uncontrolled + alert(value)} /> + Uncontrolled with defaultValue + alert(value)} /> + Conrolled without onChange + alert(value)} /> + + setValue(e)} + onSubmit={(value: number) => alert(value)} + /> + + + + {value} +
+ ); +}; + +export const Default = Template.bind({}); +Default.args = {}; + +export const WithIcon = Template.bind({}); +WithIcon.args = { + icon: CalendarIcon, +}; + +export const WithNoPlaceholder = Template.bind({}); +WithNoPlaceholder.args = { + placeholder: "", +}; + +export const WithDefaultValue = Template.bind({}); +WithDefaultValue.args = { + value: 123, +}; + +export const WithStepAttribute = Template.bind({}); +WithStepAttribute.args = { + step: ".1", +}; + +export const WithMinMaxAttribute = Template.bind({}); +WithMinMaxAttribute.args = { + min: "2", + max: "10", +}; + +export const WithError = Template.bind({}); +WithError.args = { + value: 123, + error: true, +}; + +export const WithErrorMessage = Template.bind({}); +WithErrorMessage.args = { + value: 123, + error: true, + errorMessage: "Something is wrong", +}; + +export const WithDisabled = Template.bind({}); +WithDisabled.args = { + value: 123, + disabled: true, +}; + +export const WithDisabledAndError = Template.bind({}); +WithDisabledAndError.args = { + value: 123, + error: true, + disabled: true, +}; diff --git a/src/stories/input-elements/TextInput.stories.tsx b/src/stories/input-elements/TextInput.stories.tsx index f21e1a6cd..fc52235e4 100644 --- a/src/stories/input-elements/TextInput.stories.tsx +++ b/src/stories/input-elements/TextInput.stories.tsx @@ -122,3 +122,13 @@ export const WithTypePassword = Template.bind({}); WithTypePassword.args = { type: "password", }; + +export const WithTypeEmail = Template.bind({}); +WithTypeEmail.args = { + type: "email", +}; + +export const WithTypeUrl = Template.bind({}); +WithTypeUrl.args = { + type: "url", +}; diff --git a/src/styles.css b/src/styles.css index b5c61c956..bd6213e1d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,3 @@ @tailwind base; @tailwind components; -@tailwind utilities; +@tailwind utilities; \ No newline at end of file diff --git a/src/tests/input-elements/NumberInput.test.tsx b/src/tests/input-elements/NumberInput.test.tsx new file mode 100644 index 000000000..d4546fe8c --- /dev/null +++ b/src/tests/input-elements/NumberInput.test.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { NumberInput } from "components"; + +describe("NumberInput", () => { + test("renders the NumberInput component with default props", () => { + const { container } = render(); + expect(container.querySelector('[data-testid="base-input"]')?.getAttribute("type")).toBe( + "number", + ); + }); + test("can only type numbers", () => { + render(); + const inputEl: HTMLInputElement = screen.getByTestId("base-input"); + fireEvent.change(inputEl, { target: { value: "Test" } }); + expect(inputEl.value).toBe(""); + }); + + test(".1 step attribute", () => { + render(); + const inputEl: HTMLInputElement = screen.getByTestId("base-input"); + expect(inputEl.value).toBe("2"); + const stepUp = screen.getByTestId("step-up"); + fireEvent.mouseUp(stepUp); + expect(inputEl.value).toBe("2.1"); + }); + + test("min/max attribute", () => { + render(); + const inputEl: HTMLInputElement = screen.getByTestId("base-input"); + const stepUp = screen.getByTestId("step-up"); + const stepDown = screen.getByTestId("step-down"); + fireEvent.mouseUp(stepDown); + expect(inputEl.value).toBe("1"); + fireEvent.mouseUp(stepUp); + fireEvent.mouseUp(stepUp); + expect(inputEl.value).toBe("2"); + }); +}); diff --git a/src/tests/input-elements/TextInput.test.tsx b/src/tests/input-elements/TextInput.test.tsx new file mode 100644 index 000000000..39d7904e8 --- /dev/null +++ b/src/tests/input-elements/TextInput.test.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { TextInput } from "components"; + +describe("TextInput", () => { + test("renders the TextInput component with text type", () => { + render(); + }); + + test("renders the TextInput component with password type", () => { + render(); + }); + + test("renders the TextInput component with email type", () => { + render(); + }); + + test("renders the TextInput component with url type", () => { + render(); + }); +});