Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/improvementProject/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface ImprovementProjectGoal {
export interface ImprovementProjectBackgroundSection {
/** Snapshot copy of capability summary at IP open. Drift indicator triggers refresh. */
snapshotText?: string;
snapshotSourceHash?: string;
snapshottedAt?: string;
manualNarrative?: string;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useId, useState } from 'react';

export interface CollapsibleSectionProps {
title: string;
children: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}

export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
open,
defaultOpen = false,
onOpenChange,
}) => {
const generatedId = useId();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : uncontrolledOpen;
const headerId = `improvement-section-header-${generatedId}`;
const panelId = `improvement-section-panel-${generatedId}`;

const handleToggle = () => {
const nextOpen = !isOpen;
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};

return (
<section className="rounded-lg border border-edge bg-surface">
<button
id={headerId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={handleToggle}
className="flex w-full items-center justify-between gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold text-content transition-colors hover:bg-surface-secondary focus:outline-none focus:ring-2 focus:ring-ring"
>
<span>{title}</span>
<span aria-hidden="true" className="text-content/60">
{isOpen ? '-' : '+'}
</span>
</button>

{isOpen && (
<div
id={panelId}
role="region"
aria-labelledby={headerId}
className="border-t border-edge px-4 py-4 text-sm text-content"
>
{children}
</div>
)}
</section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import { ProgressIndicator } from './ProgressIndicator';
import {
HeaderMetadataSection,
type HeaderMetadataSectionProps,
} from './sections/HeaderMetadataSection';
import { BackgroundSection, type BackgroundSectionProps } from './sections/BackgroundSection';
import { GoalSection, type GoalSectionProps } from './sections/GoalSection';
import {
InvestigationLineageSection,
type InvestigationLineageSectionProps,
} from './sections/InvestigationLineageSection';
import { ApproachSection, type ApproachSectionProps } from './sections/ApproachSection';
import {
OutcomeReferenceSection,
type OutcomeReferenceSectionProps,
} from './sections/OutcomeReferenceSection';

export type ImprovementProjectSectionKey =
| 'metadata'
| 'background'
| 'goal'
| 'lineage'
| 'approach'
| 'outcome';

type SectionContent = React.ReactNode | (() => React.ReactNode);

interface ImprovementProjectSection {
key: ImprovementProjectSectionKey;
title: string;
defaultOpen: boolean;
}

const SECTIONS: ImprovementProjectSection[] = [
{ key: 'metadata', title: 'Project metadata', defaultOpen: true },
{ key: 'background', title: 'Background / Current State', defaultOpen: true },
{ key: 'goal', title: 'Goal', defaultOpen: false },
{ key: 'lineage', title: 'Investigation lineage', defaultOpen: false },
{ key: 'approach', title: 'Approach / Countermeasures', defaultOpen: false },
{ key: 'outcome', title: 'Outcome reference', defaultOpen: false },
];

const DEFAULT_CONTENT: Record<ImprovementProjectSectionKey, React.ReactNode> = {
metadata: <p className="text-content/60">Project metadata placeholder</p>,
background: <p className="text-content/60">Background / Current State placeholder</p>,
goal: <p className="text-content/60">Goal placeholder</p>,
lineage: <p className="text-content/60">Investigation lineage placeholder</p>,
approach: <p className="text-content/60">Approach / Countermeasures placeholder</p>,
outcome: <p className="text-content/60">Outcome reference placeholder</p>,
};

export interface ImprovementProjectFormProps {
currentStep?: number;
metadataProps?: HeaderMetadataSectionProps;
backgroundProps?: BackgroundSectionProps;
goalProps?: GoalSectionProps;
lineageProps?: InvestigationLineageSectionProps;
approachProps?: ApproachSectionProps;
outcomeReferenceProps?: OutcomeReferenceSectionProps;
sectionContent?: Partial<Record<ImprovementProjectSectionKey, SectionContent>>;
}

function renderSectionContent(
content: SectionContent | undefined,
key: ImprovementProjectSectionKey,
metadataProps?: HeaderMetadataSectionProps,
backgroundProps?: BackgroundSectionProps,
goalProps?: GoalSectionProps,
lineageProps?: InvestigationLineageSectionProps,
approachProps?: ApproachSectionProps,
outcomeReferenceProps?: OutcomeReferenceSectionProps
) {
if (typeof content === 'function') {
return content();
}

if (content === undefined && key === 'metadata' && metadataProps) {
return <HeaderMetadataSection {...metadataProps} />;
}

if (content === undefined && key === 'background' && backgroundProps) {
return <BackgroundSection {...backgroundProps} />;
}

if (content === undefined && key === 'goal' && goalProps) {
return <GoalSection {...goalProps} />;
}

if (content === undefined && key === 'lineage' && lineageProps) {
return <InvestigationLineageSection {...lineageProps} />;
}

if (content === undefined && key === 'approach' && approachProps) {
return <ApproachSection {...approachProps} />;
}

if (content === undefined && key === 'outcome' && outcomeReferenceProps) {
return <OutcomeReferenceSection {...outcomeReferenceProps} />;
}

return content ?? DEFAULT_CONTENT[key];
}

export const ImprovementProjectForm: React.FC<ImprovementProjectFormProps> = ({
currentStep = 1,
metadataProps,
backgroundProps,
goalProps,
lineageProps,
approachProps,
outcomeReferenceProps,
sectionContent,
}) => {
return (
<div className="space-y-4">
<ProgressIndicator currentStep={currentStep} />

<div className="space-y-3">
{SECTIONS.map(section => (
<CollapsibleSection
key={section.key}
title={section.title}
defaultOpen={section.defaultOpen}
>
{renderSectionContent(
sectionContent?.[section.key],
section.key,
metadataProps,
backgroundProps,
goalProps,
lineageProps,
approachProps,
outcomeReferenceProps
)}
</CollapsibleSection>
))}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

const DEFAULT_STEPS = [
'Project metadata',
'Background / Current State',
'Goal',
'Investigation lineage',
'Approach / Countermeasures',
'Outcome reference',
];

export interface ProgressIndicatorProps {
currentStep?: number;
steps?: string[];
}

export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
currentStep = 1,
steps = DEFAULT_STEPS,
}) => {
const boundedCurrentStep = Math.min(Math.max(currentStep, 1), steps.length);

return (
<ol aria-label="Improvement project progress" className="grid grid-cols-6 gap-2">
{steps.map((label, index) => {
const stepNumber = index + 1;
const state =
stepNumber < boundedCurrentStep
? 'complete'
: stepNumber === boundedCurrentStep
? 'current'
: 'upcoming';

return (
<li
key={label}
aria-label={`Step ${stepNumber} of ${steps.length}, ${label}, ${state}`}
aria-current={state === 'current' ? 'step' : undefined}
className="min-w-0"
>
<span
className={`block h-2 rounded-full ${
state === 'complete'
? 'bg-content'
: state === 'current'
? 'bg-accent'
: 'bg-surface-secondary'
}`}
/>
<span className="sr-only">{label}</span>
</li>
);
})}
</ol>
);
};
Loading