Skip to content

Commit

Permalink
feat: add number input and email/url types to text input (#575) (#577)
Browse files Browse the repository at this point in the history
number input
Co-authored-by: RubensRafael <rubensrafael2@live.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Rubens Rafael <70234898+RubensRafael@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Severin Landolt <sev.landolt@gmail.com>
Co-authored-by: gitstart <gitstart@users.noreply.github.com>

curve type
Co-authored-by: Thomas McInnis <thomasmcinnis@icloud.com>
Co-authored-by: tryanmac <88759292+tryanmac@users.noreply.github.com>
Co-authored-by: GitStart <1501599+gitstart@users.noreply.github.com>
  • Loading branch information
mitrotasios committed Jul 23, 2023
1 parent b041b1d commit e424884
Show file tree
Hide file tree
Showing 14 changed files with 516 additions and 137 deletions.
17 changes: 17 additions & 0 deletions src/assets/MinusIcon.tsx
@@ -0,0 +1,17 @@
/* eslint-disable max-len */
import React from "react";

const MinusIcon = ({ ...props }) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
</svg>
);

export default MinusIcon;
17 changes: 17 additions & 0 deletions src/assets/PlusIcon.tsx
@@ -0,0 +1,17 @@
/* eslint-disable max-len */
import React from "react";

const PlusIcon = ({ ...props }) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);

export default PlusIcon;
2 changes: 2 additions & 0 deletions src/assets/index.ts
Expand Up @@ -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";
157 changes: 157 additions & 0 deletions 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<HTMLInputElement> {
type?: "text" | "password" | "email" | "url" | "number";
defaultValue?: string | number;
value?: string | number;
icon?: React.ElementType | React.JSXElementConstructor<any>;
error?: boolean;
errorMessage?: string;
disabled?: boolean;
stepper?: ReactNode;
makeInputClassName: (className: string) => string;
}

const BaseInput = React.forwardRef<HTMLInputElement, BaseInputProps>((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<HTMLInputElement>(null);

const hasSelection = hasValue(value || defaultValue);

const handleFocusChange = (isFocused: boolean) => {
if (isFocused === false) {
inputRef.current?.blur();
} else {
inputRef.current?.focus();
}
setIsFocused(isFocused);
};

return (
<>
<div
className={tremorTwMerge(
makeInputClassName("root"),
// common
"relative w-full flex items-center min-w-[10rem] outline-none rounded-tremor-default",
// light
"shadow-tremor-input",
// dark
"dark:shadow-dark-tremor-input",
getSelectButtonColors(hasSelection, disabled, error),
isFocused &&
tremorTwMerge(
// common
"ring-2 transition duration-100",
// light
"border-tremor-brand-subtle ring-tremor-brand-muted",
// light
"dark:border-dark-tremor-brand-subtle dark:ring-dark-tremor-brand-muted",
),
border.sm.all,
className,
)}
onClick={() => {
if (!disabled) {
handleFocusChange(true);
}
}}
onFocus={() => {
handleFocusChange(true);
}}
onBlur={() => {
handleFocusChange(false);
}}
>
{Icon ? (
<Icon
className={tremorTwMerge(
makeInputClassName("icon"),
// common
"shrink-0",
// light
"text-tremor-content-subtle",
// light
"dark:text-dark-tremor-content-subtle",
sizing.lg.height,
sizing.lg.width,
spacing.xl.marginLeft,
)}
/>
) : null}
<input
ref={mergeRefs([inputRef, ref])}
defaultValue={defaultValue}
value={value}
type={type}
className={tremorTwMerge(
makeInputClassName("input"),
// common
"w-full focus:outline-none focus:ring-0 border-none bg-transparent text-tremor-default",
// light
"text-tremor-content-emphasis",
// dark
"dark:text-dark-tremor-content-emphasis",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
Icon ? spacing.lg.paddingLeft : spacing.twoXl.paddingLeft,
error ? spacing.lg.paddingRight : spacing.twoXl.paddingRight,
spacing.sm.paddingY,
disabled
? "placeholder:text-tremor-content-subtle dark:placeholder:text-dark-tremor-content-subtle"
: "placeholder:text-tremor-content dark:placeholder:text-dark-tremor-content",
)}
placeholder={placeholder}
disabled={disabled}
data-testid="base-input"
{...other}
/>
{error ? (
<ExclamationFilledIcon
className={tremorTwMerge(
makeInputClassName("errorIcon"),
"text-rose-500 shrink-0",
spacing.md.marginRight,
sizing.lg.height,
sizing.lg.width,
)}
/>
) : null}
{stepper ?? null}
</div>
{errorMessage ? (
<p
className={tremorTwMerge(
makeInputClassName("errorMessage"),
"text-sm text-rose-500 mt-1",
)}
>
{errorMessage}
</p>
) : null}
</>
);
});

BaseInput.displayName = "BaseInput";

export default BaseInput;
132 changes: 132 additions & 0 deletions 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<BaseInputProps, "type" | "stepper" | "onSubmit" | "makeInputClassName"> {
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<HTMLInputElement, NumberInputProps>((props, ref) => {
const { onSubmit, enableStepper = true, disabled, onValueChange, onChange, ...other } = props;

const inputRef = useRef<HTMLInputElement>(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 (
<BaseInput
type="number"
ref={mergeRefs([inputRef, ref])}
disabled={disabled}
makeInputClassName={makeClassName("NumberInput")}
onKeyDown={(e) => {
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 ? (
<div className={tremorTwMerge("flex justify-center align-middle")}>
<div
tabIndex={-1}
onClick={(e) => 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",
)}
>
<MinusIcon
data-testid="step-down"
className={`${
isArrowDownPressed ? "scale-95" : ""
} h-4 w-4 duration-75 transition group-active:scale-95`}
/>
</div>
<div
tabIndex={-1}
onClick={(e) => 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",
)}
>
<PlusIcon
data-testid="step-up"
className={`${
isArrowUpPressed ? "scale-95" : ""
} h-4 w-4 duration-75 transition group-active:scale-95`}
/>
</div>
</div>
) : null
}
{...other}
/>
);
});

NumberInput.displayName = "NumberInput";

export default NumberInput;
2 changes: 2 additions & 0 deletions src/components/input-elements/NumberInput/index.ts
@@ -0,0 +1,2 @@
export { default as NumberInput } from "./NumberInput";
export type { NumberInputProps } from "./NumberInput";

0 comments on commit e424884

Please sign in to comment.