feat: restructure procedural editor with collapsible sections and fixed-height layout

Convert scrolling document layout to fixed-height editor with accordion-mode
collapsible sections for Details and Intake Form. Step list now gets all
remaining height with independent scrolling. Add CollapsibleEditorSection
component with ARIA attributes (aria-expanded, aria-controls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-19 03:15:00 -05:00
parent 96b8818b36
commit dff53e55bb
5 changed files with 174 additions and 42 deletions

View File

@@ -0,0 +1,68 @@
import { useState, useId, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface CollapsibleEditorSectionProps {
title: string
icon: ReactNode
summary: string
expanded?: boolean
onToggle?: () => void
defaultExpanded?: boolean
children: ReactNode
}
export function CollapsibleEditorSection({
title,
icon,
summary,
expanded: controlledExpanded,
onToggle,
defaultExpanded = false,
children,
}: CollapsibleEditorSectionProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
const isExpanded = controlledExpanded ?? internalExpanded
const generatedId = useId()
const sectionId = `section-${generatedId}`
const handleToggle = () => {
if (onToggle) {
onToggle()
} else {
setInternalExpanded(!internalExpanded)
}
}
return (
<div className="border-b border-border">
<button
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={sectionId}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
isExpanded && 'rotate-90'
)}
/>
<span className="shrink-0 text-muted-foreground">{icon}</span>
<span className="text-sm font-medium text-foreground">{title}</span>
{!isExpanded && (
<span className="min-w-0 truncate text-sm text-muted-foreground">
{summary}
</span>
)}
</button>
{isExpanded && (
<div id={sectionId} className="px-4 pb-4 pt-1">
{children}
</div>
)}
</div>
)
}

View File

@@ -6,15 +6,8 @@ export function IntakeFormBuilder() {
const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore()
return (
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Intake Form</h2>
<span className="text-sm text-muted-foreground">
({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''})
</span>
</div>
<div>
<div className="mb-3 flex items-center justify-end">
<button
onClick={addField}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"

View File

@@ -1,14 +1,17 @@
import { useEffect } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered, Wrench } from 'lucide-react'
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { toast } from '@/lib/toast'
import type { TreeType } from '@/types'
type SectionKey = 'details' | 'intake' | 'schedule'
export function ProceduralEditorPage() {
const { id } = useParams<{ id: string }>()
const [searchParams] = useSearchParams()
@@ -22,6 +25,7 @@ export function ProceduralEditorPage() {
description,
tags,
isPublic,
intakeForm,
isDirty,
isSaving,
isLoading,
@@ -40,6 +44,15 @@ export function ProceduralEditorPage() {
const isMaintenance = treeType === 'maintenance'
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
// Accordion state: only one section open at a time
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(
isEditMode ? null : 'details'
)
const toggleSection = useCallback((key: SectionKey) => {
setExpandedSection(prev => prev === key ? null : key)
}, [])
// Load tree or init new
useEffect(() => {
if (isEditMode && id) {
@@ -47,6 +60,8 @@ export function ProceduralEditorPage() {
} else {
const urlType = searchParams.get('type')
initNew((urlType === 'maintenance' ? 'maintenance' : 'procedural') as TreeType)
// New flows: details expanded, or schedule for new maintenance
setExpandedSection(urlType === 'maintenance' ? 'schedule' : 'details')
}
return () => { reset() }
@@ -101,6 +116,17 @@ export function ProceduralEditorPage() {
}
}
// Summary strings for collapsed sections
const detailsSummary = [
name ? `"${name}"` : '"Untitled"',
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
isPublic ? 'Public' : 'Private',
].join(' \u00b7 ')
const intakeSummary = intakeForm.length === 0
? 'No fields defined'
: `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', \u2026' : ''}`
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
@@ -110,9 +136,9 @@ export function ProceduralEditorPage() {
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div className="flex h-full flex-col overflow-hidden">
{/* Toolbar — sticky */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/my-trees')}
@@ -124,8 +150,9 @@ export function ProceduralEditorPage() {
{isMaintenance
? <Wrench className="h-5 w-5 text-amber-400" />
: <ListOrdered className="h-5 w-5 text-muted-foreground" />}
<h1 className="text-xl font-bold text-foreground sm:text-2xl">
<h1 className="text-lg font-bold text-foreground">
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`}
{name && <span className="ml-2 font-normal text-muted-foreground"> {name}</span>}
</h1>
</div>
</div>
@@ -144,7 +171,7 @@ export function ProceduralEditorPage() {
<button
onClick={() => handleSave('published')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Publish'}
@@ -152,11 +179,15 @@ export function ProceduralEditorPage() {
</div>
</div>
{/* Content */}
<div className="space-y-6">
{/* Metadata */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-foreground">Details</h2>
{/* Collapsible sections */}
<div className="shrink-0">
<CollapsibleEditorSection
title="Details"
icon={<Settings className="h-4 w-4" />}
summary={detailsSummary}
expanded={expandedSection === 'details'}
onToggle={() => toggleSection('details')}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
@@ -199,12 +230,21 @@ export function ProceduralEditorPage() {
</div>
</div>
</div>
</div>
</CollapsibleEditorSection>
{/* Intake Form Builder */}
<IntakeFormBuilder />
<CollapsibleEditorSection
title="Intake Form"
icon={<FileText className="h-4 w-4" />}
summary={intakeSummary}
expanded={expandedSection === 'intake'}
onToggle={() => toggleSection('intake')}
>
<IntakeFormBuilder />
</CollapsibleEditorSection>
</div>
{/* Step List */}
{/* Step List — flex-1, scrolls independently */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<StepList />
</div>
</div>