/** * @title Inline Edit * @description 읽기 뷰와 편집 뷰를 오가며 페이지를 떠나지 않고 값을 바로 고칠 수 있게 해요. */import * as React from 'react';import { Check, CircleAlert, X } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/inline-edit — inline-edit.js, internal/read-view.js, internal/buttons.js,// inline-editable-textfield.js 스펙 (폼 라벨·메시지는 @atlaskit/form label/messages)/* ------------------------------------------------------------------------------------------------- * Types * -----------------------------------------------------------------------------------------------*//** editView 에 넘겨주는 필드 props (Atlaskit Form Field 의 fieldProps + errorMessage) */export interface InlineEditFieldProps<FieldValue> { id: string; name: string; value: FieldValue; /** 이벤트(e.target.value 사용) 또는 값 자체를 받아요. */ onChange: (eventOrValue: unknown) => void; onBlur: () => void; onFocus: () => void; isInvalid: boolean; isRequired: boolean; isDisabled: boolean; 'aria-invalid': 'true' | 'false'; 'aria-labelledby'?: string; 'aria-describedby'?: string; /** validate 가 돌려준 오류 메시지 */ errorMessage?: string;}interface CommonProps<FieldValue> { /** 편집 뷰 확인 버튼의 접근성 이름. 기본 "Confirm" */ confirmButtonLabel?: string; /** 편집 뷰 취소 버튼의 접근성 이름. 기본 "Cancel" */ cancelButtonLabel?: string; /** 편집을 시작할 때 필드에 들어갈 값 */ defaultValue: FieldValue; /** 필드 위 라벨 */ label?: React.ReactNode; /** 오류 메시지(문자열)를 돌려주면 확인을 막고 메시지를 보여줘요. Promise 도 돼요. */ validate?: (value: FieldValue) => string | void | undefined | Promise<string | void | undefined>; /** true 면 필드 밖을 눌러도 편집 뷰가 닫히지(자동 저장되지) 않아요. 긴 텍스트에 권장 */ keepEditViewOpenOnBlur?: boolean; /** 확인·취소 버튼을 숨겨요. Enter 로 확인, Esc 로 취소 */ hideActionButtons?: boolean; /** 라벨 옆에 필수 표시(*)를 붙여요. */ isRequired?: boolean; /** 읽기 뷰가 부모 너비를 꽉 채워요. */ readViewFitContainerWidth?: boolean; /** 읽기 뷰의 숨은 편집 버튼 이름 앞부분. 기본 "Edit" */ editButtonLabel?: string; /** 읽기 뷰의 숨은 편집 버튼 이름 뒷부분. 기본 "edit" */ editLabel?: string; onCancel?: () => void; /** 처음부터 편집 뷰로 시작해요. */ startWithEditViewOpen?: boolean; testId?: string;}export interface InlineEditProps<FieldValue> extends CommonProps<FieldValue> { /** 편집 뷰. fieldProps 를 입력 컴포넌트에 펼치고 ref 를 연결해요. */ editView: (fieldProps: InlineEditFieldProps<FieldValue>, ref: React.RefObject<any>) => React.ReactNode; /** 읽기 뷰 */ readView: () => React.ReactNode; /** 확인했을 때 새 값과 함께 호출돼요. */ onConfirm: (value: FieldValue) => void; /** 읽기 뷰를 눌러 편집을 요청할 때 호출돼요. */ onEdit?: () => void; /** 지정하면 편집 상태를 직접 제어해요. */ isEditing?: boolean; className?: string;}/* ------------------------------------------------------------------------------------------------- * 내부 조각 * -----------------------------------------------------------------------------------------------*/function getTextFromReactNode(node: React.ReactNode): string { if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(getTextFromReactNode).join(''); if (React.isValidElement<{ children?: React.ReactNode }>(node)) return getTextFromReactNode(node.props.children); return '';}// @atlaskit/form Labelfunction FieldLabel({ id, htmlFor, children, isRequired }: { id: string; htmlFor?: string; children: React.ReactNode; isRequired?: boolean }) { return ( <label id={id} htmlFor={htmlFor} data-slot="inline-edit-label" className="mt-ds-0 mb-ds-050 inline-block ds-font-body-small font-ds-bold text-ds-text-subtle" > {children} {isRequired && ( <span aria-hidden className="ps-ds-025 text-ds-text-danger"> * </span> )} </label> );}// internal/buttons — 편집 뷰 오른쪽 아래에 떠 있는 확인·취소 버튼const actionWrapperClass = 'z-[200] box-border w-ds-400 rounded-ds-medium bg-ds-surface-overlay ds-font-body shadow-ds-overlay';const actionButtonClass = 'bg-ds-surface-overlay hover:bg-ds-surface-overlay-hovered active:bg-ds-surface-overlay-pressed active:text-ds-text [&_svg]:size-3';function InlineEditButtons({ confirmButtonLabel, cancelButtonLabel, onMouseDown, onCancelClick, testId,}: { confirmButtonLabel: string; cancelButtonLabel: string; onMouseDown: () => void; onCancelClick: (e: React.MouseEvent<HTMLButtonElement>) => void; testId?: string;}) { return ( <div data-slot="inline-edit-buttons" className="absolute end-0 top-full mt-ds-075 flex shrink-0 gap-ds-050"> <div tabIndex={-1} className={actionWrapperClass}> <IconButton type="submit" icon={<Check />} label={confirmButtonLabel} onMouseDown={onMouseDown} className={actionButtonClass} data-testid={testId && `${testId}--confirm`} /> </div> <div tabIndex={-1} className={actionWrapperClass}> <IconButton type="button" icon={<X />} label={cancelButtonLabel} onMouseDown={onMouseDown} onClick={onCancelClick} className={actionButtonClass} data-testid={testId && `${testId}--cancel`} /> </div> </div> );}const DRAG_THRESHOLD = 5;// internal/read-viewfunction InlineEditReadView({ editButtonLabel, onEditRequested, postReadViewClick, editButtonRef, readViewFitContainerWidth, readView, testId,}: { editButtonLabel: string; onEditRequested: () => void; postReadViewClick: () => void; editButtonRef: React.RefObject<HTMLButtonElement | null>; readViewFitContainerWidth?: boolean; readView: () => React.ReactNode; testId?: string;}) { const start = React.useRef({ x: 0, y: 0 }); const onReadViewClick = (event: React.MouseEvent<HTMLDivElement>) => { const element = event.target as HTMLElement; const moved = Math.abs(start.current.x - event.clientX) >= DRAG_THRESHOLD || Math.abs(start.current.y - event.clientY) >= DRAG_THRESHOLD; // 읽기 뷰 안의 링크는 기본 동작을 지키고, 텍스트를 드래그해 선택한 경우도 편집으로 넘어가지 않는다 if (element.tagName.toLowerCase() !== 'a' && !moved) { event.preventDefault(); onEditRequested(); postReadViewClick(); } }; return ( <div className="leading-none"> {/* 키보드 사용자를 위한 크기 0 편집 버튼. 포커스되면 옆 읽기 뷰에 포커스 테두리를 그린다 */} <button ref={editButtonRef} type="button" onClick={onEditRequested} aria-label={editButtonLabel} data-testid={testId && `${testId}--edit-button`} className="peer m-ds-0 block appearance-none border-0 bg-transparent p-ds-0 leading-none outline-none" /> <div role="presentation" onClick={onReadViewClick} onMouseDown={(e) => { start.current = { x: e.clientX, y: e.clientY }; }} data-slot="inline-edit-read-view" data-read-view-fit-container-width={readViewFitContainerWidth || undefined} className={cn( 'box-border inline-block max-w-full cursor-pointer rounded-ds-medium border-2 border-solid border-transparent', 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', 'hover:bg-ds-background-neutral-subtle-hovered peer-focus-visible:border-ds-border-focused', readViewFitContainerWidth ? 'w-full' : 'w-auto', )} > {readView()} </div> </div> );}/* ------------------------------------------------------------------------------------------------- * InlineEdit * -----------------------------------------------------------------------------------------------*/function InlineEdit<FieldValue = string>({ startWithEditViewOpen = false, keepEditViewOpenOnBlur = false, hideActionButtons = false, isRequired = false, readViewFitContainerWidth = false, editButtonLabel = 'Edit', editLabel = 'edit', confirmButtonLabel = 'Confirm', cancelButtonLabel = 'Cancel', defaultValue, isEditing, label, validate, readView, editView, onConfirm, onCancel, onEdit, testId, className,}: InlineEditProps<FieldValue>) { const baseId = React.useId(); const fieldId = `${baseId}-field`; const labelId = `${baseId}-label`; const errorId = `${baseId}-error`; const isUncontrolled = typeof isEditing === 'undefined'; const [isEditingState, setEditingState] = React.useState(startWithEditViewOpen); const shouldBeEditing = isUncontrolled ? isEditingState : !!isEditing; const [value, setValue] = React.useState<FieldValue>(defaultValue); const [error, setError] = React.useState<string | undefined>(undefined); const [touched, setTouched] = React.useState(false); const formRef = React.useRef<HTMLFormElement>(null); const editViewRef = React.useRef<any>(null); const editButtonRef = React.useRef<HTMLButtonElement>(null); const focusReceivedRef = React.useRef(false); const shouldFocusEditButtonRef = React.useRef(false); const preventFocusOnEditButtonRef = React.useRef(false); const blurTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined); const wasEditingRef = React.useRef(shouldBeEditing); // 편집 뷰가 열릴 때마다 값·오류를 초기화한다 (Atlaskit Field key 리셋과 같음) React.useEffect(() => { if (shouldBeEditing && !wasEditingRef.current) { setValue(defaultValue); setError(undefined); setTouched(false); } // 편집이 끝나면 키보드 사용자를 위해 편집 버튼으로 포커스를 돌려준다 if (!shouldBeEditing && wasEditingRef.current) { if (shouldFocusEditButtonRef.current && !preventFocusOnEditButtonRef.current) { editButtonRef.current?.focus(); } shouldFocusEditButtonRef.current = false; preventFocusOnEditButtonRef.current = false; } wasEditingRef.current = shouldBeEditing; }, [shouldBeEditing, defaultValue]); React.useEffect(() => () => clearTimeout(blurTimerRef.current), []); const runValidate = React.useCallback( async (next: FieldValue) => { if (!validate) return undefined; const result = await validate(next); return typeof result === 'string' && result ? result : undefined; }, [validate], ); const doNotFocusOnEditButton = () => { preventFocusOnEditButtonRef.current = true; }; const requestEdit = () => { if (isUncontrolled) setEditingState(true); onEdit?.(); if (shouldBeEditing) editViewRef.current?.focus?.(); }; const cancel = () => { clearTimeout(blurTimerRef.current); shouldFocusEditButtonRef.current = true; setValue(defaultValue); setError(undefined); if (isUncontrolled) setEditingState(false); onCancel?.(); }; const submit = async () => { const nextError = await runValidate(value); setTouched(true); setError(nextError); if (nextError) return; shouldFocusEditButtonRef.current = true; if (isUncontrolled) setEditingState(false); onConfirm(value); }; const handleChange = (eventOrValue: unknown) => { const next = eventOrValue && typeof eventOrValue === 'object' && 'target' in eventOrValue ? ((eventOrValue as React.ChangeEvent<HTMLInputElement>).target.value as unknown as FieldValue) : (eventOrValue as FieldValue); setValue(next); setTouched(true); // 입력 중에도 바로 검사해서 메시지를 갱신한다 void runValidate(next).then(setError); }; const isInvalid = touched && !!error; // 필드 밖으로 포커스가 나가면 (확인 버튼이 아니라면) 자동으로 확인한다 const onEditViewWrapperBlur = () => { if (keepEditViewOpenOnBlur) return; focusReceivedRef.current = false; blurTimerRef.current = setTimeout(() => { if (!isInvalid && !focusReceivedRef.current && formRef.current) { doNotFocusOnEditButton(); if (formRef.current.checkValidity()) void submit(); } }, 0); }; const editButtonFullLabel = label ? `${editButtonLabel}, ${typeof label === 'string' ? label : getTextFromReactNode(label)}, ${editLabel}` : `${editButtonLabel}, ${editLabel}`; const fieldProps: InlineEditFieldProps<FieldValue> = { id: fieldId, name: 'inlineEdit', value, onChange: handleChange, onBlur: () => setTouched(true), onFocus: () => {}, isInvalid, isRequired, isDisabled: false, 'aria-invalid': isInvalid ? 'true' : 'false', 'aria-labelledby': label ? labelId : undefined, 'aria-describedby': isInvalid ? errorId : undefined, errorMessage: isInvalid ? error : undefined, }; return ( <form ref={formRef} role="presentation" data-slot="inline-edit" data-editing={shouldBeEditing || undefined} data-testid={testId} className={className} onKeyDown={(e) => { if (e.key === 'Esc' || e.key === 'Escape') { e.preventDefault(); cancel(); } // 버튼이 없는 여러 줄 입력에서도 Ctrl/Cmd + Enter 로 확인 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); } }} onSubmit={(e) => { e.preventDefault(); void submit(); }} > {/* Field 래퍼 (라벨 + 간격) */} <div className="mt-ds-100"> {label && ( <FieldLabel id={labelId} htmlFor={shouldBeEditing ? fieldId : undefined} isRequired={isRequired}> {label} </FieldLabel> )} {shouldBeEditing ? ( <div className="relative max-w-full" onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onEditViewWrapperBlur(); }} onFocus={() => { focusReceivedRef.current = true; }} > {editView(fieldProps, editViewRef)} {!hideActionButtons ? ( <InlineEditButtons testId={testId} confirmButtonLabel={confirmButtonLabel} cancelButtonLabel={cancelButtonLabel} onMouseDown={doNotFocusOnEditButton} onCancelClick={(e) => { e.preventDefault(); cancel(); }} /> ) : ( <button type="submit" hidden> Submit </button> )} {isInvalid && ( <span id={errorId} className="sr-only" role="alert"> {error} </span> )} </div> ) : ( <InlineEditReadView editButtonLabel={editButtonFullLabel} onEditRequested={requestEdit} postReadViewClick={doNotFocusOnEditButton} editButtonRef={editButtonRef} readViewFitContainerWidth={readViewFitContainerWidth} readView={readView} testId={testId} /> )} </div> </form> );}/* ------------------------------------------------------------------------------------------------- * InlineEditValidationMessage — 편집 뷰 옆에 뜨는 오류 말풍선 (Atlaskit 은 InlineDialog 사용) * -----------------------------------------------------------------------------------------------*/function InlineEditValidationMessage({ className, children, ...props }: React.ComponentProps<'div'>) { if (!children) return null; return ( <div aria-hidden data-slot="inline-edit-validation-message" className={cn( 'absolute start-0 top-full z-[300] mt-ds-075 max-w-[280px] rounded-ds-medium bg-ds-surface-overlay px-ds-200 py-ds-100', 'ds-font-body text-ds-text shadow-ds-overlay', 'animate-in fade-in-0 duration-150', className, )} {...props} > {children} </div> );}/* ------------------------------------------------------------------------------------------------- * InlineEditTextfield — Atlaskit Textfield 의 standard 모양 (편집 뷰 입력) * -----------------------------------------------------------------------------------------------*/export interface InlineEditTextfieldProps extends Omit<React.ComponentProps<'input'>, 'value' | 'onChange'> { value?: string; onChange?: React.ChangeEventHandler<HTMLInputElement>; isInvalid?: boolean; isCompact?: boolean; elemAfterInput?: React.ReactNode;}function InlineEditTextfield({ isInvalid, isCompact, elemAfterInput, className, ...props }: InlineEditTextfieldProps) { return ( <div data-slot="inline-edit-textfield" data-invalid={isInvalid || undefined} className={cn( 'box-border flex w-full cursor-text items-center justify-between overflow-hidden rounded-ds-medium align-top break-words', 'border-(length:--ds-border-width) border-solid border-ds-border-input bg-ds-background-input py-(--ds-border-width) text-ds-text ds-font-body', 'transition-[background-color,border-color,box-shadow] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', 'hover:border-ds-border-input hover:bg-ds-background-input-hovered', 'has-[input:focus]:border-ds-border-focused has-[input:focus]:bg-ds-background-input-pressed has-[input:focus]:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-focused)]', isInvalid && 'border-ds-border-danger shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-danger)] hover:border-ds-border-danger has-[input:focus]:border-ds-border-danger has-[input:focus]:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-danger)]', className, )} > <input data-compact={isCompact || undefined} className={cn( 'box-border w-full min-w-0 border-0 bg-transparent px-ds-075 ds-font-body text-inherit outline-none', 'placeholder:text-ds-text-subtlest', isCompact ? 'py-ds-050' : 'py-ds-100', )} {...props} /> {elemAfterInput} </div> );}/* ------------------------------------------------------------------------------------------------- * InlineEditableTextfield — 한 줄 텍스트용 권장 기본 설정 * -----------------------------------------------------------------------------------------------*/export interface InlineEditableTextfieldProps extends CommonProps<string> { /** 확인했을 때 새 문자열과 함께 호출돼요. */ onConfirm: (value: string) => void; /** 값이 비어 있을 때 읽기 뷰에 보여줄 문구 */ placeholder: string; /** 좁은 간격(32px 높이) */ isCompact?: boolean; className?: string;}function InlineEditableTextfield({ isCompact = false, defaultValue, placeholder, testId, ...props }: InlineEditableTextfieldProps) { return ( <InlineEdit<string> {...props} testId={testId} defaultValue={defaultValue} editView={({ errorMessage, isInvalid, isRequired, isDisabled, onFocus, onBlur, value, ...fieldProps }, ref) => ( <div className="relative"> <InlineEditTextfield {...fieldProps} ref={ref} value={value ?? ''} onBlur={onBlur} onFocus={onFocus} required={isRequired} disabled={isDisabled} isInvalid={isInvalid} isCompact={isCompact} placeholder={placeholder} autoFocus data-testid={testId} elemAfterInput={ isInvalid ? ( <span className="flex pe-ds-075 leading-[100%]"> <CircleAlert aria-label="error" className="size-3 text-ds-icon-danger" /> </span> ) : null } /> <InlineEditValidationMessage>{errorMessage}</InlineEditValidationMessage> </div> )} readView={() => ( <div data-compact={isCompact || undefined} data-testid={testId && `read-view-${testId}`} className={cn( 'flex max-w-full px-ds-075 ds-font-body break-words text-ds-text', isCompact ? 'py-ds-050' : 'py-ds-100', !defaultValue && 'text-ds-text-subtlest', )} > {defaultValue || placeholder} </div> )} /> );}export { InlineEdit, InlineEditableTextfield, InlineEditTextfield, InlineEditValidationMessage };