diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index a38018e..b15cc9b 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, 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' @@ -30,6 +30,7 @@ import { parseFormDate, } from '@/lib/dates' import { pickLocalized } from '@/lib/locales' +import { cn } from '@/lib/utils' type FormValues = { businessKey: string @@ -361,21 +362,112 @@ export const SchemaDrivenForm = ({ // strip сверху (sticky), он же выполняет функцию quick-jump. const formContainerRef = useRef(null) + // Scroll-spy: IntersectionObserver следит за section anchors, highlight'ит + // chip активной секции (= видимая ближе всего к верху scroll viewport'а). + // rootMargin '-44px 0px -60% 0px' создаёт «detection band» в верхней 40% + // viewport'а: header sticky ~44px сверху, section считается «активной» + // если её top попадает в этот диапазон. Это даёт ощущение «active = что + // юзер сейчас читает», а не «что в самом верху». threshold 0 — fires при + // любом пересечении границы, дешевле чем массив тысяч threshold'ов. + // + // Сохраняем Map и считаем active как первую секцию + // в sectionOrder у которой isIntersecting=true. Это устойчивее чем + // «entries[0].target.id» — observer callback приходит batched и порядок + // entries не гарантирован. + const [activeSection, setActiveSection] = useState(null) + const intersectingRef = useRef>(new Map()) + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + const map = intersectingRef.current + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.sectionId as SectionId | undefined + if (!id) continue + map.set(id, entry.isIntersecting) + } + // Pick first in sectionOrder that is currently intersecting. Fallback — + // оставляем предыдущий active (избегаем «мигания» в null когда юзер + // между секциями в gap-промежутке). + for (const id of sectionOrder) { + if (map.get(id)) { + setActiveSection(id) + return + } + } + }, + [sectionOrder], + ) + + useEffect(() => { + if (!showAllSections) { + setActiveSection(null) + intersectingRef.current.clear() + return + } + const form = formContainerRef.current + if (!form) return + // root: ближайший scrollable ancestor — drawer body. null = viewport, + // но для embedded drawer'а правильнее scoped root. Ищем через closest + // overflow-y-auto. Если не найдено — fallback на null (window scroll). + let root: Element | null = form.parentElement + while (root && root !== document.body) { + const style = window.getComputedStyle(root) + if ( + style.overflowY === 'auto' || + style.overflowY === 'scroll' || + style.overflowY === 'overlay' + ) { + break + } + root = root.parentElement + } + const observer = new IntersectionObserver(handleIntersect, { + root: root && root !== document.body ? root : null, + // Top 44px reserved для chip strip sticky. Bottom -60% — секция + // считается active пока её top в верхних 40% viewport'а. + rootMargin: '-44px 0px -60% 0px', + threshold: 0, + }) + // Observe все section anchor divs (data-section-id). Querying inside + // form ref scope чтобы не зацепить чужие элементы. + const anchors = form.querySelectorAll('[data-section-id]') + anchors.forEach((el) => observer.observe(el)) + return () => { + observer.disconnect() + intersectingRef.current.clear() + } + // sectionOrder в deps чтобы при изменении schema'ы observer'ы пересоздались + // для новых anchor'ов. + }, [showAllSections, handleIntersect, sectionOrder]) + const sectionsChipStrip = showAllSections ? ( -
- {visibleTabs.map((tab) => ( - - ))} +
+ {visibleTabs.map((tab) => { + const isActive = activeSection === tab.id + return ( + + ) + })}
) : null @@ -411,6 +503,7 @@ export const SchemaDrivenForm = ({
{showAllSections && (isIdentity || fields.length > 0) && ( diff --git a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx index ba58354..56d1c2d 100644 --- a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx @@ -37,11 +37,17 @@ import { cn } from '@/lib/utils' * localStorage key {@code ord-drawer-wide}. * * - *

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. + *

Status (v2.12+): + *

    + *
  • ✅ Drawer shell + width toggle + sticky chrome (Phase 1).
  • + *
  • ✅ Toolbar: search полей + «только заполненные» (props.toolbar).
  • + *
  • ✅ Section anchor chips + scroll-spy active highlight — рендерит + * SchemaDrivenForm в sections layout (sticky chip strip с + * IntersectionObserver).
  • + *
  • ❌ Wide-mode left ToC rail — отклонено по user feedback + * («в редакторе записей левая навигация лишняя»); chip strip + * выполняет ту же функцию quick-jump компактнее.
  • + *
* *

Drives form submit через атрибут {@code form={formId}} на footer Save * button. SchemaDrivenForm должен получить тот же {@code formId} prop и @@ -226,9 +232,9 @@ export function RecordDrawer({ )} {/* === BODY (scrolling) === - * Phase B остатки (deferred — требует x-section schema metadata): - * - section anchor chips strip над form sections - * - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */} + * SchemaDrivenForm в sections layout рендерит свой sticky chip + * strip + IntersectionObserver scroll-spy. Drawer body — просто + * overflow-y-auto контейнер, scroll root для observer'а. */}

{children}
{/* === FOOTER (sticky bottom) === */} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 813d03f..8fb8211 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -381,6 +381,9 @@ i18n 'drawer.close': 'Закрыть', 'drawer.captionEdit': 'редактирование записи · {{dict}}', 'drawer.captionCreate': 'новая запись · {{dict}}', + 'drawer.sectionsNav': 'Секции формы', + 'drawer.searchPlaceholder': 'Поиск полей', + 'drawer.onlyFilled': 'только заполненные', 'scope.PUBLIC': 'PUBLIC — публичный', 'scope.INTERNAL': 'INTERNAL — внутренний', 'scope.RESTRICTED': 'RESTRICTED — ограниченный', @@ -1052,6 +1055,9 @@ i18n 'drawer.close': 'Close', 'drawer.captionEdit': 'edit record · {{dict}}', 'drawer.captionCreate': 'new record · {{dict}}', + 'drawer.sectionsNav': 'Form sections', + 'drawer.searchPlaceholder': 'Search fields', + 'drawer.onlyFilled': 'only filled', 'form.error.required': 'Required field', 'scope.PUBLIC': 'PUBLIC', 'scope.INTERNAL': 'INTERNAL',