diff --git a/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx b/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx deleted file mode 100644 index e1b08893..00000000 --- a/packages/frappe-ui-react/src/components/textInput/tests/textInput.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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 custom id", () => { - render( {}} htmlId="custom-id" />); - expect(screen.getByRole("textbox")).toHaveAttribute("id", "custom-id"); - }); - }); - - 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"); - }); - }); - - 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(); - }); - }); -}); diff --git a/packages/frappe-ui-react/src/components/textInput/textInput.interactions.stories.tsx b/packages/frappe-ui-react/src/components/textInput/textInput.interactions.stories.tsx new file mode 100644 index 00000000..7f512fa4 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textInput/textInput.interactions.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { userEvent, expect, within } from "storybook/test"; +import TextInput from "./textInput"; + +const meta: Meta = { + title: "Components/TextInput/Interactions", + component: TextInput, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, + argTypes: { + value: { control: "text", description: "Input value (controlled)" }, + onChange: { action: "changed", description: "Change handler" }, + size: { + control: { type: "select", options: ["sm", "md", "lg", "xl"] }, + description: "Input size variant", + }, + variant: { + control: { type: "select", options: ["subtle", "outline"] }, + description: "Visual variant", + }, + state: { + control: { + type: "select", + options: ["default", "error", "success", "warning"], + }, + description: "Input state", + }, + disabled: { control: "boolean", description: "Disabled state" }, + loading: { control: "boolean", description: "Loading state" }, + prefix: { control: false, description: "Prefix render function" }, + suffix: { control: false, description: "Suffix render function" }, + className: { control: "text", description: "Additional CSS classes" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const BasicInput: Story = { + render: (args) => { + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + placeholder="Type here..." + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + + expect(input).toBeInTheDocument(); + await userEvent.type(input, "Hello World"); + expect(input).toHaveValue("Hello World"); + }, +}; + +export const ControlledInput: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + placeholder="Controlled input" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + + await userEvent.type(input, "Hello"); + expect(input).toHaveValue("Hello"); + }, +}; + +export const InputLoadingState: Story = { + args: { + loading: true, + value: "Loading...", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + + expect(input).toBeDisabled(); + expect(input).toHaveAttribute("aria-busy", "true"); + + const spinner = canvasElement.querySelector("svg.animate-spin"); + expect(spinner).toBeInTheDocument(); + }, +}; + +export const DisabledState: Story = { + args: { + disabled: true, + value: "Disabled", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + + expect(input).toBeDisabled(); + }, +}; + +export const WithPrefixSuffix: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + prefix={() => $} + suffix={() => .00} + placeholder="Amount" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("$")).toBeInTheDocument(); + expect(canvas.getByText(".00")).toBeInTheDocument(); + + const input = canvas.getByRole("textbox"); + await userEvent.type(input, "100"); + expect(input).toHaveValue("100"); + }, +}; + +export const VariantStyling: Story = { + render: () => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const inputs = canvas.getAllByRole("textbox"); + + expect(inputs).toHaveLength(3); + + expect(inputs[0]).toHaveClass("bg-surface-red-1"); + expect(inputs[0]).toHaveAttribute("aria-invalid", "true"); + + expect(inputs[1]).toHaveClass("border-outline-green-2"); + + expect(inputs[2]).toHaveClass("bg-surface-amber-1"); + }, +}; 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..7725fbfd 100644 --- a/packages/frappe-ui-react/src/components/textInput/textInput.tsx +++ b/packages/frappe-ui-react/src/components/textInput/textInput.tsx @@ -1,30 +1,206 @@ -import React, { - useMemo, - useRef, - useCallback, - type InputHTMLAttributes, - forwardRef, -} from "react"; +import React, { useMemo, useRef, useCallback, forwardRef, useId } from "react"; +import { cva } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; import { debounce } from "../../utils/debounce"; -import type { TextInputProps } from "./types"; +import { cn } from "../../utils"; +import type { TextInputProps, TextInputSize } from "./types"; + +const textInputVariants = cva( + "w-full rounded transition-colors outline-none appearance-none placeholder:text-ink-gray-5", + { + variants: { + size: { + sm: "text-sm h-7", + md: "text-base h-8", + lg: "text-lg h-10", + xl: "text-xl h-12", + }, + variant: { + subtle: "", + outline: "", + }, + state: { + default: "", + error: "", + success: "", + warning: "", + }, + disabled: { + true: "bg-surface-gray-2 border border-outline-gray-2 text-ink-gray-4 cursor-not-allowed", + false: "text-ink-gray-8", + }, + }, + compoundVariants: [ + // Subtle variant states + { + variant: "subtle", + state: "default", + disabled: false, + class: + "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", + }, + { + variant: "subtle", + state: "error", + disabled: false, + class: + "bg-surface-red-1 border border-surface-red-1 hover:bg-surface-red-2 focus:ring-2 focus:ring-outline-red-2", + }, + { + variant: "subtle", + state: "success", + disabled: false, + class: + "bg-surface-green-2 border border-surface-green-2 hover:bg-surface-green-1 focus:ring-2 focus:ring-outline-green-2", + }, + { + variant: "subtle", + state: "warning", + disabled: false, + class: + "bg-surface-amber-1 border border-surface-amber-1 hover:bg-surface-amber-2 focus:ring-2 focus:ring-outline-amber-2", + }, + // Outline variant states + { + variant: "outline", + state: "default", + disabled: false, + class: + "bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 focus:ring-2 focus:ring-outline-gray-3", + }, + { + variant: "outline", + state: "error", + disabled: false, + class: + "bg-surface-white border border-outline-red-2 hover:border-outline-red-3 focus:ring-2 focus:ring-outline-red-2", + }, + { + variant: "outline", + state: "success", + disabled: false, + class: + "bg-surface-white border border-outline-green-2 hover:border-outline-green-3 focus:ring-2 focus:ring-outline-green-2", + }, + { + variant: "outline", + state: "warning", + disabled: false, + class: + "bg-surface-white border border-outline-amber-2 hover:border-outline-amber-3 focus:ring-2 focus:ring-outline-amber-2", + }, + ], + defaultVariants: { + size: "md", + variant: "subtle", + state: "default", + disabled: false, + }, + } +); + +const paddingVariants = cva("py-1.5", { + variants: { + size: { + sm: "", + md: "", + lg: "", + xl: "", + }, + hasPrefix: { + true: "", + false: "", + }, + hasSuffix: { + true: "", + false: "", + }, + }, + compoundVariants: [ + // Prefix only + { size: "sm", hasPrefix: true, class: "pl-10" }, + { size: "md", hasPrefix: true, class: "pl-11" }, + { size: "lg", hasPrefix: true, class: "pl-12" }, + { size: "xl", hasPrefix: true, class: "pl-14" }, + { size: "sm", hasPrefix: false, class: "pl-2" }, + { size: "md", hasPrefix: false, class: "pl-2.5" }, + { size: "lg", hasPrefix: false, class: "pl-3" }, + { size: "xl", hasPrefix: false, class: "pl-3.5" }, + // Suffix only + { size: "sm", hasSuffix: true, class: "pr-10" }, + { size: "md", hasSuffix: true, class: "pr-11" }, + { size: "lg", hasSuffix: true, class: "pr-12" }, + { size: "xl", hasSuffix: true, class: "pr-14" }, + { size: "sm", hasSuffix: false, class: "pr-2" }, + { size: "md", hasSuffix: false, class: "pr-2.5" }, + { size: "lg", hasSuffix: false, class: "pr-3" }, + { size: "xl", hasSuffix: false, class: "pr-3.5" }, + ], + defaultVariants: { + size: "md", + hasPrefix: false, + hasSuffix: false, + }, +}); + +const iconLeftPosVariants = cva( + "absolute flex items-center text-ink-gray-6 pointer-events-none", + { + variants: { + size: { + sm: "left-2", + md: "left-2.5", + lg: "left-3", + xl: "left-3.5", + }, + }, + defaultVariants: { + size: "md", + }, + } +); + +const iconRightPosVariants = cva( + "absolute flex items-center text-ink-gray-6 pointer-events-none", + { + variants: { + size: { + sm: "right-2", + md: "right-2.5", + lg: "right-3", + xl: "right-3.5", + }, + }, + defaultVariants: { + size: "md", + }, + } +); 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,137 +208,85 @@ 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 isDisabled = disabled || loading; + const stateKey = state ?? "default"; - 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 paddingClasses = paddingVariants({ + size: size as TextInputSize, + hasPrefix: !!prefix, + hasSuffix: !!suffix || loading, + }); - 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]; + const debouncedOnChange = useMemo(() => { + if (!debounceTime || !onChange) return onChange; + return debounce((event: React.ChangeEvent) => { + onChange(event); + }, debounceTime); + }, [debounceTime, onChange]); - 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 emitChange = useCallback( - (value: string) => { - if (onChange) { - const syntheticEvent = { - target: { value }, - } as React.ChangeEvent; - onChange(syntheticEvent); - } - }, - [onChange] - ); - - const debouncedEmitChange = useMemo(() => { - if (debounceTime) { - return debounce((value: string) => emitChange(value), debounceTime); + const handleChange = (e: React.ChangeEvent) => { + if (debounceTime && debouncedOnChange) { + debouncedOnChange(e); + } else { + onChange?.(e); } - return emitChange; - }, [debounceTime, emitChange]); + }; - const handleChange = useCallback( - (e: React.ChangeEvent) => { - debouncedEmitChange(e.target.value); - }, - [debouncedEmitChange] - ); + return ( +
+
+ {prefix && ( +
+ {prefix(size)} +
+ )} - const inputValue = - value ?? (rest as InputHTMLAttributes).value; + - return ( -
- {prefix && ( -
- {prefix?.(size)} -
- )} - - {suffix && ( -
- {suffix && suffix()} -
- )} + {(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..cada1831 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 type { 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; }