"use client" import * as React from "react"; import { cn } from "../../../lib/utils"; import { inputClass, TEXTAREA_CLASS } from "../input"; // ============================================================================= // Types // ============================================================================= export interface EditableRootProps extends Omit, "value" | "defaultValue" | "onChange" | "onSubmit" | "children"> { /** Controlled value */ value?: string; /** Default uncontrolled value */ defaultValue?: string; /** Callback when value changes */ onValueChange?: (value: string) => void; /** Callback when value is submitted (Enter or blur) */ onValueSubmit?: (value: string) => void; /** Callback when editing is cancelled (Escape) */ onValueCancel?: (previousValue: string) => void; /** Whether editing is active (controlled) */ editing?: boolean; /** Callback when editing state changes */ onEditingChange?: (editing: boolean) => void; /** Whether the editable is disabled. @default false */ disabled?: boolean; /** Whether the editable is read-only. @default false */ readOnly?: boolean; /** Whether to select all text on focus. @default true */ selectAllOnFocus?: boolean; /** Whether to submit on blur. @default true */ submitOnBlur?: boolean; /** Placeholder when empty */ placeholder?: string; /** Form field name */ name?: string; /** Children or render prop */ children?: React.ReactNode; } export interface EditablePreviewProps extends React.ComponentPropsWithoutRef<"span"> {} export interface EditableInputProps extends Omit, "value" | "defaultValue"> { /** Density — matches the standalone Input's `inputSize`. Default: 'default'. */ inputSize?: 'default' | 'sm'; } export interface EditableTextareaProps extends Omit, "value" | "defaultValue"> {} // ============================================================================= // Context // ============================================================================= interface EditableContextValue { value: string; setValue: (value: string) => void; isEditing: boolean; setIsEditing: (editing: boolean) => void; submit: () => void; cancel: () => void; disabled: boolean; readOnly: boolean; selectAllOnFocus: boolean; submitOnBlur: boolean; placeholder?: string; inputRef: React.RefObject; textareaRef: React.RefObject; previousValueRef: React.MutableRefObject; } const EditableContext = React.createContext(null); function useEditable(componentName: string) { const context = React.useContext(EditableContext); if (!context) { throw new Error(`${componentName} must be used within `); } return context; } // ============================================================================= // Root // ============================================================================= const Editable = React.forwardRef( (props, ref) => { const { value: valueProp, defaultValue = "", onValueChange, onValueSubmit, onValueCancel, editing: editingProp, onEditingChange, disabled = false, readOnly = false, selectAllOnFocus = true, submitOnBlur = true, placeholder, name, children, className, ...rootProps } = props; const isControlledValue = valueProp !== undefined; const isControlledEditing = editingProp !== undefined; const [internalValue, setInternalValue] = React.useState(defaultValue); const [internalEditing, setInternalEditing] = React.useState(false); const resolvedValue = isControlledValue ? valueProp : internalValue; const resolvedEditing = isControlledEditing ? editingProp : internalEditing; const previousValueRef = React.useRef(resolvedValue); const inputRef = React.useRef(null); const textareaRef = React.useRef(null); const isFormControl = React.useRef(false); const rootRef = React.useRef(null); React.useImperativeHandle(ref, () => rootRef.current!); React.useEffect(() => { if (rootRef.current) { isFormControl.current = !!rootRef.current.closest("form"); } }, []); const setValue = React.useCallback( (newValue: string) => { if (!isControlledValue) { setInternalValue(newValue); } onValueChange?.(newValue); }, [isControlledValue, onValueChange] ); const setEditing = React.useCallback( (editing: boolean) => { if (!isControlledEditing) { setInternalEditing(editing); } onEditingChange?.(editing); }, [isControlledEditing, onEditingChange] ); const submit = React.useCallback(() => { const currentValue = resolvedValue; previousValueRef.current = currentValue; onValueSubmit?.(currentValue); setEditing(false); }, [resolvedValue, onValueSubmit, setEditing]); const cancel = React.useCallback(() => { const previousValue = previousValueRef.current; setValue(previousValue); onValueCancel?.(previousValue); setEditing(false); }, [setValue, onValueCancel, setEditing]); const startEditing = React.useCallback(() => { if (disabled || readOnly) return; previousValueRef.current = resolvedValue; setEditing(true); }, [disabled, readOnly, resolvedValue, setEditing]); React.useEffect(() => { if (resolvedEditing) { requestAnimationFrame(() => { inputRef.current?.focus(); textareaRef.current?.focus(); }); } }, [resolvedEditing]); return (
{children} {isFormControl.current && name && ( )}
); } ); Editable.displayName = "Editable"; // ============================================================================= // Preview // ============================================================================= const EditablePreview = React.forwardRef( (props, ref) => { const context = useEditable("EditablePreview"); if (context.isEditing) return null; return ( { props.onClick?.(event); if (!context.disabled && !context.readOnly) { context.setIsEditing(true); } }} onKeyDown={(event) => { props.onKeyDown?.(event); if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (!context.disabled && !context.readOnly) { context.setIsEditing(true); } } }} {...props} > {context.value || context.placeholder || "Click to edit"} ); } ); EditablePreview.displayName = "EditablePreview"; // ============================================================================= // Input // ============================================================================= const EditableInput = React.forwardRef( ({ inputSize = 'default', className, onChange, onKeyDown, onBlur, onFocus, ...props }, ref) => { const context = useEditable("EditableInput"); const composedRef = React.useCallback( (node: HTMLInputElement | null) => { context.inputRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }, [ref, context.inputRef] ); if (!context.isEditing) return null; return ( { onChange?.(event); context.setValue(event.target.value); }} onKeyDown={(event) => { onKeyDown?.(event); switch (event.key) { case "Enter": event.preventDefault(); context.submit(); break; case "Escape": event.preventDefault(); context.cancel(); break; } }} onBlur={(event) => { onBlur?.(event); if (context.submitOnBlur) { context.submit(); } }} onFocus={(event) => { onFocus?.(event); if (context.selectAllOnFocus) { event.target.select(); } }} /> ); } ); EditableInput.displayName = "EditableInput"; // ============================================================================= // Textarea // ============================================================================= const EditableTextarea = React.forwardRef( (props, ref) => { const context = useEditable("EditableTextarea"); const composedRef = React.useCallback( (node: HTMLTextAreaElement | null) => { context.textareaRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }, [ref, context.textareaRef] ); if (!context.isEditing) return null; return (