feat(ui): row click opens edit + wide-mode left section rail in drawer
Two improvements per user feedback ('по клику открывается редактирование и
в развернутом виде слева навигация'):
## 1. Row click → edit drawer
TableRow в /dictionaries/$name теперь имеет onClick={setEdit({kind:'edit',
record:r})} когда canMutate. cursor-pointer + bubbling guards:
- Checkbox cell и Actions cell имеют onClick stopPropagation чтобы
внутренние controls не открывали drawer случайно.
- Anonymous users (no canMutate) — row не clickable (read-only, no drawer).
## 2. Wide-mode left rail в drawer
SchemaDrivenForm в sections layout теперь умеет ResizeObserver-based detection
своей ширины. Когда контейнер ≥720px (drawer wide mode = 880px) → render'ит
180px sticky left rail с section list + per-section field counts. Click rail
item → smooth scroll к section anchor. Иначе chip strip сверху, как раньше.
Container-based (не viewport-based) чтобы narrow drawer на wide viewport не
рендерил rail (520px не fits 180+1fr grid).
This commit is contained in:
@@ -276,7 +276,9 @@ export const SchemaDrivenForm = ({
|
|||||||
const showAllSections = layout === 'sections'
|
const showAllSections = layout === 'sections'
|
||||||
const sectionVisible = (id: TabId) => showAllSections || activeTab === id
|
const sectionVisible = (id: TabId) => showAllSections || activeTab === id
|
||||||
|
|
||||||
// Scroll-to-section для chip click (только в sections mode).
|
// Scroll-to-section для chip click + rail click. Использует form container
|
||||||
|
// (scrollable parent = drawer body) вместо window — section header sticky
|
||||||
|
// считается от form container coordinate space.
|
||||||
const scrollToSection = (id: TabId) => {
|
const scrollToSection = (id: TabId) => {
|
||||||
document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({
|
document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
@@ -284,30 +286,76 @@ export const SchemaDrivenForm = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wide-mode left rail: detection через ResizeObserver. Когда form container
|
||||||
|
// ≥720px (drawer wide mode = 880px) — показываем 180px nav rail слева
|
||||||
|
// с section list + counts. Иначе chip strip на top sufficient. Container-
|
||||||
|
// based вместо viewport-based чтобы narrow drawer на wide viewport не
|
||||||
|
// рендерил rail (drawer = 520px не fits).
|
||||||
|
const formContainerRef = useRef<HTMLFormElement>(null)
|
||||||
|
const [showRail, setShowRail] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAllSections || !formContainerRef.current) return
|
||||||
|
const node = formContainerRef.current
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const width = entries[0]?.contentRect.width ?? 0
|
||||||
|
setShowRail(width >= 720)
|
||||||
|
})
|
||||||
|
ro.observe(node)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [showAllSections])
|
||||||
|
|
||||||
|
// Body content (sections + form fields) — рендерится либо напрямую,
|
||||||
|
// либо в grid 180px+1fr когда showRail (wide mode).
|
||||||
|
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 as TabId)}
|
||||||
|
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 as TabId].length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
// Left rail для wide mode — sticky nav с section list + per-section
|
||||||
|
// field counts. Click → scrollToSection (smooth scroll к anchor).
|
||||||
|
const leftRail = showRail && showAllSections ? (
|
||||||
|
<aside className="self-start sticky top-0 pt-0.5">
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollToSection(tab.id as TabId)}
|
||||||
|
className="flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-sm hover:bg-surface-2 text-body text-ink-2 hover:text-ink text-left transition-colors"
|
||||||
|
>
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
<span className="text-mono text-cell text-mute shrink-0">
|
||||||
|
{filteredBuckets[tab.id as TabId].length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id={formId}
|
id={formId}
|
||||||
onSubmit={handleSubmit(submit)}
|
onSubmit={handleSubmit(submit)}
|
||||||
className="space-y-4"
|
ref={formContainerRef}
|
||||||
|
className={showRail ? 'grid grid-cols-[180px_1fr] gap-6' : ''}
|
||||||
>
|
>
|
||||||
{/* Section chips strip per handoff (sections mode) OR tab bar (legacy). */}
|
{leftRail}
|
||||||
{showAllSections ? (
|
<div className="space-y-4 min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10">
|
{showAllSections ? sectionsChipStrip : (
|
||||||
{visibleTabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => scrollToSection(tab.id as TabId)}
|
|
||||||
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 as TabId].length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
|
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -448,6 +496,7 @@ export const SchemaDrivenForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -905,10 +905,18 @@ function DictionaryDetail() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={r.id}
|
key={r.id}
|
||||||
data-selected={selection.has(r.businessKey)}
|
data-selected={selection.has(r.businessKey)}
|
||||||
className={rowCursor === idx ? 'bg-accent-bg/40 outline outline-1 outline-accent/30' : undefined}
|
// Row-wide click → open edit drawer (только для canMutate).
|
||||||
|
// Anonymous users — read-only, click no-op чтобы не путать.
|
||||||
|
// Bubbling: nested buttons (checkbox, action icons) делают
|
||||||
|
// stopPropagation чтобы они не открывали drawer случайно.
|
||||||
|
onClick={canMutate ? () => setEdit({ kind: 'edit', record: r }) : undefined}
|
||||||
|
className={[
|
||||||
|
canMutate ? 'cursor-pointer' : '',
|
||||||
|
rowCursor === idx ? 'bg-accent-bg/40 outline outline-1 outline-accent/30' : '',
|
||||||
|
].filter(Boolean).join(' ') || undefined}
|
||||||
>
|
>
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
<TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
||||||
checked={selection.has(r.businessKey)}
|
checked={selection.has(r.businessKey)}
|
||||||
@@ -967,7 +975,7 @@ function DictionaryDetail() {
|
|||||||
{new Date(r.validFrom).toLocaleDateString()}
|
{new Date(r.validFrom).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t('history.title')}
|
label={t('history.title')}
|
||||||
|
|||||||
Reference in New Issue
Block a user