feat(drawer): section chip strip scroll-spy via IntersectionObserver

This commit is contained in:
Александр Зимин
2026-05-12 13:29:52 +00:00
parent 9073229c0e
commit da04b862ab
3 changed files with 128 additions and 23 deletions
@@ -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 { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv' import Ajv, { type ErrorObject } from 'ajv'
@@ -30,6 +30,7 @@ import {
parseFormDate, parseFormDate,
} from '@/lib/dates' } from '@/lib/dates'
import { pickLocalized } from '@/lib/locales' import { pickLocalized } from '@/lib/locales'
import { cn } from '@/lib/utils'
type FormValues = { type FormValues = {
businessKey: string businessKey: string
@@ -361,21 +362,112 @@ export const SchemaDrivenForm = ({
// strip сверху (sticky), он же выполняет функцию quick-jump. // strip сверху (sticky), он же выполняет функцию quick-jump.
const formContainerRef = useRef<HTMLFormElement>(null) const formContainerRef = useRef<HTMLFormElement>(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<id, isIntersecting> и считаем active как первую секцию
// в sectionOrder у которой isIntersecting=true. Это устойчивее чем
// «entries[0].target.id» — observer callback приходит batched и порядок
// entries не гарантирован.
const [activeSection, setActiveSection] = useState<SectionId | null>(null)
const intersectingRef = useRef<Map<SectionId, boolean>>(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<HTMLElement>('[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 ? ( const sectionsChipStrip = showAllSections ? (
<div className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10"> <div
{visibleTabs.map((tab) => ( className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10"
role="navigation"
aria-label={t('drawer.sectionsNav', { defaultValue: 'Секции формы' })}
>
{visibleTabs.map((tab) => {
const isActive = activeSection === tab.id
return (
<button <button
key={tab.id} key={tab.id}
type="button" type="button"
onClick={() => scrollToSection(tab.id)} onClick={() => scrollToSection(tab.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm bg-surface-2 border border-line hover:bg-accent-bg hover:border-accent text-cap text-ink-2 hover:text-accent transition-colors" aria-current={isActive ? 'location' : undefined}
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-cap transition-colors',
isActive
? 'bg-accent-bg border-accent text-accent'
: 'bg-surface-2 border-line text-ink-2 hover:bg-accent-bg/40 hover:border-accent/60 hover:text-accent',
)}
> >
{tab.label} {tab.label}
<span className="text-mono text-mute"> <span className={cn('text-mono', isActive ? 'text-accent/80' : 'text-mute')}>
{filteredBuckets[tab.id]?.length ?? 0} {filteredBuckets[tab.id]?.length ?? 0}
</span> </span>
</button> </button>
))} )
})}
</div> </div>
) : null ) : null
@@ -411,6 +503,7 @@ export const SchemaDrivenForm = ({
<div <div
key={id} key={id}
id={`form-section-${formId ?? 'default'}-${id}`} id={`form-section-${formId ?? 'default'}-${id}`}
data-section-id={id}
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'} className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
> >
{showAllSections && (isIdentity || fields.length > 0) && ( {showAllSections && (isIdentity || fields.length > 0) && (
@@ -37,11 +37,17 @@ import { cn } from '@/lib/utils'
* localStorage key {@code ord-drawer-wide}.</li> * localStorage key {@code ord-drawer-wide}.</li>
* </ul> * </ul>
* *
* <p>Phase 1 (current): drawer shell + width toggle + sticky chrome. * <p>Status (v2.12+):
* Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail * <ul>
* (180px), search field, "только заполненные" toggle. Требует {@code x-section} * <li>✅ Drawer shell + width toggle + sticky chrome (Phase 1).</li>
* metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими * <li>✅ Toolbar: search полей + «только заполненные» (props.toolbar).</li>
* 3 tabs. * <li>✅ Section anchor chips + scroll-spy active highlight — рендерит
* SchemaDrivenForm в sections layout (sticky chip strip с
* IntersectionObserver).</li>
* <li>❌ Wide-mode left ToC rail — отклонено по user feedback
* («в редакторе записей левая навигация лишняя»); chip strip
* выполняет ту же функцию quick-jump компактнее.</li>
* </ul>
* *
* <p>Drives form submit через атрибут {@code form={formId}} на footer Save * <p>Drives form submit через атрибут {@code form={formId}} на footer Save
* button. SchemaDrivenForm должен получить тот же {@code formId} prop и * button. SchemaDrivenForm должен получить тот же {@code formId} prop и
@@ -226,9 +232,9 @@ export function RecordDrawer({
)} )}
{/* === BODY (scrolling) === {/* === BODY (scrolling) ===
* Phase B остатки (deferred — требует x-section schema metadata): * SchemaDrivenForm в sections layout рендерит свой sticky chip
* - section anchor chips strip над form sections * strip + IntersectionObserver scroll-spy. Drawer body — просто
* - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */} * overflow-y-auto контейнер, scroll root для observer'а. */}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div> <div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{/* === FOOTER (sticky bottom) === */} {/* === FOOTER (sticky bottom) === */}
+6
View File
@@ -381,6 +381,9 @@ i18n
'drawer.close': 'Закрыть', 'drawer.close': 'Закрыть',
'drawer.captionEdit': 'редактирование записи · {{dict}}', 'drawer.captionEdit': 'редактирование записи · {{dict}}',
'drawer.captionCreate': 'новая запись · {{dict}}', 'drawer.captionCreate': 'новая запись · {{dict}}',
'drawer.sectionsNav': 'Секции формы',
'drawer.searchPlaceholder': 'Поиск полей',
'drawer.onlyFilled': 'только заполненные',
'scope.PUBLIC': 'PUBLIC — публичный', 'scope.PUBLIC': 'PUBLIC — публичный',
'scope.INTERNAL': 'INTERNAL — внутренний', 'scope.INTERNAL': 'INTERNAL — внутренний',
'scope.RESTRICTED': 'RESTRICTED — ограниченный', 'scope.RESTRICTED': 'RESTRICTED — ограниченный',
@@ -1052,6 +1055,9 @@ i18n
'drawer.close': 'Close', 'drawer.close': 'Close',
'drawer.captionEdit': 'edit record · {{dict}}', 'drawer.captionEdit': 'edit record · {{dict}}',
'drawer.captionCreate': 'new record · {{dict}}', 'drawer.captionCreate': 'new record · {{dict}}',
'drawer.sectionsNav': 'Form sections',
'drawer.searchPlaceholder': 'Search fields',
'drawer.onlyFilled': 'only filled',
'form.error.required': 'Required field', 'form.error.required': 'Required field',
'scope.PUBLIC': 'PUBLIC', 'scope.PUBLIC': 'PUBLIC',
'scope.INTERNAL': 'INTERNAL', 'scope.INTERNAL': 'INTERNAL',