import clsx from 'clsx'; import type { CSSProperties, ForwardedRef, HTMLAttributes, KeyboardEvent, PropsWithChildren, } from 'react'; import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import Input from '../input'; import * as styles from './inline-edit.css'; export interface InlineEditHandle { triggerEdit: () => void; } export interface InlineEditProps extends Omit, 'onChange' | 'onInput'> { /** * Content to be displayed */ value?: string; /** * Whether the content is editable */ editable?: boolean; /** * Whether to exit when pressing `Escape` */ exitible?: boolean; onInput?: (v: string) => void; onChange?: (v: string) => void; /** * Trigger edit by `click` or `doubleClick` * @default `'doubleClick'` */ trigger?: 'click' | 'doubleClick'; /** * whether to auto select all text when trigger edit */ autoSelect?: boolean; /** * Placeholder when value is empty */ placeholder?: string; /** * Custom placeholder `className` */ placeholderClassName?: string; /** * Custom placeholder `style` */ placeholderStyle?: CSSProperties; handleRef?: ForwardedRef; /** * Customize attrs for the input */ inputAttrs?: Omit, 'onChange' | 'onBlur'>; } export const InlineEdit = ({ value, editable, exitible, className, style, trigger = 'doubleClick', autoSelect, onInput, onChange, placeholder, placeholderClassName, placeholderStyle, handleRef, inputAttrs, ...attrs }: InlineEditProps) => { const [editing, setEditing] = useState(false); const [editingValue, setEditingValue] = useState(value); const inputRef = useRef(null); useImperativeHandle(handleRef, () => ({ triggerEdit, })); const triggerEdit = useCallback(() => { if (!editable) return; setEditing(true); setTimeout(() => { inputRef.current?.focus(); autoSelect && inputRef.current?.select(); }, 0); }, [autoSelect, editable]); const onDoubleClick = useCallback(() => { if (trigger !== 'doubleClick') return; triggerEdit(); }, [triggerEdit, trigger]); const onClick = useCallback(() => { if (trigger !== 'click') return; triggerEdit(); }, [triggerEdit, trigger]); const submit = useCallback(() => { onChange?.(editingValue || ''); }, [editingValue, onChange]); const onEnter = useCallback(() => { inputRef.current?.blur(); }, []); const onBlur = useCallback(() => { setEditing(false); submit(); // to reset input's scroll position to match actual display inputRef.current?.scrollTo(0, 0); }, [submit]); const onKeyDown = useCallback( (e: KeyboardEvent) => { e.stopPropagation(); if (!exitible) return; if (e.key !== 'Escape') return; inputRef.current?.blur(); }, [exitible] ); const inputHandler = useCallback( (v: string) => { setEditingValue(v); onInput?.(v); }, [onInput] ); // update editing value when value prop changes useEffect(() => { setEditingValue(value); }, [value]); // to make sure text is not wrapped, and multi-space is shown normally const displayValue = useMemo(() => { return editingValue ? editingValue.replace(/\n/g, '') : ''; }, [editingValue]); // to make sure input's style is the same as displayed text const inputWrapperInheritsStyles = { margin: 'inherit', padding: 'inherit', borderRadius: 'inherit', fontSize: 'inherit', fontFamily: 'inherit', lineHeight: 'inherit', fontWeight: 'inherit', letterSpacing: 'inherit', textAlign: 'inherit', color: 'inherit', backgroundColor: 'inherit', } as CSSProperties; const inputInheritsStyles = { ...inputWrapperInheritsStyles, padding: 0, margin: 0, }; return (
{/* display area, will be transparent when input */}
{displayValue} {!displayValue && ( )}
{/* actual input */} { }
); }; interface PlaceholderProps extends PropsWithChildren, HTMLAttributes { label?: string; } const Placeholder = ({ label, children, className, style, ...attrs }: PlaceholderProps) => { return (
{children ?? label}
); };