Files
fluxer/fluxer_app/src/features/ui/components/InlineEdit.tsx
T

242 lines
6.5 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import styles from '@app/features/ui/components/InlineEdit.module.css';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
interface InlineEditProps {
value: string;
onSave: (value: string) => Promise<void> | void;
prefix?: string;
suffix?: string;
placeholder?: string;
maxLength?: number;
validate?: (value: string) => boolean;
className?: string;
inputClassName?: string;
buttonClassName?: string;
width?: number | string;
allowEmpty?: boolean;
}
type Mode = 'idle' | 'editing' | 'saving';
function sanitizeDraft(draft: string): string {
return draft.replace(/[\r\n\t]/g, '');
}
export const InlineEdit: React.FC<InlineEditProps> = observer((props) => {
const {
className = '',
inputClassName = '',
buttonClassName = '',
placeholder,
width,
onSave,
value,
prefix = '',
suffix = '',
maxLength,
validate,
allowEmpty = false,
} = props;
const [mode, setMode] = useState<Mode>('idle');
const [draft, setDraft] = useState<string>(value);
const [error, setError] = useState<string | null>(null);
const editableRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (mode === 'idle') {
setDraft(value);
}
}, [value, mode]);
const fieldStyle = useMemo<React.CSSProperties | undefined>(() => {
if (!width) return undefined;
return {
minWidth: typeof width === 'number' ? `${width}px` : width,
};
}, [width]);
const canSave = (raw: string): boolean => {
const trimmed = raw.trim();
if (trimmed === value.trim()) return true;
if (!trimmed.length) return !!allowEmpty;
if (validate && !validate(trimmed)) return false;
return true;
};
const startEdit = () => {
setError(null);
setDraft(value);
setMode('editing');
};
useEffect(() => {
if (mode !== 'editing') return;
const el = editableRef.current;
if (!el) return;
el.textContent = draft;
requestAnimationFrame(() => {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
});
}, [mode]);
const cancelEdit = () => {
setError(null);
setDraft(value);
setMode('idle');
};
const doSave = async () => {
const next = draft.trim();
if (!canSave(next)) {
setError('VALIDATION_FAILED');
return;
}
if (next === value.trim()) {
setMode('idle');
return;
}
setMode('saving');
setError(null);
try {
await Promise.resolve(onSave(next));
setMode('idle');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'SAVE_FAILED');
setMode('editing');
}
};
const handleEditableInput: React.FormEventHandler<HTMLDivElement> = (e) => {
const el = e.currentTarget;
const raw = el.textContent ?? '';
let next = sanitizeDraft(raw);
if (typeof maxLength === 'number' && maxLength > 0 && next.length > maxLength) {
next = next.slice(0, maxLength);
}
if (next !== raw) {
el.textContent = next;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
}
setError(null);
setDraft(next);
};
const handleEditableKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (isIMEComposing(e)) {
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void doSave();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancelEdit();
editableRef.current?.blur();
return;
}
};
const handleEditableBlur: React.FocusEventHandler<HTMLDivElement> = () => {
if (!canSave(draft)) {
cancelEdit();
} else {
void doSave();
}
};
const isEditing = mode === 'editing' || mode === 'saving';
const hasValue = value.trim().length > 0;
const showPlaceholder = !hasValue && !isEditing;
if (!isEditing) {
return (
<div className={clsx(styles.container, className)} data-flx="ui.inline-edit.container">
<FocusRing offset={-2} data-flx="ui.inline-edit.focus-ring">
<button
type="button"
onClick={startEdit}
className={clsx(styles.idleButton, {[styles.placeholder]: showPlaceholder})}
style={fieldStyle}
data-flx="ui.inline-edit.idle-button.start-edit"
>
<span className={clsx(styles.wrapper, buttonClassName)} data-flx="ui.inline-edit.wrapper">
{prefix && (
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base">
{prefix}
</span>
)}
<span
className={clsx(styles.inlineTextBase, styles.text, inputClassName)}
data-flx="ui.inline-edit.inline-text-base--2"
>
{hasValue ? value : placeholder || ''}
</span>
{suffix && (
<span
className={clsx(styles.inlineTextBase, styles.affix)}
data-flx="ui.inline-edit.inline-text-base--3"
>
{suffix}
</span>
)}
</span>
</button>
</FocusRing>
{error && (
<span className={styles.error} data-flx="ui.inline-edit.error">
{error}
</span>
)}
</div>
);
}
return (
<div className={clsx(styles.container, className)} data-flx="ui.inline-edit.container--2">
<div className={clsx(styles.wrapper, buttonClassName)} style={fieldStyle} data-flx="ui.inline-edit.wrapper--2">
{prefix && (
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base--4">
{prefix}
</span>
)}
<div
ref={editableRef}
className={clsx(styles.inlineTextBase, styles.text, styles.editable, inputClassName)}
contentEditable
suppressContentEditableWarning
onInput={handleEditableInput}
onKeyDown={handleEditableKeyDown}
onBlur={handleEditableBlur}
data-placeholder={placeholder ?? ''}
role="textbox"
tabIndex={0}
data-flx="ui.inline-edit.inline-text-base.editable-input"
/>
{suffix && (
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base--5">
{suffix}
</span>
)}
</div>
{error && (
<span className={styles.error} data-flx="ui.inline-edit.error--2">
{error}
</span>
)}
</div>
);
});