Merge branch 'feat/sections-chip-strip-scroll-spy' into 'main'
feat(drawer): section chip strip scroll-spy via IntersectionObserver See merge request 2-6/2-6-4/terravault/ordinis!141
This commit is contained in:
@@ -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<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 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{tab.label}
|
||||
<span className="text-mono text-mute">
|
||||
{filteredBuckets[tab.id]?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
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
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => scrollToSection(tab.id)}
|
||||
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}
|
||||
<span className={cn('text-mono', isActive ? 'text-accent/80' : 'text-mute')}>
|
||||
{filteredBuckets[tab.id]?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -411,6 +503,7 @@ export const SchemaDrivenForm = ({
|
||||
<div
|
||||
key={id}
|
||||
id={`form-section-${formId ?? 'default'}-${id}`}
|
||||
data-section-id={id}
|
||||
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
|
||||
>
|
||||
{showAllSections && (isIdentity || fields.length > 0) && (
|
||||
|
||||
@@ -37,11 +37,17 @@ import { cn } from '@/lib/utils'
|
||||
* 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>Status (v2.12+):
|
||||
* <ul>
|
||||
* <li>✅ Drawer shell + width toggle + sticky chrome (Phase 1).</li>
|
||||
* <li>✅ Toolbar: search полей + «только заполненные» (props.toolbar).</li>
|
||||
* <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
|
||||
* 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'а. */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
|
||||
{/* === FOOTER (sticky bottom) === */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user