From 6cce8d1c889ad29c9e658e8839549eae21309ceb Mon Sep 17 00:00:00 2001 From: Swayam kale Date: Sat, 7 Feb 2026 23:44:58 +0530 Subject: [PATCH 1/3] feat:component-textinput --- .../components/textInput/tests/textInput.tsx | 70 ++--- .../textInput/textInput.stories.tsx | 253 ++++++----------- .../src/components/textInput/textInput.tsx | 257 ++++++++++-------- .../src/components/textInput/types.ts | 26 +- 4 files changed, 260 insertions(+), 346 deletions(-) diff --git a/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx b/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx index e1b08893..5774f9cb 100644 --- a/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx +++ b/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx @@ -1,58 +1,36 @@ -import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import TextInput from "../textInput"; describe("TextInput", () => { - describe("Rendering", () => { - it("renders with placeholder", () => { - render( - {}} /> - ); - expect(screen.getByPlaceholderText("Type here...")).toBeInTheDocument(); - }); - - it("renders with initial value", () => { - render( {}} />); - expect(screen.getByDisplayValue("Initial")).toBeInTheDocument(); - }); - - it("is disabled when disabled prop is true", () => { - render( {}} disabled />); - expect(screen.getByRole("textbox")).toBeDisabled(); - }); + it("renders with placeholder", () => { + render( + {}} /> + ); + expect(screen.getByPlaceholderText("Type here...")).toBeInTheDocument(); + }); - it("renders with custom id", () => { - render( {}} htmlId="custom-id" />); - expect(screen.getByRole("textbox")).toHaveAttribute("id", "custom-id"); - }); + it("calls onChange when typing", () => { + const handleChange = jest.fn(); + render(); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Hello" } }); + expect(handleChange).toHaveBeenCalled(); }); - describe("User Interaction", () => { - it("calls onChange when typing", () => { - const handleChange = jest.fn(); - render(); - const input = screen.getByRole("textbox"); - fireEvent.change(input, { target: { value: "Hello" } }); - expect(handleChange).toHaveBeenCalled(); - expect(handleChange.mock.calls[0][0].target.value).toBe("Hello"); - }); + it("renders prefix element", () => { + render( + {}} + prefix={() => PREFIX} + /> + ); + expect(screen.getByText("PREFIX")).toBeInTheDocument(); }); - describe("Prefix & Suffix", () => { - it("renders prefix and suffix", () => { - const Prefix = () => Prefix; - const Suffix = () => Suffix; - render( - {}} - prefix={() => } - suffix={() => } - /> - ); - expect(screen.getByText("Prefix")).toBeInTheDocument(); - expect(screen.getByText("Suffix")).toBeInTheDocument(); - }); + it("is disabled when disabled prop is true", () => { + render( {}} disabled />); + expect(screen.getByRole("textbox")).toBeDisabled(); }); }); diff --git a/packages/frappe-ui-react/src/components/textInput/textInput.stories.tsx b/packages/frappe-ui-react/src/components/textInput/textInput.stories.tsx index 5bb6e50d..ff787383 100644 --- a/packages/frappe-ui-react/src/components/textInput/textInput.stories.tsx +++ b/packages/frappe-ui-react/src/components/textInput/textInput.stories.tsx @@ -1,233 +1,132 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { TextInputProps } from "./types"; import TextInput from "./textInput"; -import { Avatar } from "../avatar"; -import FeatherIcon from "../featherIcon"; +import type { TextInputProps } from "./types"; -export default { +const meta: Meta = { title: "Components/TextInput", component: TextInput, - parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, tags: ["autodocs"], argTypes: { type: { - control: { - type: "select", - options: [ - "text", - "number", - "email", - "date", - "datetime-local", - "password", - "search", - "tel", - "time", - "url", - ], - }, - description: "Type of the text input", + options: [ + "text", + "number", + "email", + "date", + "datetime-local", + "password", + "search", + "tel", + "time", + "url", + ], + control: { type: "select" }, + description: "HTML input type", }, size: { - control: { type: "select", options: ["sm", "md", "lg"] }, + options: ["sm", "md", "lg", "xl"], + control: { type: "select" }, description: "Size of the text input", }, variant: { - control: { type: "select", options: ["outline", "subtle"] }, - description: "Visual variant of the text input", + options: ["subtle", "outline"], + control: { type: "select" }, + description: "Visual variant of the input", + }, + state: { + options: [undefined, "success", "warning", "error"], + control: { type: "select" }, + description: "Validation / feedback state", }, disabled: { control: "boolean", - description: "If true, disables the text input", + description: "Disables the input", }, - placeholder: { - control: "text", - description: "Placeholder text for the text input", + loading: { + control: "boolean", + description: "Shows loading spinner and disables input", }, - htmlId: { + placeholder: { control: "text", - description: "HTML id attribute for the text input", + description: "Placeholder text", }, value: { control: "text", - description: "Current value of the text input", + description: "Controlled value of the input", }, - debounce: { - control: "number", - description: "Debounce time in milliseconds for the onChange event", + prefix: { + control: false, + description: "Prefix slot (icon or custom element)", }, - required: { - control: "boolean", - description: "If true, marks the text input as required", + suffix: { + control: false, + description: "Suffix slot (icon or custom element)", }, onChange: { action: "changed", - description: "Callback function when the input value changes", + description: "Triggered when input value changes", }, - prefix: { - control: false, - description: "Function to render a prefix element inside the input", + htmlId: { + control: "text", + description: "HTML id attribute", }, - suffix: { - control: false, - description: "Function to render a suffix element inside the input", + debounce: { + control: "number", + description: "Debounce delay in milliseconds", + }, + required: { + control: "boolean", + description: "Marks the input as required", }, className: { control: "text", - description: "Custom CSS classes for the text input", - }, - style: { - control: "object", - description: "Inline styles for the text input", + description: "Custom CSS class", }, }, -} as Meta; +}; -const Template: StoryObj = { - render: (args) => { - const [value, setValue] = useState(args.value || ""); +export default meta; +type Story = StoryObj; +const Template: Story = { + render: (args) => { + const [val, setVal] = useState(args.value || ""); return ( -
+
setValue(e.target.value)} + value={val} + onChange={(e) => { + setVal(e.target.value); + args.onChange?.(e); + }} />
); }, }; -export const Text = { - ...Template, - args: { - type: "text", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Number = { - ...Template, - args: { - type: "number", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Email = { +export const Subtle: Story = { ...Template, args: { type: "email", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Date = { - ...Template, - args: { - type: "date", - placeholder: "Placeholder", - value: "", - }, -}; -export const DateTimeLocal = { - ...Template, - args: { - type: "datetime-local", - value: "", - }, -}; - -export const Password = { - ...Template, - args: { - type: "password", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Search = { - ...Template, - args: { - type: "search", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Telephone = { - ...Template, - args: { - type: "tel", - placeholder: "Placeholder", - value: "", - }, -}; - -export const Time = { - ...Template, - args: { - type: "time", - value: "", - }, -}; - -export const Url = { - ...Template, - args: { - type: "url", - placeholder: "Placeholder", - value: "", - }, -}; - -export const PrefixSlotIcon = { - ...Template, - args: { - type: "url", - placeholder: "Placeholder", - prefix: (size) => ( - - ), - value: "", + placeholder: "user@example.com", + variant: "subtle", + size: "md", }, }; -export const SuffixSlotIcon = { +export const Outline: Story = { ...Template, args: { - type: "url", - placeholder: "Placeholder", - suffix: () => , - value: "", - }, -}; - -export const PrefixSlotAvatar = { - ...Template, - args: { - type: "url", - placeholder: "Placeholder", - prefix: (size) => ( - - ), - value: "", - }, -}; - -export const Default = { - ...Template, - args: { - value: "", + type: "email", + placeholder: "user@example.com", + variant: "outline", + size: "md", }, }; diff --git a/packages/frappe-ui-react/src/components/textInput/textInput.tsx b/packages/frappe-ui-react/src/components/textInput/textInput.tsx index bcbe4465..dc9bc98d 100644 --- a/packages/frappe-ui-react/src/components/textInput/textInput.tsx +++ b/packages/frappe-ui-react/src/components/textInput/textInput.tsx @@ -1,30 +1,33 @@ -import React, { - useMemo, - useRef, - useCallback, - type InputHTMLAttributes, - forwardRef, -} from "react"; +import React, { useMemo, useRef, useCallback, forwardRef, useId } from "react"; +import { clsx } from "clsx"; +import { Loader2 } from "lucide-react"; import { debounce } from "../../utils/debounce"; -import type { TextInputProps } from "./types"; +import type { TextInputProps, TextInputSize } from "./types"; const TextInput = forwardRef( ( { type = "text", - size = "sm", + size = "md", variant = "subtle", + state, + loading = false, disabled = false, value, onChange, debounce: debounceTime, prefix, suffix, + className, + htmlId, + required, ...rest }, ref ) => { const inputRef = useRef(null); + const generatedId = useId(); + const id = htmlId || generatedId; const setRefs = useCallback( (node: HTMLInputElement) => { @@ -32,79 +35,94 @@ const TextInput = forwardRef( if (typeof ref === "function") { ref(node); } else if (ref) { - ref.current = node; + (ref as { current: HTMLInputElement | null }).current = node; } }, [ref] ); - const textColor = disabled ? "text-ink-gray-5" : "text-ink-gray-8"; - - const inputClasses = useMemo(() => { - const sizeClasses = { - sm: "text-base rounded h-7", - md: "text-base rounded h-8", - lg: "text-lg rounded-md h-10", - xl: "text-xl rounded-md h-10", - }[size]; - - const paddingClasses = { - sm: ["py-1.5", prefix ? "pl-9" : "pl-2", suffix ? "pr-8" : "pr-2"].join( - " " - ), - md: [ - "py-1.5", - prefix ? "pl-10" : "pl-2.5", - suffix ? "pr-9" : "pr-2.5", - ].join(" "), - lg: [ - "py-1.5", - prefix ? "pl-12" : "pl-3", - suffix ? "pr-10" : "pr-3", - ].join(" "), - xl: [ - "py-1.5", - prefix ? "pl-13" : "pl-3", - suffix ? "pr-10" : "pr-3", - ].join(" "), - }[size]; - - const currentVariant = disabled ? "disabled" : variant; - const variantClasses = { - subtle: - "border border-surface-gray-2 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3", - outline: - "border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3", - disabled: `border ${ - variant === "outline" ? "border-outline-gray-2" : "border-transparent" - } bg-surface-gray-1 placeholder-ink-gray-3`, - ghost: "border-0 focus:ring-0 focus-visible:ring-0", - }[currentVariant]; - - return [ - sizeClasses, - paddingClasses, - variantClasses, - textColor, - "transition-colors w-full dark:[color-scheme:dark] outline-none", - ] - .filter(Boolean) - .join(" "); - }, [size, prefix, suffix, disabled, variant, textColor]); - - const prefixClasses = useMemo(() => { - return { sm: "pl-2", md: "pl-2.5", lg: "pl-3", xl: "pl-3" }[size]; - }, [size]); - - const suffixClasses = useMemo(() => { - return { sm: "pr-2", md: "pr-2.5", lg: "pr-3", xl: "pr-3" }[size]; - }, [size]); + const isDisabled = disabled || loading; + const stateKey = state ?? "default"; + + const sizeClasses = { + sm: "text-sm h-7", + md: "text-base h-8", + lg: "text-lg h-10", + xl: "text-xl h-12", + }[size as TextInputSize]; + + const iconLeftPosClasses = { + sm: "left-2", + md: "left-2.5", + lg: "left-3", + xl: "left-3.5", + }[size as TextInputSize]; + + const iconRightPosClasses = { + sm: "right-2", + md: "right-2.5", + lg: "right-3", + xl: "right-3.5", + }[size as TextInputSize]; + + const paddingClasses = clsx( + "py-1.5", + prefix + ? { sm: "pl-10", md: "pl-11", lg: "pl-12", xl: "pl-14" }[ + size as TextInputSize + ] + : { sm: "pl-2", md: "pl-2.5", lg: "pl-3", xl: "pl-3.5" }[ + size as TextInputSize + ], + suffix || loading + ? { sm: "pr-10", md: "pr-11", lg: "pr-12", xl: "pr-14" }[ + size as TextInputSize + ] + : { sm: "pr-2", md: "pr-2.5", lg: "pr-3", xl: "pr-3.5" }[ + size as TextInputSize + ] + ); + + const subtleClasses = { + default: + "bg-surface-gray-2 border border-surface-gray-2 hover:bg-surface-gray-3 focus:bg-surface-white focus:ring-2 focus:ring-outline-gray-3", + error: + "bg-surface-red-1 border border-surface-red-1 hover:bg-surface-red-2 focus:ring-2 focus:ring-outline-red-2", + success: + "bg-surface-green-2 border border-surface-green-2 hover:bg-surface-green-1 focus:ring-2 focus:ring-outline-green-2", + warning: + "bg-surface-amber-1 border border-surface-amber-1 hover:bg-surface-amber-2 focus:ring-2 focus:ring-outline-amber-2", + }; + + const outlineClasses = { + default: + "bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 focus:ring-2 focus:ring-outline-gray-3", + error: + "bg-surface-white border border-outline-red-2 hover:border-outline-red-3 focus:ring-2 focus:ring-outline-red-2", + success: + "bg-surface-white border border-outline-green-2 hover:border-outline-green-3 focus:ring-2 focus:ring-outline-green-2", + warning: + "bg-surface-white border border-outline-amber-2 hover:border-outline-amber-3 focus:ring-2 focus:ring-outline-amber-2", + }; + + const disabledClass = + "bg-surface-gray-2 border border-outline-gray-2 text-ink-gray-4 cursor-not-allowed"; + + const variantMap = { + subtle: subtleClasses, + outline: outlineClasses, + }; + + const currentVariantClasses = isDisabled + ? disabledClass + : variantMap[variant][stateKey]; const emitChange = useCallback( - (value: string) => { + (val: string) => { if (onChange) { const syntheticEvent = { - target: { value }, + target: { value: val }, + currentTarget: { value: val }, } as React.ChangeEvent; onChange(syntheticEvent); } @@ -114,55 +132,70 @@ const TextInput = forwardRef( const debouncedEmitChange = useMemo(() => { if (debounceTime) { - return debounce((value: string) => emitChange(value), debounceTime); + return debounce((val: string) => emitChange(val), debounceTime); } return emitChange; }, [debounceTime, emitChange]); - const handleChange = useCallback( - (e: React.ChangeEvent) => { + const handleChange = (e: React.ChangeEvent) => { + if (debounceTime) { debouncedEmitChange(e.target.value); - }, - [debouncedEmitChange] - ); - - const inputValue = - value ?? (rest as InputHTMLAttributes).value; + } else { + onChange?.(e); + } + }; return ( -
- {prefix && ( -
- {prefix?.(size)} -
- )} - - {suffix && ( -
- {suffix && suffix()} -
- )} +
+
+ {prefix && ( +
+ {prefix(size)} +
+ )} + + + + {(suffix || loading) && ( +
+ {loading ? ( + + ) : ( + suffix?.(size) + )} +
+ )} +
); } ); +TextInput.displayName = "TextInput"; export default TextInput; diff --git a/packages/frappe-ui-react/src/components/textInput/types.ts b/packages/frappe-ui-react/src/components/textInput/types.ts index f8576c30..cfba40ab 100644 --- a/packages/frappe-ui-react/src/components/textInput/types.ts +++ b/packages/frappe-ui-react/src/components/textInput/types.ts @@ -1,10 +1,16 @@ -import type { ReactNode } from "react"; -import type { TextInputTypes } from "../../common/types"; +import { InputHTMLAttributes, ReactNode } from "react"; -export interface TextInputProps { - type?: TextInputTypes; - size?: "sm" | "md" | "lg" | "xl"; - variant?: "subtle" | "outline" | "ghost"; +export type TextInputSize = "sm" | "md" | "lg" | "xl"; +export type TextInputVariant = "subtle" | "outline"; +export type TextInputState = "success" | "warning" | "error"; + +export interface TextInputProps extends Omit< + InputHTMLAttributes, + "size" | "prefix" | "suffix" +> { + size?: TextInputSize; + variant?: TextInputVariant; + state?: TextInputState; placeholder?: string; disabled?: boolean; htmlId?: string; @@ -12,10 +18,8 @@ export interface TextInputProps { debounce?: number; required?: boolean; onChange?: (event: React.ChangeEvent) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prefix?: (args?: any) => ReactNode; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - suffix?: (args?: any) => ReactNode; + prefix?: (args?: TextInputSize) => ReactNode; + suffix?: (args?: TextInputSize) => ReactNode; className?: string; - style?: Record; + loading?: boolean; } From 1b32f14014f18e0d689c1c1b3c7ffd7778af33ba Mon Sep 17 00:00:00 2001 From: Swayam kale Date: Fri, 13 Feb 2026 18:14:12 +0530 Subject: [PATCH 2/3] refactor(TextInput): improve variants, debounce, accessibility and tests --- .../src/components/datePicker/datePicker.tsx | 8 +- .../components/textInput/tests/textInput.tsx | 36 -- .../textInput.interactions.stories.tsx | 176 ++++++++++ .../src/components/textInput/textInput.tsx | 309 ++++++++++++------ .../src/components/textInput/types.ts | 2 +- 5 files changed, 380 insertions(+), 151 deletions(-) delete mode 100644 packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx create mode 100644 packages/frappe-ui-react/src/components/textInput/textInput.interactions.stories.tsx diff --git a/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx b/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx index d2a7d666..c09d82f4 100644 --- a/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx +++ b/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx @@ -69,14 +69,13 @@ export const DatePicker: React.FC = ({ suffix={() => ( )} - variant={variant} + variant={variant === "ghost" ? "subtle" : variant} />
) } body={({ togglePopover }) => (
- {/* Month Switcher */}
- {/* Calendar / Month Grid / Year Grid */} +
{view === "date" && (
@@ -226,7 +225,7 @@ export const DatePicker: React.FC = ({
)}
- {/* Actions */} + {clearable && (
@@ -256,7 +255,6 @@ export const DatePicker: React.FC = ({
- + {/* Calendar / Month Grid / Year Grid */}
{view === "date" && (
@@ -225,7 +226,7 @@ export const DatePicker: React.FC = ({
)}
- + {/* Actions */} {clearable && (
@@ -255,6 +256,7 @@ export const DatePicker: React.FC = ({