diff --git a/app/src/components/StandardLayout.tsx b/app/src/components/StandardLayout.tsx index 0e86b61fe..b21b31a0a 100644 --- a/app/src/components/StandardLayout.tsx +++ b/app/src/components/StandardLayout.tsx @@ -15,6 +15,7 @@ import { useDisclosure } from '@/hooks/useDisclosure'; import { cn } from '@/lib/utils'; import { isFlagshipShellEnabled } from '@/libs/featureFlags'; import FlagshipSidebar from './flagship/FlagshipSidebar'; +import { SIDE_PANEL_SLOT_ID } from './flagship/SidePanel'; import GiveCalcBanner from './shared/GiveCalcBanner'; import HeaderNavigation from './shared/HomeHeader'; import Sidebar from './Sidebar'; @@ -50,6 +51,12 @@ export default function StandardLayout({ children }: StandardLayoutProps) {
{children}
+ {/* The right plane: SidePanel portals its content here, so the + panel is a real column of the shell — outside the scrolling + content, full height by construction — rather than a + floating box inside it. Empty (zero width) on pages + without a companion. */} +
); diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 6cef8358c..3125e5029 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,5 +1,11 @@ import { useMemo, useState } from 'react'; -import { IconArrowRight, IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { + IconArrowLeft, + IconChevronRight, + IconFolder, + IconInfoCircle, + IconSearch, +} from '@tabler/icons-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { @@ -26,17 +32,34 @@ interface ParameterSearchBoxProps { /** State code → name, so the scope filter reads "California", not "CA only" */ stateLabels?: Record; /** - * Called with a folder's parameter path when its header is clicked. - * Search shows only the leaves that matched; this is how a reader gets - * to the siblings that did not. + * Label for any tree node by dotted path, from the metadata the + * entries were built from. Powers the clickable breadcrumb when + * browsing a folder; without it the breadcrumb is static text. + */ + labelFor?: (path: string) => string | null | undefined; + /** + * Render results in the page flow rather than floating over it. Set + * when something below needs to stay visible — a floating list would + * sit on top of the very folder it just opened. */ - onOpenFolder?: (folderPath: string) => void; + resultsInFlow?: boolean; } -/** The parent path of a leaf: gov.irs.credits.ctc.amount → gov.irs.credits.ctc. */ +/** + * The folder a leaf sits in: gov.irs.credits.ctc.amount → + * gov.irs.credits.ctc. + * + * Bracket indices are not nodes in the policy tree — it stops at + * `...eitc.max` and renders the brackets inside it — so a trailing + * `[n]` is dropped. Pointing at `...max[0]` names a folder the tree + * cannot reveal, and the reveal silently does nothing. + */ function parentPath(path: string): string | null { const lastDot = path.lastIndexOf('.'); - return lastDot > 0 ? path.slice(0, lastDot) : null; + if (lastDot <= 0) { + return null; + } + return path.slice(0, lastDot).replace(/\[\d+\]$/, ''); } const CONTRIB_EXPLANATION = @@ -72,6 +95,51 @@ function capitalizeFirst(text: string): string { return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; } +interface FolderSubfolder { + path: string; + count: number; + /** One entry inside, to derive a display name when no label exists */ + sample: ParameterSearchEntry; +} + +/** + * One level of a folder: its own leaves plus a row per immediate + * subfolder, instead of every descendant flattened with arrow-prefixed + * labels. Bracket leaves (`max[0].amount`) count as the folder's own — + * bracket indices are not nodes in the policy tree. + */ +function buildFolderView(entries: ParameterSearchEntry[], path: string) { + const descendants: ParameterSearchEntry[] = []; + const direct: ParameterSearchEntry[] = []; + const subfolderMap = new Map(); + for (const entry of entries) { + const inDot = entry.path.startsWith(`${path}.`); + if (!inDot && !entry.path.startsWith(`${path}[`)) { + continue; + } + descendants.push(entry); + const rest = inDot ? entry.path.slice(path.length + 1) : ''; + const separator = rest.search(/[.[]/); + if (!inDot || separator === -1) { + direct.push(entry); + continue; + } + const subPath = `${path}.${rest.slice(0, separator)}`; + const subfolder = subfolderMap.get(subPath); + if (subfolder) { + subfolder.count += 1; + } else { + subfolderMap.set(subPath, { path: subPath, count: 1, sample: entry }); + } + } + direct.sort((a, b) => a.path.localeCompare(b.path)); + return { + descendants, + direct, + subfolders: [...subfolderMap.values()].sort((a, b) => a.path.localeCompare(b.path)), + }; +} + function EntryBadges({ entry }: { entry: ParameterSearchEntry }) { return ( @@ -109,11 +177,20 @@ export default function ParameterSearchBox({ currentValueFor, clusters = [], stateLabels = {}, - onOpenFolder, + resultsInFlow = false, index: providedIndex, + labelFor, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); const [highlighted, setHighlighted] = useState(0); + const [hoveredFolder, setHoveredFolder] = useState(null); + /** + * A folder opened from the results: the dropdown shows everything the + * folder holds, in place — search only surfaced the leaves that + * matched, and the siblings that did not are usually what a near miss + * needs. `folder` is the breadcrumb, kept to label rows relative to it. + */ + const [browsing, setBrowsing] = useState<{ path: string; folder: string } | null>(null); const [filters, setFilters] = useState(DEFAULT_SEARCH_FILTERS); const index = useMemo( @@ -133,15 +210,85 @@ export default function ParameterSearchBox({ () => groupSearchResults(searchParameters(index, query, RESULT_LIMIT, filters)), [index, query, filters] ); - const flatEntries = useMemo(() => groups.flatMap((group) => group.entries), [groups]); + const folderView = useMemo( + () => (browsing ? buildFolderView(entries, browsing.path) : null), + [entries, browsing] + ); + + /** + * The folder's display name, as the longest breadcrumb prefix its + * contents share. The clicked header's label can be deeper than the + * folder itself — "… → Bracket 1" for a folder that also holds + * Bracket 2 — because bracket indices fold into their parent. + * Fallback for when no `labelFor` lookup produces crumbs. + */ + const folderLabel = useMemo(() => { + const contents = folderView?.descendants ?? []; + if (contents.length === 0) { + return browsing?.folder ?? ''; + } + let prefix = contents[0].breadcrumb.split(' → ').slice(0, -1); + for (const entry of contents.slice(1)) { + const segments = entry.breadcrumb.split(' → '); + let shared = 0; + while (shared < prefix.length && prefix[shared] === segments[shared]) { + shared += 1; + } + prefix = prefix.slice(0, shared); + } + return prefix.join(' → '); + }, [folderView, browsing]); + + /** + * The breadcrumb as clickable ancestors: every prefix of the folder + * path that the metadata gives a label to. Unlabeled nodes simply get + * no crumb, matching how entry breadcrumbs are built. + */ + const crumbs = useMemo(() => { + if (!browsing || !labelFor) { + return []; + } + const segments = browsing.path.split('.'); + const found: { path: string; label: string }[] = []; + for (let depth = 2; depth <= segments.length; depth += 1) { + const ancestor = segments.slice(0, depth).join('.'); + const label = labelFor(ancestor); + if (label) { + found.push({ path: ancestor, label: capitalizeFirst(label) }); + } + } + return found; + }, [browsing, labelFor]); + + // What entry breadcrumbs are sliced against; the crumb join equals + // the shared breadcrumb prefix because both come from the same labels. + const currentLabel = crumbs.length > 0 ? crumbs.map((c) => c.label).join(' → ') : folderLabel; + + const flatEntries = useMemo( + () => (browsing ? (folderView?.direct ?? []) : groups.flatMap((group) => group.entries)), + [browsing, folderView, groups] + ); const select = (entry: ParameterSearchEntry) => { onSelect(entry); setQuery(''); + setBrowsing(null); setHighlighted(0); }; const handleKeyDown = (event: React.KeyboardEvent) => { + // Escape works even in a folder with no direct parameters, where + // there is nothing to highlight but still somewhere to go back to. + if (event.key === 'Escape') { + // Step out of the folder first; a second Escape clears the search. + if (browsing) { + setBrowsing(null); + setHighlighted(0); + } else { + setQuery(''); + } + return; + } if (!flatEntries.length) { return; } @@ -154,8 +301,6 @@ export default function ParameterSearchBox({ } else if (event.key === 'Enter') { event.preventDefault(); select(flatEntries[highlighted]); - } else if (event.key === 'Escape') { - setQuery(''); } }; @@ -261,13 +406,14 @@ export default function ParameterSearchBox({ value={query} onChange={(event) => { setQuery(event.target.value); + setBrowsing(null); setHighlighted(0); }} onKeyDown={handleKeyDown} placeholder={placeholder} aria-label="Search parameters" role="combobox" - aria-expanded={flatEntries.length > 0} + aria-expanded={Boolean(browsing) || flatEntries.length > 0} aria-controls="parameter-search-results" style={{ flex: 1, @@ -280,177 +426,463 @@ export default function ParameterSearchBox({ /> - {flatEntries.length > 0 && ( + {(browsing || flatEntries.length > 0) && (
- {groups.map((group) => { - const isFolder = group.entries.length > 1 && group.folder; - return ( -
- {isFolder && - (() => { - const folderPath = parentPath(group.entries[0].path); - const headerStyle: React.CSSProperties = { + {browsing && ( + <> + +
+ + + {crumbs.length > 0 ? ( + crumbs.map((crumb, idx) => ( + + {idx > 0 && } + {idx < crumbs.length - 1 ? ( + + ) : ( + {crumb.label} + )} + + )) + ) : ( + + {folderLabel} + + )} + + + {folderView?.descendants.length ?? 0} parameter + {(folderView?.descendants.length ?? 0) === 1 ? '' : 's'} + +
+ {/* The full path once, instead of repeated under every row. */} +
+ {browsing.path} +
+ {(folderView?.direct ?? []).map((entry, i) => ( + + ))} + {(folderView?.subfolders ?? []).map((sub) => { + const fromLookup = labelFor?.(sub.path); + const name = fromLookup + ? capitalizeFirst(fromLookup) + : currentLabel && sub.sample.breadcrumb.startsWith(`${currentLabel} → `) + ? sub.sample.breadcrumb.slice(currentLabel.length + 3).split(' → ')[0] + : capitalizeFirst( + sub.path.slice(sub.path.lastIndexOf('.') + 1).replace(/_/g, ' ') + ); + const isHovered = hoveredFolder === sub.path; + return ( + + ); + })} + + )} + {!browsing && + groups.map((group) => { + const isFolder = group.entries.length > 1 && group.folder; + return ( +
+ {isFolder && + (() => { + const folderPath = parentPath(group.entries[0].path); + const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: spacing.xs, + width: '100%', + padding: `${spacing.sm} ${spacing.lg} ${spacing.xs}`, + fontSize: typography.fontSize.xs, + fontFamily: typography.fontFamily.primary, + fontWeight: typography.fontWeight.semibold, + color: colors.text.secondary, + textAlign: 'left', + }; + // Only a folder with a resolvable path can be + // browsed; otherwise the header stays the label it was. + if (!folderPath) { + return ( +
+ + {group.folder} +
+ ); + } + // Keyed by the group's breadcrumb, not the stripped + // folder path — bracket siblings share the path and + // would hover in lockstep. + const isHovered = hoveredFolder === group.folder; return ( -
+
+ + {group.folder} + + {/* The constant affordance: folders open, rows add. */} + + ); - } + })()} + {group.entries.map((entry) => { + runningIndex += 1; + const i = runningIndex; return (
+
- {group.folder} - - + {entry.path} +
); - })()} - {group.entries.map((entry) => { - runningIndex += 1; - const i = runningIndex; - return ( - - ); - })} -
- ); - })} + })} +
+ ); + })} )} diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index c4df0c27d..eea86e382 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react'; -import { Text } from '@/components/ui'; +import { Spinner, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { ParameterTreeNode } from '@/types/metadata'; @@ -12,18 +12,6 @@ interface ParameterTreeBrowserProps { addablePaths: Set; /** Paths already in the draft — shown with an "In draft" tag. */ draftPaths: Set; - /** - * Folder path to reveal — its ancestors expand, it opens, and it - * scrolls into view. Set when a search result's folder is opened, so - * a near miss leads to the parameters around it. - */ - expandTo?: string | null; -} - -/** Every ancestor path of a dotted parameter path, plus the path itself. */ -function pathWithAncestors(path: string): string[] { - const segments = path.split('.'); - return segments.map((_, index) => segments.slice(0, index + 1).join('.')); } /** @@ -36,30 +24,17 @@ export default function ParameterTreeBrowser({ onSelectLeaf, addablePaths, draftPaths, - expandTo = null, }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); - const containerRef = useRef(null); - - useEffect(() => { - if (!expandTo) { - return; - } - setExpanded((prev) => new Set([...prev, ...pathWithAncestors(expandTo)])); - // The rows for those ancestors only exist after the expansion renders. - const frame = requestAnimationFrame(() => { - const rows = containerRef.current?.querySelectorAll('[data-path]') ?? []; - const target = [...rows].find((row) => row.getAttribute('data-path') === expandTo); - target?.scrollIntoView({ block: 'center' }); - }); - return () => cancelAnimationFrame(frame); - }, [expandTo]); if (!tree) { return ( - - Loading the policy tree… - +
+ + + Loading the policy tree… + +
); } @@ -90,7 +65,6 @@ export default function ParameterTreeBrowser({
- {open && ( -
- - - {draft.provisions.map((provision) => ( - + - - - {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} - - - - - - {formatValue(provision.baselineValue, provision.unit)} → - - - - - ))} - - - - setDraftLabel(event.target.value)} - placeholder="Name this reform, e.g. CTC expansion 2026" - aria-label="Reform name" + {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} + + + + - {!hasEditedValue && draft.provisions.length > 0 && ( - - Values match current law so far — edit a value above to make this a reform. + > + + {formatValue(provision.baselineValue, provision.unit)} → - )} + + - - - 0 && ( + - {( - [ - { scope: 'national', label: 'Nationwide', enabled: true }, - { scope: 'household', label: 'A household', enabled: false }, - ] as const - ).map(({ scope, label, enabled }) => { - const active = draft.population.scope === scope; - return ( - - ); - })} - + Values match current law so far — edit a value above to make this a reform. + + )} + - - - - Baseline: current law · {new Date().getFullYear()} - - {runReport.error && ( - - {runReport.error} - - )} - + + + {( + [ + { scope: 'national', label: 'Nationwide', enabled: true }, + { scope: 'household', label: 'A household', enabled: false }, + ] as const + ).map(({ scope, label, enabled }) => { + const active = draft.population.scope === scope; + return ( + + ); + })} + - - {saveMutation.isError && ( - - Could not save the reform. Try again. - - )} - - - - - - -
- )} - + + + + Baseline: current law · {new Date().getFullYear()} + + {runReport.error && ( + + {runReport.error} + + )} + + + + {saveMutation.isError && ( + + Could not save the reform. Try again. + + )} + {/* The primary verb gets the full row; the two secondary verbs + share the one below it. */} + + + + + + + ); } diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 9e6fe6f3a..e40ba4f3e 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -1,10 +1,5 @@ import { useState } from 'react'; -import { - IconAdjustments, - IconChartBar, - IconLayoutSidebarRightCollapse, - IconX, -} from '@tabler/icons-react'; +import { IconChartBar, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; import { getReformStore } from '@/api/reformStore'; import { Button, Stack, Text } from '@/components/ui'; @@ -16,6 +11,7 @@ import { RunReportProvision } from '@/libs/flagship/runReport'; import { Reform } from '@/types/ingredients/Reform'; import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; import { formatValue } from '@/utils/parameterValues'; +import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; interface ReportAdjustPanelProps { @@ -54,9 +50,6 @@ export default function ReportAdjustPanel({ const runReport = useRunFlagshipReport(); const countryId = useCurrentCountry(); const queryClient = useQueryClient(); - // Collapsed by default: the report is the main event; adjusting is - // one click away on the edge tab. - const [collapsed, setCollapsed] = useState(true); const [removed, setRemoved] = useState>(new Set()); const [reconcileError, setReconcileError] = useState(null); const [isReconciling, setIsReconciling] = useState(false); @@ -124,87 +117,12 @@ export default function ReportAdjustPanel({ } }; - if (collapsed) { - return ( - - ); - } - return ( - - - - Adjust parameters - - - - {active.map((provision) => ( - + ); } diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx new file mode 100644 index 000000000..5ceb14872 --- /dev/null +++ b/app/src/components/flagship/SidePanel.tsx @@ -0,0 +1,248 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; +import { IconChevronDown } from '@tabler/icons-react'; +import { createPortal } from 'react-dom'; +import { Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +/** + * The flagship shell's right plane. + * + * Both companions — the draft reform and a report's adjust rail — are + * the same shape: a column beside the content, folding to a slim spine. + * The chrome lives here once, and the column is real: the panel portals + * into a slot that is a flex sibling of the shell's scrolling
+ * (see StandardLayout), so it runs the full height of the page by + * construction, never scrolls away with the content, and never wraps + * beneath it. Where the slot does not exist (tests, the legacy shell) + * the panel renders in place. + */ +export const SIDE_PANEL_SLOT_ID = 'flagship-side-panel-slot'; + +const PANEL_WIDTH = 340; +const SPINE_WIDTH = 40; +/** Width eases over this; the two faces crossfade inside it. */ +const WIDTH_MS = 240; +const FADE_MS = 160; + +/** Effects that must run before paint, without warning during SSR. */ +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +interface SidePanelProps { + /** Header text, and the label on the folded spine. */ + title: string; + /** + * Small uppercase label above the title — the panel's kind ("Draft"), + * styled like the sidebar's section labels, so the title itself can be + * the thing's own name. + */ + kicker?: string; + /** Right-hand note in the header: source, count, whatever is short. */ + meta?: string; + /** Teal title, for a panel holding unsaved work — the same accent the + * sidebar gives its active item, not a colored band. */ + accent?: boolean; + /** Report companions start folded; the draft starts open. */ + defaultOpen?: boolean; + /** + * Remember the fold across navigations under this key. The draft + * panel follows the reader between pages; without persistence every + * navigation would spring a deliberately folded panel back open. + */ + storageKey?: string; + children: React.ReactNode; +} + +function readStoredOpen(storageKey: string | undefined, fallback: boolean): boolean { + if (!storageKey || typeof sessionStorage === 'undefined') { + return fallback; + } + const stored = sessionStorage.getItem(`side-panel-open:${storageKey}`); + return stored === null ? fallback : stored === 'true'; +} + +export default function SidePanel({ + title, + kicker, + meta, + accent = false, + defaultOpen = true, + storageKey, + children, +}: SidePanelProps) { + const [open, setOpenState] = useState(() => readStoredOpen(storageKey, defaultOpen)); + const [slot, setSlot] = useState(null); + + useIsomorphicLayoutEffect(() => { + setSlot(document.getElementById(SIDE_PANEL_SLOT_ID)); + }, []); + + const setOpen = (next: boolean) => { + setOpenState(next); + if (storageKey && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(`side-panel-open:${storageKey}`, String(next)); + } + }; + + // The plane mirrors the left sidebar: the same flat surface and quiet + // edge, with teal reserved for text — no colored bands. + const titleColor = accent ? colors.primary[700] : colors.text.primary; + const bodyId = `side-panel-body-${(storageKey ?? title).replace(/\W+/g, '-').toLowerCase()}`; + + const reduceMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + /** + * Both faces stay mounted so the fold can animate: the container's + * width eases between panel and spine while the faces crossfade. + * visibility (not just opacity) removes the hidden face from the + * accessibility tree and tab order, delayed so the fade-out is seen. + */ + const face = (visible: boolean): React.CSSProperties => ({ + opacity: visible ? 1 : 0, + visibility: visible ? 'visible' : 'hidden', + transition: reduceMotion + ? undefined + : `opacity ${FADE_MS}ms ease, visibility 0s linear ${visible ? 0 : FADE_MS}ms`, + }); + + const content = ( +
+
+ + {/* The body scrolls, not the page: the plane keeps its own height. */} +
+ {children} +
+
+ + +
+ ); + + // Into the shell's right-plane slot when it exists; in place otherwise. + return slot ? createPortal(content, slot) : content; +} diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index f79534ad1..13dfdaf61 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -10,11 +10,10 @@ interface WorkspaceLayoutProps { } /** - * Shared layout for the working sections (Ask, Build, Reforms). With - * no draft the content sits alone in a centered column; the moment a - * draft exists it slides in as a sticky right panel — the panel only - * exists when there is something in it. Panes wrap to a single column - * on narrow screens. + * Shared layout for the working sections (Ask, Build, Reforms). The + * content sits in a centered column; the moment a draft exists its + * panel appears — rendered here, docked by SidePanel into the shell's + * right plane, so the layout never manages rail geometry itself. */ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLayoutProps) { const draft = useDraftReform(); @@ -22,37 +21,10 @@ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLay const hasDraft = Boolean(draft && draft.countryId === countryId && draft.provisions.length > 0); const contentWidth = wide ? 1400 : 760; - if (!hasDraft) { - return
{children}
; - } - return ( -
-
-
-
{children}
-
-
- - -
-
+
+ {children} + {hasDraft && }
); } diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index a0082cf10..e2ea04035 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -932,13 +932,11 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps)
-
- -
+
); diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index 9445121d3..e0f0743ef 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; import ParameterTreeBrowser from '@/components/flagship/ParameterTreeBrowser'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { Button, Stack, Text, Title } from '@/components/ui'; +import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; @@ -41,7 +41,6 @@ export default function BuildPage() { // know what the thing is called, so it stays out of the way until asked for. const [showTree, setShowTree] = useState(false); // The folder a search result pointed at, revealed in the tree below. - const [treeFocus, setTreeFocus] = useState(null); // Store-memoized like the index: built once per metadata load, not // per navigation or render. @@ -84,40 +83,38 @@ export default function BuildPage() { stateLabels={stateLabels} index={searchIndex} onSelect={addEntry} - onOpenFolder={(folderPath) => { - // Results stay up: the near miss is worth comparing - // against whatever the folder turns out to hold. - setShowTree(true); - setTreeFocus(folderPath); - }} + labelFor={(path) => parameters?.[path]?.label ?? null} + // Always in flow on this page: a floating list would + // cover the tree when it is open. + resultsInFlow currentValueFor={(entry) => { const value = getCurrentValue(parameters?.[entry.path]?.values); return value === undefined ? null : formatValue(value, entry.unit); }} /> ) : ( - - Loading the parameter index… - + + + Loading the parameter index… + + )}
-
- -
+ ); diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index 352063d31..90d692546 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -51,6 +51,48 @@ const ENTRIES: ParameterSearchEntry[] = [ }, ]; +// A folder with both its own leaf and a nested subfolder, for the +// in-place folder browser: crumbs up, subfolder rows down. +const REFUNDABILITY_ENTRIES: ParameterSearchEntry[] = [ + { + path: 'gov.irs.credits.ctc.refundable.fully_refundable', + label: 'fully refundable', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Fully refundable', + unit: 'bool', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.ctc.refundable.phase_in.rate', + label: 'rate', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Phase-in → Rate', + unit: '/1', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.ctc.refundable.phase_in.threshold', + label: 'threshold', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Phase-in → Threshold', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, +]; + +const NODE_LABELS: Record = { + 'gov.irs': 'IRS', + 'gov.irs.credits': 'Credits', + 'gov.irs.credits.ctc': 'Child tax credit', + 'gov.irs.credits.ctc.refundable': 'Refundability', + 'gov.irs.credits.ctc.refundable.phase_in': 'Phase-in', +}; + +const labelForNode = (path: string) => NODE_LABELS[path] ?? null; + describe('ParameterSearchBox', () => { test('given a matching query then results show breadcrumb and path', async () => { // Given @@ -240,34 +282,137 @@ describe('ParameterSearchBox', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); - test('given a folder group then its header opens that folder in the tree', async () => { - // Given + test('given a folder header is clicked then the folder contents show in place', async () => { + // Given — search matched only one of the folder's parameters const user = userEvent.setup(); - const onOpenFolder = vi.fn(); - render(); + const bracketed: ParameterSearchEntry[] = [ + { + path: 'gov.irs.credits.eitc.max[0].threshold', + label: 'threshold', + breadcrumb: 'IRS → Credits → EITC → Maximum → Bracket 1 → Threshold', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.eitc.max[0].amount', + label: 'amount', + breadcrumb: 'IRS → Credits → EITC → Maximum → Bracket 1 → Amount', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.eitc.max[1].threshold', + label: 'threshold', + breadcrumb: 'IRS → Credits → EITC → Maximum → Bracket 2 → Threshold', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, + ]; + render(); + // 'bracket' matches both Bracket 1 rows, so they cluster under a + // folder header; Bracket 2's lone row stays standalone. + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'bracket'); + + // When — the bracket index is not a folder of its own, so the header + // resolves to the real parent and lists every descendant + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + + // Then — the sibling the query missed is now on screen + expect(screen.getByText('3 parameters')).toBeInTheDocument(); + expect(screen.getByText('Bracket 1 → Amount')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /back to matches/i })).toBeInTheDocument(); + }); - // When + test('given folder contents then selecting one adds it and closes the list', async () => { + const user = userEvent.setup(); + const onSelectEntry = vi.fn(); + render(); await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'eitc'); - await user.click( - screen.getByRole('button', { name: /open irs → credits → eitc in the policy tree/i }) - ); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + + await user.click(screen.getByText('Phase-in rate')); - // Then — the folder path, not the breadcrumb - expect(onOpenFolder).toHaveBeenCalledWith('gov.irs.credits.eitc'); + expect(onSelectEntry).toHaveBeenCalledWith( + expect.objectContaining({ path: 'gov.irs.credits.eitc.phase_in_rate' }) + ); }); - test('given no folder handler then the header stays a label', async () => { - // Given + test('given back to matches then the search results return', async () => { const user = userEvent.setup(); render(); + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'eitc'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + + await user.click(screen.getByRole('button', { name: /back to matches/i })); + + expect(screen.getByText('IRS → Credits → EITC')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /back to matches/i })).not.toBeInTheDocument(); + }); + + test('given a breadcrumb crumb is clicked then the parent folder opens with subfolder rows', async () => { + // Given — browsing the Phase-in folder, reached from search + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'phase-in'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + expect(screen.getByText('2 parameters')).toBeInTheDocument(); + + // When — stepping up one level via the breadcrumb + await user.click(screen.getByRole('button', { name: 'Refundability' })); + + // Then — the parent's own leaf shows, and Phase-in folds into a + // subfolder row instead of flattened arrow-prefixed rows + expect(screen.getByText('3 parameters')).toBeInTheDocument(); + expect(screen.getByText('Fully refundable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open phase-in/i })).toBeInTheDocument(); + expect(screen.queryByText('Phase-in → Rate')).not.toBeInTheDocument(); + }); + + test('given a subfolder row is clicked then the dropdown descends into it', async () => { + // Given — browsing the Refundability folder + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'phase-in'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + await user.click(screen.getByRole('button', { name: 'Refundability' })); // When - await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'eitc'); + await user.click(screen.getByRole('button', { name: /open phase-in/i })); // Then - expect(screen.getByText('IRS → Credits → EITC')).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: /open irs → credits → eitc in the policy tree/i }) - ).not.toBeInTheDocument(); + expect(screen.getByText('2 parameters')).toBeInTheDocument(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + expect(screen.getByText('Threshold')).toBeInTheDocument(); + }); + + test('given escape inside a folder then it steps back to matches, not to empty', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox', { name: /search parameters/i }); + await user.type(input, 'eitc'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + + await user.type(input, '{Escape}'); + + expect(input).toHaveValue('eitc'); + expect(screen.queryByRole('button', { name: /back to matches/i })).not.toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx b/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx index 3de077a7c..70a0ed737 100644 --- a/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx @@ -118,35 +118,4 @@ describe('ParameterTreeBrowser', () => { expect(screen.getByText(/loading the policy tree/i)).toBeInTheDocument(); }); - - test('given expandTo then the folder and its ancestors open to reveal the parameter', () => { - // Given / When - render( - - ); - - // Then — no click needed; the ancestor chain expanded on its own - expect(screen.getByText('Child tax credit amount')).toBeInTheDocument(); - }); - - test('given no expandTo then nothing is expanded', () => { - // Given / When - render( - - ); - - // Then - expect(screen.queryByText('Child tax credit amount')).not.toBeInTheDocument(); - }); }); diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index c200c4757..8e1639265 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -58,6 +58,9 @@ function renderCard() { describe('ReformPreviewCard', () => { beforeEach(() => { vi.clearAllMocks(); + // The panel remembers its fold in sessionStorage; a fold test must + // not leak a closed panel into the next test. + sessionStorage.clear(); clearDraftReform(); seedDraft(); }); @@ -150,12 +153,13 @@ describe('ReformPreviewCard', () => { renderCard(); // When - await user.click(screen.getByRole('button', { name: /here's your draft reform/i })); + await user.click(screen.getByRole('button', { name: /collapse new reform/i })); - // Then — the count survives the fold; the editing controls do not - expect(screen.getByText('1 provision')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); + // Then — the folded spine keeps the panel's name; the controls fade + // out but stay mounted so the fold can animate + expect(screen.getByRole('button', { name: /open new reform/i })).toBeInTheDocument(); + expect(screen.getByText('Run report')).not.toBeVisible(); + expect(screen.getByLabelText('Reform name')).not.toBeVisible(); }); test('given a folded draft then clicking again restores the controls', async () => { @@ -163,15 +167,13 @@ describe('ReformPreviewCard', () => { const user = userEvent.setup(); seedDraft(); renderCard(); - const header = screen.getByRole('button', { name: /here's your draft reform/i }); - // When - await user.click(header); - await user.click(header); + await user.click(screen.getByRole('button', { name: /collapse new reform/i })); + await user.click(screen.getByRole('button', { name: /open new reform/i })); // Then - expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); - expect(screen.getByLabelText('Reform name')).toBeInTheDocument(); + expect(screen.getByText('Run report')).toBeVisible(); + expect(screen.getByLabelText('Reform name')).toBeVisible(); }); test('given the default view then the draft is open', () => { @@ -180,9 +182,25 @@ describe('ReformPreviewCard', () => { renderCard(); // Then — an unseen draft is what this panel exists to prevent - expect(screen.getByRole('button', { name: /here's your draft reform/i })).toHaveAttribute( + expect(screen.getByRole('button', { name: /collapse new reform/i })).toHaveAttribute( 'aria-expanded', 'true' ); }); + + test('given the draft is named then the panel header carries that name', async () => { + // Given + const user = userEvent.setup(); + seedDraft(); + renderCard(); + + // When + await user.type(screen.getByLabelText('Reform name'), 'CTC expansion 2026'); + + // Then — the title is the reform's identity, like a document title + expect( + screen.getByRole('button', { name: /collapse ctc expansion 2026/i }) + ).toBeInTheDocument(); + expect(screen.getByText('Draft')).toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index d8f10bc6a..8c883b03c 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -56,7 +56,7 @@ async function renderPanel() { ); // Collapsed by default — expand via the edge tab before interacting. - await userEvent.setup().click(screen.getByRole('button', { name: /adjust parameters/i })); + await userEvent.setup().click(screen.getByRole('button', { name: /open adjust parameters/i })); return result; } @@ -160,10 +160,10 @@ describe('ReportAdjustPanel', () => { const user = userEvent.setup(); await renderPanel(); - await user.click(screen.getByRole('button', { name: /collapse the adjust panel/i })); + await user.click(screen.getByRole('button', { name: /collapse adjust parameters/i })); expect(screen.queryByRole('button', { name: /recompute/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /adjust parameters/i })); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); expect(screen.getByRole('button', { name: /recompute/i })).toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx new file mode 100644 index 000000000..14b815807 --- /dev/null +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test } from 'vitest'; +import SidePanel from '@/components/flagship/SidePanel'; + +describe('SidePanel', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('given a storageKey then the fold survives a remount', async () => { + const user = userEvent.setup(); + const { unmount } = render( + +

panel body

+
+ ); + await user.click(screen.getByRole('button', { name: /collapse draft reform/i })); + unmount(); + + // Remount, as a page navigation does — the fold must hold. + render( + +

panel body

+
+ ); + + expect(screen.getByRole('button', { name: /open draft reform/i })).toBeInTheDocument(); + expect(screen.getByText('panel body')).not.toBeVisible(); + }); + + test('given the shell slot exists then the panel renders into it', () => { + const slot = document.createElement('div'); + slot.id = 'flagship-side-panel-slot'; + document.body.appendChild(slot); + try { + render( + +

panel body

+
+ ); + + expect(slot.textContent).toContain('panel body'); + } finally { + slot.remove(); + } + }); + + test('given an open panel then its body and header meta show', () => { + render( + +

panel body

+
+ ); + + expect(screen.getByText('panel body')).toBeInTheDocument(); + expect(screen.getByText('2 provisions')).toBeInTheDocument(); + }); + + test('given the header is clicked then the panel folds to a titled spine', async () => { + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + await user.click(screen.getByRole('button', { name: /collapse adjust parameters/i })); + + // The spine keeps the panel's name; the body fades out but stays + // mounted so the fold can animate — hidden, not removed. + expect(screen.getByRole('button', { name: /open adjust parameters/i })).toBeInTheDocument(); + expect(screen.getByText('panel body')).not.toBeVisible(); + }); + + test('given defaultOpen false then the panel starts folded', () => { + render( + +

panel body

+
+ ); + + expect(screen.getByRole('button', { name: /open adjust parameters/i })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(screen.getByText('panel body')).not.toBeVisible(); + }); + + test('given a folded panel then reopening restores the body', async () => { + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + await user.click(screen.getByRole('button', { name: /open draft reform/i })); + + expect(screen.getByText('panel body')).toBeVisible(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx index a0af54d52..7c7b5bbd9 100644 --- a/app/src/tests/unit/pages/flagship/Build.test.tsx +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -35,4 +35,14 @@ describe('BuildPage', () => { expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); }); + + test('given the parameter index has not loaded then a spinner stands in for the search box', () => { + // Given / When — the store starts empty, as it does on a cold load + render(); + + // Then + expect(screen.getByText(/loading the parameter index/i)).toBeInTheDocument(); + expect(screen.getAllByRole('status').length).toBeGreaterThan(0); + expect(screen.queryByRole('combobox', { name: /search parameters/i })).not.toBeInTheDocument(); + }); }); diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 7b103d1eb..96851b370 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,3 +1 @@ -- Name states in the parameter search filter, explain the contributed filter, and stop long parameter values from squeezing result labels -- Lead the build page with search, fold the policy tree behind a toggle, and open a search result's folder in the tree -- Let the draft reform panel fold away +- Dock the draft reform and report adjust panels as a full-height right-hand plane that folds to a spine