feat(record): RecordDrawer slide-over per Stage 3.5 handoff (Phase A)

Заменяет Modal-based record create/edit на right slide-over drawer per
design_handoff_ordinis_mdm/README.md Screen 3.

What's в Phase A (drawer shell + sticky chrome):

- src/components/record/RecordDrawer.tsx (NEW)
  - Two widths: 520px default / 880px wide, toggle через ‹/› button.
    Persists в localStorage 'ord-drawer-wide'.
  - Sticky header: caption (cap style) + recordId mono + width-toggle +
    history button (edit mode) + ×.
  - Sticky footer: [Удалить] (danger, edit mode) · 'не сохранено · N полей'
    caption · [Отмена] · [Сохранить] (drives form submit через form attr).
  - Built на shadcn Sheet primitive + Radix Dialog. Override sheetVariants
    inline для transition-able max-width (variant size не toggle'ится
    smoothly без animate prop).
  - aria-labelledby wires DialogTitle к содержимому header.
  - Mobile <880px: w-full fullscreen (per handoff responsive table).

- src/components/form/SchemaDrivenForm.tsx
  - New optional props: formId, onDirtyChange.
  - Когда formId передан — внутренний FormActions footer прячется (drawer
    footer driving submit через <button form={formId} type=submit>).
  - Dirty-count считается из RHF formState.dirtyFields (top-level + data.*).
  - useEffect surfaces count к parent — drawer footer caption updates live.

- src/routes/dictionaries.$name.tsx
  - Modal create/edit -> RecordDrawer.
  - State: + recordDirtyCount (reset на close).
  - onDelete wires к existing close-confirm flow (kind: 'close-confirm').
  - onHistory wires к existing setHistoryKey RecordHistoryDrawer.

- src/i18n.ts
  - + form.delete, form.unsavedNFields_one/few/many/other (i18next plural rules).
  - + drawer.{collapse,expand,close,captionEdit,captionCreate}.

Phase B (TODO Stage 3.5b — separate MR):
- Section anchor chips (sticky strip)
- Wide-mode left ToC rail (180px)
- Sticky toolbar: search + 'только заполненные' toggle + counter
- Requires x-section schema metadata в backend properties (CEO open question #5 в handoff)

Tests: 8 files, 116 tests pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 14:39:38 +03:00
parent b94912789f
commit 750395da09
4 changed files with 320 additions and 24 deletions
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv'
@@ -48,6 +48,12 @@ type Props = {
serverError?: string | null
onSubmit: (req: CreateRecordRequest) => void
onCancel: () => void
/** When provided, form renders WITHOUT internal Cancel/Save buttons —
* footer is expected to be drawn by parent (e.g. RecordDrawer). External
* submit triggered via `<button type="submit" form={formId}>`. */
formId?: string
/** Notifies parent of dirty-field count (for "N полей" footer caption). */
onDirtyChange?: (dirtyCount: number) => void
}
const ajv = new Ajv({ allErrors: true, strict: false })
@@ -90,6 +96,8 @@ export const SchemaDrivenForm = ({
serverError,
onSubmit,
onCancel,
formId,
onDirtyChange,
}: Props) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabId>('identity')
@@ -165,8 +173,27 @@ export const SchemaDrivenForm = ({
) : undefined,
}))
// Surface dirty-field count к parent (RecordDrawer footer caption).
// Используем formState.dirtyFields shallow keys + nested data keys.
const dirtyCount = useMemo(() => {
const top = Object.keys(formState.dirtyFields).filter(
(k) => k !== 'data' && (formState.dirtyFields as Record<string, unknown>)[k],
).length
const dataDirty = formState.dirtyFields.data
? Object.keys(formState.dirtyFields.data).length
: 0
return top + dataDirty
}, [formState.dirtyFields])
useEffect(() => {
onDirtyChange?.(dirtyCount)
}, [dirtyCount, onDirtyChange])
return (
<form onSubmit={handleSubmit(submit)} className="space-y-4">
<form
id={formId}
onSubmit={handleSubmit(submit)}
className="space-y-4"
>
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
@@ -269,14 +296,19 @@ export const SchemaDrivenForm = ({
{serverError && <Alert variant="error">{serverError}</Alert>}
<FormActions align="end">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
{t('form.cancel')}
</Button>
<Button type="submit" variant="primary" loading={isPending}>
{isPending ? t('form.saving') : t('form.save')}
</Button>
</FormActions>
{/* Когда formId не передан — внутренний footer (backward compat).
* Когда передан — drawer footer (см. RecordDrawer) рисует кнопки и
* сабмитит через атрибут form={formId} на <button type="submit">. */}
{!formId && (
<FormActions align="end">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
{t('form.cancel')}
</Button>
<Button type="submit" variant="primary" loading={isPending}>
{isPending ? t('form.saving') : t('form.save')}
</Button>
</FormActions>
)}
</form>
)
}
@@ -0,0 +1,227 @@
import { useCallback, useState, useId, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
ArrowLeftToLine,
ArrowRightToLine,
History,
Trash2,
X,
} from 'lucide-react'
import { Sheet, SheetPortal } from '@/ui'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { Button } from '@/ui'
import { cn } from '@/lib/utils'
/**
* RecordDrawer — right slide-over для create/edit записи. Stage 3.5 per
* design_handoff_ordinis_mdm/README.md (Screen 3).
*
* <p>Layout (sticky frame, scrolling body):
* <pre>
* ┌───────────────────────────────────────┐
* │ HEADER (sticky) │
* │ caption · id · [/] [⏱] [×] │
* ├───────────────────────────────────────┤
* │ BODY (scroll) │
* │ children — SchemaDrivenForm │
* ├───────────────────────────────────────┤
* │ FOOTER (sticky) │
* │ [Удалить] · "N полей" · [Отмена][Save]│
* └───────────────────────────────────────┘
* </pre>
*
* <p>Two widths:
* <ul>
* <li>520px (default) — focused single-column edit.</li>
* <li>880px (wide) — for dense schemas + future ToC rail. Persists в
* localStorage key {@code ord-drawer-wide}.</li>
* </ul>
*
* <p>Phase 1 (current): drawer shell + width toggle + sticky chrome.
* Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail
* (180px), search field, "только заполненные" toggle. Требует {@code x-section}
* metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими
* 3 tabs.
*
* <p>Drives form submit через атрибут {@code form={formId}} на footer Save
* button. SchemaDrivenForm должен получить тот же {@code formId} prop и
* скрыть свой внутренний footer.
*/
const LS_KEY = 'ord-drawer-wide'
/** Hook: persisted boolean (drawer wide mode). */
function useDrawerWide(): [boolean, () => void] {
const [wide, setWide] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
return window.localStorage.getItem(LS_KEY) === '1'
})
const toggle = useCallback(() => {
setWide((prev) => {
const next = !prev
try {
window.localStorage.setItem(LS_KEY, next ? '1' : '0')
} catch {
// localStorage может быть disabled (Safari private). Игнорируем —
// в памяти state сохраняется до закрытия таба, persist опционален.
}
return next
})
}, [])
return [wide, toggle]
}
export type RecordDrawerProps = {
open: boolean
onClose: () => void
/** Title caption — "редактирование записи · {dictionary}" or "новая запись · {dictionary}". */
caption: string
/** Record business key / id — shown в header mono. Undefined для create. */
recordId?: string
/** Save button form attribute target. SchemaDrivenForm renders <form id={formId}>. */
formId: string
/** Dirty-field count для caption "не сохранено · N полей". 0 hides it. */
dirtyCount?: number
/** Disable footer buttons (mutation в полёте). */
isPending?: boolean
/** Show [Удалить] danger button (только edit mode, не create). */
onDelete?: () => void
/** Show [История] button + handler (только edit mode). */
onHistory?: () => void
children: ReactNode
}
export function RecordDrawer({
open,
onClose,
caption,
recordId,
formId,
dirtyCount = 0,
isPending = false,
onDelete,
onHistory,
children,
}: RecordDrawerProps) {
const { t } = useTranslation()
const [wide, toggleWide] = useDrawerWide()
// Уникальный id для DialogTitle/Description чтобы Radix mapping aria-labelledby.
const titleId = useId()
// Reset focus к save button при открытии — обычно user закончил поиск и хочет
// быстро save после check. Radix focus traps inside, default landing — first
// focusable. Save в footer первой стрелкой Tab был бы удобнее, но это
// нарушает default flow. Пока default.
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetPortal>
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/40',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
)}
/>
<DialogPrimitive.Content
aria-labelledby={titleId}
className={cn(
'fixed inset-y-0 right-0 z-50 h-full bg-surface shadow-2xl border-l border-line',
'flex flex-col',
'transition-[max-width] duration-200 ease-out',
// Mobile <= 880px: fullscreen per handoff.
'w-full',
wide ? 'sm:max-w-[880px]' : 'sm:max-w-[520px]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
'data-[state=closed]:duration-200 data-[state=open]:duration-300',
)}
>
{/* === HEADER (sticky top) === */}
<header className="shrink-0 border-b border-line-2 px-5 py-3 flex items-center gap-3">
<div className="min-w-0 flex-1">
<DialogPrimitive.Title id={titleId} className="text-cap text-mute truncate">
{caption}
</DialogPrimitive.Title>
{recordId && (
<div className="text-mono text-ink-2 truncate mt-0.5">{recordId}</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
<button
type="button"
aria-label={wide ? t('drawer.collapse') : t('drawer.expand')}
title={wide ? t('drawer.collapse') : t('drawer.expand')}
onClick={toggleWide}
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{wide ? <ArrowRightToLine size={16} /> : <ArrowLeftToLine size={16} />}
</button>
{onHistory && (
<button
type="button"
aria-label={t('history.title')}
title={t('history.title')}
onClick={onHistory}
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<History size={16} />
</button>
)}
<DialogPrimitive.Close
aria-label={t('drawer.close')}
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X size={16} strokeWidth={2.25} />
</DialogPrimitive.Close>
</div>
</header>
{/* === BODY (scrolling) ===
* Phase 2 здесь будет: sticky toolbar (search + filled-only toggle +
* counter) → section anchor chips strip → optional left ToC rail в
* wide mode (CSS grid 180px / 1fr). Пока children — SchemaDrivenForm. */}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{/* === FOOTER (sticky bottom) === */}
<footer className="shrink-0 border-t border-line-2 px-5 py-3 flex items-center gap-3 bg-surface">
{onDelete && (
<Button
type="button"
variant="danger"
size="sm"
onClick={onDelete}
disabled={isPending}
className="shrink-0"
>
<Trash2 size={14} className="mr-1.5" />
{t('form.delete')}
</Button>
)}
<div className="flex-1 min-w-0 text-cell text-mute truncate">
{dirtyCount > 0 && t('form.unsavedNFields', { count: dirtyCount })}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
disabled={isPending}
>
{t('form.cancel')}
</Button>
<Button
type="submit"
form={formId}
variant="primary"
size="sm"
loading={isPending}
>
{isPending ? t('form.saving') : t('form.save')}
</Button>
</footer>
</DialogPrimitive.Content>
</SheetPortal>
</Sheet>
)
}