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
163 changes: 136 additions & 27 deletions app/src/components/flagship/ParameterSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { IconFolder, IconSearch } from '@tabler/icons-react';
import { IconArrowRight, IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import { colors, spacing, typography } from '@/designTokens';
import {
createParameterSearchIndex,
Expand All @@ -22,8 +23,26 @@ interface ParameterSearchBoxProps {
currentValueFor?: (entry: ParameterSearchEntry) => string | null;
/** Derived concept clusters for variant-aware matching */
clusters?: string[][];
/** State code → name, so the scope filter reads "California", not "CA only" */
stateLabels?: Record<string, string>;
/**
* 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.
*/
onOpenFolder?: (folderPath: string) => void;
}

/** The parent path of a leaf: gov.irs.credits.ctc.amount → gov.irs.credits.ctc. */
function parentPath(path: string): string | null {
const lastDot = path.lastIndexOf('.');
return lastDot > 0 ? path.slice(0, lastDot) : null;
}

const CONTRIB_EXPLANATION =
'Policy options contributed to the model — proposed reforms and ' +
'experimental provisions that are not current law.';

const RESULT_LIMIT = 20;

const badgeStyle: React.CSSProperties = {
Expand Down Expand Up @@ -89,6 +108,8 @@ export default function ParameterSearchBox({
placeholder = 'Search any parameter, e.g. child tax credit amount',
currentValueFor,
clusters = [],
stateLabels = {},
onOpenFolder,
index: providedIndex,
}: ParameterSearchBoxProps) {
const [query, setQuery] = useState('');
Expand All @@ -99,7 +120,15 @@ export default function ParameterSearchBox({
() => providedIndex ?? createParameterSearchIndex(entries, clusters),
[providedIndex, entries, clusters]
);
const stateCodes = useMemo(() => listStateCodes(entries), [entries]);
// Named states sort by name; any code the metadata does not name falls
// to the end of the list under its bare code.
const stateOptions = useMemo(
() =>
listStateCodes(entries)
.map((code) => ({ code, label: stateLabels[code] ?? code.toUpperCase() }))
.sort((a, b) => a.label.localeCompare(b.label)),
[entries, stateLabels]
);
const groups = useMemo(
() => groupSearchResults(searchParameters(index, query, RESULT_LIMIT, filters)),
[index, query, filters]
Expand Down Expand Up @@ -143,7 +172,7 @@ export default function ParameterSearchBox({
flexWrap: 'wrap',
}}
>
{stateCodes.length > 0 && (
{stateOptions.length > 0 && (
<label style={controlShell}>
Scope
<select
Expand All @@ -162,24 +191,58 @@ export default function ParameterSearchBox({
>
<option value="all">All jurisdictions</option>
<option value="federal">Federal only</option>
{stateCodes.map((code) => (
<option key={code} value={code}>
{code.toUpperCase()} only
</option>
))}
<optgroup label="States">
{stateOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</optgroup>
</select>
</label>
)}
<label style={{ ...controlShell, cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters.includeContrib}
onChange={(event) => setFilters({ ...filters, includeContrib: event.target.checked })}
aria-label="Include contributed parameters"
style={{ accentColor: colors.primary[500], width: 13, height: 13, margin: 0 }}
/>
Contributed
</label>
<div style={controlShell}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: spacing.xs,
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={filters.includeContrib}
onChange={(event) => setFilters({ ...filters, includeContrib: event.target.checked })}
aria-label="Include contributed parameters"
style={{ accentColor: colors.primary[500], width: 13, height: 13, margin: 0 }}
/>
Contributed
</label>
{/* Outside the label: a control inside it would toggle the filter. */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={CONTRIB_EXPLANATION}
style={{
display: 'flex',
alignItems: 'center',
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'help',
color: colors.text.secondary,
}}
>
<IconInfoCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top" style={{ maxWidth: 240 }}>
{CONTRIB_EXPLANATION}
</TooltipContent>
</Tooltip>
</div>
</div>

<div
Expand Down Expand Up @@ -240,23 +303,58 @@ export default function ParameterSearchBox({
const isFolder = group.entries.length > 1 && group.folder;
return (
<div key={group.folder || group.entries[0].path}>
{isFolder && (
<div
style={{
{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,
}}
>
<IconFolder size={13} />
{group.folder}
</div>
)}
textAlign: 'left',
};
// Only a folder with a resolvable path can be opened;
// otherwise the header stays the label it was.
if (!onOpenFolder || !folderPath) {
return (
<div style={headerStyle}>
<IconFolder size={13} />
{group.folder}
</div>
);
}
return (
<button
type="button"
onClick={() => onOpenFolder(folderPath)}
title={`Open ${group.folder} in the policy tree`}
aria-label={`Open ${group.folder} in the policy tree`}
style={{
...headerStyle,
border: 'none',
background: 'transparent',
cursor: 'pointer',
}}
>
<IconFolder size={13} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{group.folder}
</span>
<IconArrowRight size={12} style={{ flexShrink: 0 }} />
</button>
);
})()}
{group.entries.map((entry) => {
runningIndex += 1;
const i = runningIndex;
Expand Down Expand Up @@ -294,6 +392,11 @@ export default function ParameterSearchBox({
fontFamily: typography.fontFamily.primary,
color: colors.text.primary,
fontWeight: typography.fontWeight.medium,
// Without a zero min-width this column collapses to
// its longest word when a value runs long, stacking
// the label one word per line.
flex: 1,
minWidth: 0,
}}
>
{isFolder
Expand All @@ -306,16 +409,22 @@ export default function ParameterSearchBox({
alignItems: 'center',
gap: spacing.sm,
flexShrink: 0,
// List-valued parameters (a dozen variable names)
// must not push the label out of its own row.
maxWidth: '45%',
}}
>
{currentValueFor?.(entry) && (
<span
title={currentValueFor(entry) ?? undefined}
style={{
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.primary,
color: colors.primary[700],
fontWeight: typography.fontWeight.medium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{currentValueFor(entry)}
Expand Down
34 changes: 33 additions & 1 deletion app/src/components/flagship/ParameterTreeBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react';
import { Text } from '@/components/ui';
import { colors, spacing, typography } from '@/designTokens';
Expand All @@ -12,6 +12,18 @@ interface ParameterTreeBrowserProps {
addablePaths: Set<string>;
/** Paths already in the draft — shown with an "In draft" tag. */
draftPaths: Set<string>;
/**
* 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('.'));
}

/**
Expand All @@ -24,8 +36,24 @@ export default function ParameterTreeBrowser({
onSelectLeaf,
addablePaths,
draftPaths,
expandTo = null,
}: ParameterTreeBrowserProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement | null>(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 (
Expand Down Expand Up @@ -62,6 +90,7 @@ export default function ParameterTreeBrowser({
<div key={node.name}>
<button
type="button"
data-path={node.name}
onClick={() => toggle(node.name)}
aria-expanded={isExpanded}
style={{
Expand All @@ -76,6 +105,7 @@ export default function ParameterTreeBrowser({
fontSize: typography.fontSize.sm,
color: colors.text.primary,
boxSizing: 'border-box',
background: node.name === expandTo ? colors.primary[50] : 'transparent',
}}
>
<ChevronIcon size={14} color={colors.text.secondary} style={{ flexShrink: 0 }} />
Expand All @@ -100,6 +130,7 @@ export default function ParameterTreeBrowser({
<button
key={node.name}
type="button"
data-path={node.name}
disabled={!addable || inDraft}
onClick={() => onSelectLeaf(node.name)}
style={{
Expand Down Expand Up @@ -151,6 +182,7 @@ export default function ParameterTreeBrowser({

return (
<div
ref={containerRef}
style={{
border: `1px solid ${colors.border.light}`,
borderRadius: 12,
Expand Down
Loading
Loading