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) && (
+ );
+ })}
)}
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({
);
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…
+
+
)}
+
+ );
+
+ 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