diff --git a/README.md b/README.md index fea1f54..21246dd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ https://github.com/user-attachments/assets/58c8cca5-878a-4e64-aa06-a8e202318f2a - > A lightweight, easy-to-use React component that makes any text editable with a click! ## ✨ Features @@ -39,33 +38,32 @@ import { InputClickEdit } from "@nobrainers/react-click-edit"; function App() { const [name, setName] = useState("John Doe"); - return ; + return ; } ``` ## 🔧 Props -| Prop | Type | Default | Description | -| -------------------- | ----------------------- | -------- | ------------------------------------------- | -| value | string | "" | Text to display and edit | -| isEditing | boolean | false | Initial editing state | -| inputType | string | "text" | HTML input type (text, number, email, etc.) | -| label | string | "" | Label for the input field | -| className | string | "" | Container class name | -| inputClassName | string | "" | Input field class name | -| editButtonClassName | string | "" | Edit button class name | -| saveButtonClassName | string | "" | Save button class name | -| editWrapperClassName | string | "" | Edit mode wrapper class name | -| saveButtonLabel | React.ReactNode | "Save" | Custom save button label | -| editButtonLabel | React.ReactNode | "Edit" | Custom edit button label | -| showIcons | boolean | false | Toggle button icons visibility | -| iconsOnly | boolean | false | Show only icons without text labels | -| editIcon | React.ElementType | LuPencil | Custom edit icon component | -| saveIcon | React.ElementType | LuCheck | Custom save icon component | -| iconPosition | "left" \| "right" | "left" | Position of icons in buttons | -| onEditButtonClick | () => void | () => {} | Callback when edit button is clicked | -| onInputChange | (value: string) => void | () => {} | Callback when input value changes | -| onSaveButtonClick | () => void | () => {} | Callback when save button is clicked | +| Prop | Type | Required | Default | Description | +| ----------------- | -------------------------------------- | -------- | -------- | ----------------------------------------- | +| value | string | Yes\* | - | Controlled text value to display and edit | +| defaultValue | string | No | - | Initial uncontrolled value | +| type | string | No | "text" | HTML input type attribute | +| onChange | `ChangeEventHandler` | Yes\* | - | HTML input onChange handler | +| isEditing | boolean | No | false | Control the editing state | +| label | string | No | "" | Label for the input field | +| className | string | No | "" | Container class name | +| editButtonLabel | React.ReactNode | No | "Edit" | Custom edit button label | +| saveButtonLabel | React.ReactNode | No | "Save" | Custom save button label | +| showIcons | boolean | No | false | Toggle button icons visibility | +| iconsOnly | boolean | No | false | Show only icons without text labels | +| editIcon | React.ElementType | No | LuPencil | Custom edit icon component | +| saveIcon | React.ElementType | No | LuCheck | Custom save icon component | +| iconPosition | "left" \| "right" | No | "left" | Position of icons in buttons | +| onEditButtonClick | () => void | No | () => {} | Callback when edit button is clicked | +| onSaveButtonClick | () => void | No | () => {} | Callback when save button is clicked | + +\*Either `value` + `onChange` (controlled) or `defaultValue` (uncontrolled) must be provided. ## 💡 Examples @@ -74,7 +72,7 @@ function App() { ```tsx function BasicExample() { const [name, setName] = useState("John Doe"); - return ; + return ; } ``` @@ -83,9 +81,9 @@ function BasicExample() { ```tsx console.log(value)} + onChange={(value) => console.log(value)} /> ``` @@ -133,7 +131,7 @@ function ControlledExample() { isEditing={isEditing} onEditButtonClick={() => setIsEditing(true)} onSaveButtonClick={() => setIsEditing(false)} - onInputChange={setValue} + onChange={setValue} /> ); } @@ -151,6 +149,51 @@ function ControlledExample() { /> ``` +### React Hook Form Integration + +```tsx +import { useForm, Controller } from "react-hook-form"; +import { InputClickEdit } from "@nobrainers/react-click-edit"; + +function FormExample() { + const { control, handleSubmit } = useForm(); + const onSubmit = (data) => console.log(data); + + return ( +
+ ( + + )} + /> + + + ); +} +``` + +### Register Example + +```tsx +import { useForm } from "react-hook-form"; +import { InputClickEdit } from "@nobrainers/react-click-edit"; + +function RegisterExample() { + const { register, handleSubmit } = useForm(); + const onSubmit = (data) => console.log(data); + + return ( +
+ + + + ); +} +``` + ## 🎨 Styling The component comes with minimal default styling through its base CSS file. You can override these styles or add additional styling using CSS classes. All main elements accept custom class names through props. diff --git a/package-lock.json b/package-lock.json index 1d95660..2d63030 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "jsdom": "^26.0.0", "lint-staged": "^15.3.0", "prettier": "^3.4.2", + "react-hook-form": "^7.54.2", "semantic-release": "^24.2.1", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", @@ -12748,6 +12749,22 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", diff --git a/package.json b/package.json index 18a2434..120ceb3 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "jsdom": "^26.0.0", "lint-staged": "^15.3.0", "prettier": "^3.4.2", + "react-hook-form": "^7.54.2", "semantic-release": "^24.2.1", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", diff --git a/src/InputClickEdit/InputClickEdit.test.tsx b/src/InputClickEdit/InputClickEdit.test.tsx index f3eb906..245251e 100644 --- a/src/InputClickEdit/InputClickEdit.test.tsx +++ b/src/InputClickEdit/InputClickEdit.test.tsx @@ -1,5 +1,6 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; +import { useForm } from "react-hook-form"; import { InputClickEdit } from "./InputClickEdit"; describe("InputClickEdit", () => { @@ -54,12 +55,16 @@ describe("InputClickEdit", () => { }); it("should call onInputChange when input value changes", () => { - const onInputChange = vi.fn(); - render(); - fireEvent.change(screen.getByRole("textbox"), { - target: { value: "New Value" }, - }); - expect(onInputChange).toHaveBeenCalledWith("New Value"); + const onChange = vi.fn(); + const mockEventChange = { target: { value: "New Value" } }; + render(); + fireEvent.change(screen.getByRole("textbox"), mockEventChange); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ value: "New Value" }), + }) + ); }); it("should call onSaveButtonClick when save button is clicked", () => { @@ -132,7 +137,7 @@ describe("InputClickEdit", () => { describe("Input Types", () => { it("should render different input types", () => { - render(); + render(); expect(screen.getByRole("spinbutton")).toBeInTheDocument(); }); }); @@ -145,4 +150,83 @@ describe("InputClickEdit", () => { expect(screen.getByText("Modify")).toBeInTheDocument(); }); }); + + describe("Controlled Mode", () => { + it("should update value only when controlled value prop changes", () => { + const { rerender } = render(); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "New Value" } }); + expect(input).toHaveValue("Initial"); // Should not change without prop update + + rerender(); + expect(input).toHaveValue("Updated"); + }); + }); + + describe("Uncontrolled Mode", () => { + it("should initialize with defaultValue", () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue("Default"); + }); + + it("should maintain internal state when uncontrolled", () => { + render(); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "New Value" } }); + expect(input).toHaveValue("New Value"); + }); + }); + + describe("React Hook Form Compatibility", () => { + const TestForm = () => { + const { register, watch } = useForm({ + defaultValues: { + test: "Initial", + }, + }); + const value = watch("test"); + + return ( +
+ + + ); + }; + + it("should work with react-hook-form register", async () => { + render(); + const input = screen.getByRole("textbox"); + + expect(input).toHaveValue("Initial"); + fireEvent.input(input, { target: { value: "Updated" } }); + + await waitFor(() => { + expect(input).toHaveValue("Updated"); + }); + }); + }); + + describe("Ref Handling", () => { + it("should forward ref to input element", () => { + const ref = vi.fn(); + render(); + + expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement)); + }); + + it("should maintain ref after toggling edit mode", () => { + const ref = { current: null }; + const { rerender } = render(); + + fireEvent.click(screen.getByText("Edit")); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + + fireEvent.click(screen.getByText("Save")); + rerender(); + fireEvent.click(screen.getByText("Edit")); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + }); }); diff --git a/src/InputClickEdit/InputClickEdit.tsx b/src/InputClickEdit/InputClickEdit.tsx index 3d592ea..7ad133d 100644 --- a/src/InputClickEdit/InputClickEdit.tsx +++ b/src/InputClickEdit/InputClickEdit.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, forwardRef, useCallback } from "react"; import { LuPencil } from "react-icons/lu"; import { LuCheck } from "react-icons/lu"; import cn from "classnames"; @@ -12,113 +12,144 @@ type InputClickEditProps = { editButtonClassName?: string; saveButtonClassName?: string; editWrapperClassName?: string; - value?: string; saveButtonLabel?: React.ReactNode; editButtonLabel?: React.ReactNode; label?: string; - inputType?: string; showIcons?: boolean; editIcon?: React.ElementType; saveIcon?: React.ElementType; iconPosition?: "left" | "right"; iconsOnly?: boolean; onEditButtonClick?: () => void; - onInputChange?: (value: string) => void; onSaveButtonClick?: () => void; -}; +} & React.InputHTMLAttributes; -const InputClickEdit = ({ - className = "", - inputClassName = "", - editButtonClassName = "", - saveButtonClassName = "", - editWrapperClassName = "", - value = "", - inputType = "text", - isEditing = false, - saveButtonLabel = "Save", - editButtonLabel = "Edit", - label = "", - showIcons = false, - saveIcon, - editIcon, - iconsOnly = false, - iconPosition = "left", - onEditButtonClick = () => {}, - onInputChange = () => {}, - onSaveButtonClick = () => {}, -}: InputClickEditProps) => { - const [editing, setEditing] = useState(isEditing); - useEffect(() => { - setEditing(isEditing); - }, [isEditing]); - const onEditClick = () => { - setEditing(true); - onEditButtonClick?.(); - }; +const InputClickEdit = forwardRef( + ( + { + className = "", + inputClassName = "", + editButtonClassName = "", + saveButtonClassName = "", + editWrapperClassName = "", + value, + defaultValue, + type = "text", + isEditing = false, + saveButtonLabel = "Save", + editButtonLabel = "Edit", + label = "", + showIcons = false, + saveIcon, + editIcon, + iconsOnly = false, + iconPosition = "left", + onEditButtonClick = () => {}, + onChange, + onSaveButtonClick = () => {}, + ...rest + }, + ref + ) => { + const [editing, setEditing] = useState(isEditing); + const [internalValue, setInternalValue] = useState( + () => value ?? defaultValue + ); + const isControlled = value !== undefined; - const onChange = (e: React.ChangeEvent) => { - onInputChange?.(e.target.value); - }; + useEffect(() => { + setEditing(isEditing); + }, [isEditing]); - const handleSave = () => { - setEditing(false); - onSaveButtonClick?.(); - }; + useEffect(() => { + if (isControlled) { + setInternalValue(value); + } + }, [value, isControlled]); - const inputProps = { - className: cn(styles.input, inputClassName), - onChange, - value, - type: inputType, - }; - const buttonBaseClassName = { - [styles.button]: true, - [styles.buttonReverse]: iconPosition === "right", - }; + const onEditClick = useCallback(() => { + setEditing(true); + onEditButtonClick?.(); + }, [onEditButtonClick]); - const EditIcon = editIcon || LuPencil; - const SaveIcon = saveIcon || LuCheck; + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (!isControlled) { + setInternalValue(newValue); + } + onChange?.(e); + }, + [onChange, isControlled] + ); - return ( -
- {editing ? ( -
- {label ? ( -
+ ) : ( +
+ {isControlled ? value : internalValue} + +
+ )} +
+ ); + } +); + +InputClickEdit.displayName = "InputClickEdit"; export { InputClickEdit }; export type { InputClickEditProps };