feat: inline-редактирование полей через overlay как в Twenty

- Добавлены компоненты InlineField и FieldOverlay для рендера editor поверх read-view
- TaskDetail: FieldValueRow, title и due date переведены на overlay-редактирование
- TaskRegistry: ячейки реестра используют InlineField
- TaskRegularTable: ячейки таблицы-вкладки используют InlineField
- RegistryCellEditor: textarea на AutoResizeTextarea с minRows=1/maxRows=8
- Поля не разбегаются при входе в режим редактирования, textarea растёт внутри оверлея
This commit is contained in:
2026-07-14 16:10:15 +03:00
parent 39e0776d58
commit 507c36f7fa
6 changed files with 408 additions and 132 deletions

View File

@@ -9,6 +9,7 @@ import ContractNumberField from '@/components/ContractNumberField';
import ContactField from '@/components/ContactField';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
interface FormFieldData {
id: number;
@@ -166,9 +167,10 @@ export default function RegistryCellEditor({
case 'textarea':
return (
<textarea
className={`${inputClass} resize-none`}
rows={2}
<AutoResizeTextarea
className={`${inputClass} resize-none px-1 py-0`}
minRows={1}
maxRows={8}
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -36,6 +36,7 @@ import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import RegistryCellEditor from './RegistryCellEditor';
import { InlineField } from '@/components/fields/InlineField';
interface TaskAssigneeInfo {
userId: number;
@@ -1059,18 +1060,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
const rawValue = task.fieldValues?.[fieldId];
return field ? (
<TableCell key={key} className="py-2">
{isEditing(task.id, key) ? (
<RegistryCellEditor
taskId={task.id}
field={field}
value={rawValue}
users={users}
roles={roles}
formId={formId}
onSaved={() => setEditingCell(null)}
onCancel={() => setEditingCell(null)}
/>
) : field.type === 'contract-number' ? (
{field.type === 'contract-number' ? (
<span className="text-sm">
<ContractNumberDisplay
value={rawValue}
@@ -1089,14 +1079,35 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</button>
) : REGISTRY_INLINE_TYPES.has(field.type) ? (
<div
className="cursor-pointer"
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
>
<span className="text-sm">
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
</div>
<InlineField
readView={
<span className="text-sm">
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
}
editor={
<RegistryCellEditor
taskId={task.id}
field={field}
value={rawValue}
users={users}
roles={roles}
formId={formId}
onSaved={() => setEditingCell(null)}
onCancel={() => setEditingCell(null)}
/>
}
isEditing={editingCell?.taskId === task.id && editingCell?.columnKey === key}
editable={true}
onEnterEdit={() => setEditingCell({ taskId: task.id, columnKey: key })}
onExitEdit={() => setEditingCell(null)}
fitOverlay={field.type !== 'textarea' && field.type !== 'rich-text'}
overlayClassName={field.type === 'textarea' || field.type === 'rich-text' ? 'flex flex-col overflow-hidden' : ''}
overlayMaxHeight={field.type === 'textarea' || field.type === 'rich-text' ? 320 : undefined}
overlayMinWidth={field.type === 'textarea' || field.type === 'rich-text' ? 300 : 160}
closeOnClickOutside={false}
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[24px] w-full'}
/>
) : (
<span className="text-sm">
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}

View File

@@ -10,6 +10,7 @@ import { useToast } from '@/hooks/use-toast';
import type { TableColumnDef, RegularTableRow } from '@shared/schema';
import { evaluateAllFormulas, evaluateAggregateFormula, FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine';
import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions';
import { InlineField } from '@/components/fields/InlineField';
interface DataTableCellProps {
tableId: number;
@@ -395,11 +396,12 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa
);
}
const isEditableCell = !readOnly && col.type !== 'select' && col.type !== 'table';
return (
<td
key={col.id}
className="border px-0.5 py-0 cursor-pointer hover:bg-muted/50"
onClick={() => col.type !== 'select' && col.type !== 'table' && handleCellClick(row.id, col.id, cellValue)}
className="border px-0.5 py-0 hover:bg-muted/50"
data-testid={`cell-${row.id}-${col.id}`}
>
{col.type === 'select' && !readOnly ? (
@@ -421,24 +423,37 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa
onChange={handleSelectChange}
readOnly={readOnly}
/>
) : isEditing ? (
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleCellBlur}
onKeyDown={handleKeyDown}
autoFocus
className="h-6 px-1 py-0 border-0 focus-visible:ring-1 bg-background"
type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : 'text'}
/>
) : (
<div className="min-h-[24px] flex items-center px-0.5 truncate">
{cellValue === '__auto_prolongation__'
? 'Автопролонгация'
: col.type === 'select'
? getSelectOptionLabel(col.options, cellValue)
: cellValue}
</div>
<InlineField
readView={
<div className="min-h-[24px] flex items-center px-0.5 truncate cursor-pointer">
{cellValue === '__auto_prolongation__'
? 'Автопролонгация'
: col.type === 'select'
? getSelectOptionLabel(col.options, cellValue)
: cellValue}
</div>
}
editor={
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleCellBlur}
onKeyDown={handleKeyDown}
autoFocus
className="h-6 px-1 py-0 border-0 focus-visible:ring-1 bg-background w-full"
type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : 'text'}
/>
}
isEditing={isEditing}
editable={isEditableCell}
onEnterEdit={() => handleCellClick(row.id, col.id, cellValue)}
onExitEdit={() => { setEditingCell(null); setEditValue(''); }}
activateOnClick={isEditableCell}
fitOverlay={true}
closeOnClickOutside={false}
className="w-full min-h-[24px] items-center"
/>
)}
</td>
);

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
export interface FieldOverlayProps {
children: React.ReactNode;
containerRef: React.RefObject<HTMLElement | null>;
className?: string;
/** Если true, оверлей строго повторяет размер контейнера (inset-0). Если false — растягивается вниз/вправо от верхнего-левого угла контейнера. */
fitContainer?: boolean;
/** Минимальная ширина оверлея (например, чтобы пустое поле было удобно редактировать). */
minWidth?: number | string;
/** Максимальная высота оверлея. */
maxHeight?: number | string;
}
export function FieldOverlay({
children,
containerRef,
className,
fitContainer = true,
minWidth,
maxHeight,
}: FieldOverlayProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted || !containerRef.current) return null;
const style: React.CSSProperties = {};
if (minWidth !== undefined) style.minWidth = typeof minWidth === 'number' ? `${minWidth}px` : minWidth;
if (maxHeight !== undefined) style.maxHeight = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight;
return createPortal(
<div
className={cn(
'absolute z-50 rounded-md border border-input bg-background shadow-sm overflow-hidden',
fitContainer ? 'inset-0' : 'left-0 top-0 min-w-full',
className
)}
style={style}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{children}
</div>,
containerRef.current
);
}

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { FieldOverlay } from './FieldOverlay';
const DOUBLE_TAP_MS = 300;
function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || tag === 'button' || tag === 'a') return true;
if (target.closest('button, a, input, textarea, select, [role="button"], [role="listbox"]')) return true;
return false;
}
export interface InlineFieldProps {
/** Read-view контент. Остаётся в потоке, не размонтируется. */
readView: React.ReactNode;
/** Editor контент. Рендерится поверх read-view через portal. */
editor: React.ReactNode;
/** Управляемый режим редактирования. Если не передан — компонент управляет сам. */
isEditing?: boolean;
/** Можно ли входить в редактирование. */
editable?: boolean;
onEnterEdit?: (e?: React.MouseEvent | React.TouchEvent) => void;
onExitEdit?: () => void;
className?: string;
overlayClassName?: string;
/** Строго повторять размер контейнера. Для input/number/select — true, для textarea/rich-text — false. */
fitOverlay?: boolean;
/** Минимальная ширина оверлея. */
overlayMinWidth?: number | string;
/** Максимальная высота оверлея. */
overlayMaxHeight?: number | string;
/** Не блокировать клик по интерактивным элементам внутри read-view. */
allowInteractiveReadView?: boolean;
/** Закрывать редактор по клику вне (по умолчанию true). */
closeOnClickOutside?: boolean;
/** Активировать редактирование по одиночному клику (по умолчанию false — только double-click). */
activateOnClick?: boolean;
}
export function InlineField({
readView,
editor,
isEditing: controlledIsEditing,
editable = true,
onEnterEdit,
onExitEdit,
className,
overlayClassName,
fitOverlay = true,
overlayMinWidth = 200,
overlayMaxHeight,
allowInteractiveReadView = true,
closeOnClickOutside = true,
activateOnClick = false,
}: InlineFieldProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [internalEditing, setInternalEditing] = useState(false);
const lastTapRef = useRef<number>(0);
const isEditing = controlledIsEditing !== undefined ? controlledIsEditing : internalEditing;
const enterEdit = useCallback((e?: React.MouseEvent | React.TouchEvent) => {
if (!editable) return;
onEnterEdit?.(e);
if (controlledIsEditing === undefined) {
setInternalEditing(true);
}
}, [editable, onEnterEdit, controlledIsEditing]);
const exitEdit = useCallback(() => {
onExitEdit?.();
if (controlledIsEditing === undefined) {
setInternalEditing(false);
}
}, [onExitEdit, controlledIsEditing]);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
e.stopPropagation();
enterEdit(e);
},
[allowInteractiveReadView, enterEdit]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (!activateOnClick) return;
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
e.stopPropagation();
enterEdit(e);
},
[activateOnClick, allowInteractiveReadView, enterEdit]
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
const now = Date.now();
if (now - lastTapRef.current < DOUBLE_TAP_MS) {
e.stopPropagation();
enterEdit(e);
}
lastTapRef.current = now;
},
[allowInteractiveReadView, enterEdit]
);
// Закрытие по клику вне + игнорирование Radix-порталов
useEffect(() => {
if (!isEditing || !closeOnClickOutside) return;
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target || !(target instanceof Node)) return;
const isInsideContainer = containerRef.current?.contains(target) ?? false;
const isInsideRadixPortal =
target instanceof Element &&
!!target.closest?.(
'[data-radix-popper-content-wrapper], [role="listbox"], [data-radix-select-viewport], [data-radix-menu-content], [data-radix-popover-content]'
);
if (!isInsideContainer && !isInsideRadixPortal) {
exitEdit();
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [isEditing, exitEdit]);
return (
<div
ref={containerRef}
className={cn('relative inline-flex max-w-full', className)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onTouchEnd={handleTouchEnd}
>
{readView}
{isEditing && (
<FieldOverlay
containerRef={containerRef}
className={overlayClassName}
fitContainer={fitOverlay}
minWidth={overlayMinWidth}
maxHeight={overlayMaxHeight}
>
{editor}
</FieldOverlay>
)}
</div>
);
}