fbe9940a3f
Distill the UI into a single, recognizable voice: a precision instrument for one operator on one machine. Bloomberg-coded chrome, warm-paper detail surfaces, mono-heavy numerics, with one editorial serif accent for moments of weight. Design system - Three-font stack: Geist Sans (UI), Geist Mono (data), Instrument Serif (editorial display). No Inter, no system fonts. - Two surfaces: dark chrome (--background, --accent, --signal) and warm paper detail surfaces (--surface, --surface-ink*). - Inbox has its own Ticker Tape terminal palette (--tt-*). - Shared component classes: .eyebrow, .mono, .display, .surface, .surface-2, .row-hover, .nav-active, .kbd, .editorial, .hairline. - --m-* token aliases for the legacy drawer components so test- asserted class strings keep resolving to the same hue family. Drawers (Claim + Remit) - Editorial display face for totals (paid/adjustment amounts). - Color-coded money tiles: green-tinted paid card, amber-tinted adjustment card when non-zero, muted otherwise. - Tabs get accent-blue underline + CSS-driven active state. - Validation banners with proper background opacity + ring-around- dot success badge. - StateHistoryTimeline: dashed border, ring around dots, ↳ prefix for remit ids. - DiagnosesList, PartiesGrid, MatchedRemitCard, CasAdjustmentsPanel: refined typography, dashed dividers, italic descriptions, mono amounts, font-semibold totals, hover row tints. Inbox Ticker Tape - Custom RowCheckbox with sr-only input + amber accent. - Alternating row striping, hover tints, accent rail with inset shadow on selection. - Refined sparkline with glow at high values. - BulkBar: bottom-floating bar with amber count chip, larger shadow. - CandidateBreakdown: animated progress bars with amber gradient. - InboxHeader: Instrument Serif headline, mono day/date stamp, pulsing amber status dot. Dialogs & search - NewClaimDialog: editorial title, mono NPI/CPT/amount fields. - SearchBar: refined input row with mono, footer with pulsing loading indicator. - KeyboardCheatsheet: monogram icon chip, hover row states, refined eyebrow header. Primitives - StatusBadge / ClaimStateBadge: per-state dot indicators. - SelectItem: data-[highlighted]:outline tokens for keyboard a11y. - Table primitives: refined header treatment, hover/focus states. Tests + build - 354/354 tests passing across 59 files. - Vite build clean (53.84 kB CSS / 560.86 kB JS). - Eyebrow assertions updated to match the consolidated .eyebrow class (intact visual contract, abstracted class string). - Badge variant tokens updated to the polished bg-muted/80 / /0.14 opacity scale.
269 lines
8.5 KiB
TypeScript
269 lines
8.5 KiB
TypeScript
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
import { Receipt, Stethoscope, Activity as ActivityIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { SearchResult, SearchResultsGrouped } from "@/hooks/useSearch";
|
|
|
|
/**
|
|
* Result row — one entity hit, clickable / Enter-able.
|
|
*
|
|
* The "selected" state is owned by the parent (the SearchBar wires
|
|
* keyboard nav) — we just consume it via the `selected` boolean so
|
|
* the row can paint its accent. We also expose `data-result-index`
|
|
* so the parent's index-based keyboard nav can scroll the matching
|
|
* row into view via `scrollIntoView`.
|
|
*/
|
|
type ResultRowProps = {
|
|
result: SearchResult;
|
|
selected: boolean;
|
|
index: number;
|
|
onSelect: (result: SearchResult) => void;
|
|
onHover: (index: number) => void;
|
|
};
|
|
|
|
const ICON_BY_KIND = {
|
|
claim: Receipt,
|
|
remittance: Stethoscope,
|
|
activity: ActivityIcon,
|
|
} as const;
|
|
|
|
function ResultRow({
|
|
result,
|
|
selected,
|
|
index,
|
|
onSelect,
|
|
onHover,
|
|
}: ResultRowProps) {
|
|
const Icon = ICON_BY_KIND[result.kind];
|
|
return (
|
|
<button
|
|
type="button"
|
|
// `data-result-index` lets the parent keyboard handler call
|
|
// `el.scrollIntoView()` after a nav move — see SearchBar.
|
|
data-result-index={index}
|
|
data-result-kind={result.kind}
|
|
data-result-id={result.id}
|
|
data-selected={selected || undefined}
|
|
// Row is keyboard-focusable so screen readers and keyboard-only
|
|
// users see something focusable moving under ↑/↓. We manage the
|
|
// focused-row state ourselves (via the `selected` prop) rather
|
|
// than relying on the browser's focus ring because the input
|
|
// stays focused while the palette is open.
|
|
aria-selected={selected}
|
|
role="option"
|
|
onMouseDown={(e) => {
|
|
// `mousedown` rather than `click` so the input doesn't blur
|
|
// before the navigation fires — otherwise the user sees a
|
|
// frame of "input lost focus" before the dialog closes.
|
|
e.preventDefault();
|
|
onSelect(result);
|
|
}}
|
|
onMouseEnter={() => onHover(index)}
|
|
className={cn(
|
|
"group w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
|
"focus:outline-none",
|
|
selected
|
|
? "bg-accent/15 ring-1 ring-inset ring-accent/50"
|
|
: "hover:bg-muted/40"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
|
|
selected
|
|
? "bg-accent/25 text-accent"
|
|
: "bg-muted text-muted-foreground group-hover:bg-muted/70"
|
|
)}
|
|
aria-hidden
|
|
>
|
|
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-[13px] font-medium truncate text-foreground">
|
|
{result.title}
|
|
</span>
|
|
<span className="block text-[11px] mono text-muted-foreground truncate">
|
|
{result.subtitle}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"shrink-0 px-1.5 py-0.5 rounded text-[10px] mono uppercase tracking-[0.14em] font-medium",
|
|
"bg-muted/70 text-muted-foreground"
|
|
)}
|
|
>
|
|
{result.badge}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** Section header — small caps label above each group. */
|
|
function GroupHeader({ label }: { label: string }) {
|
|
return (
|
|
<div className="px-4 pt-3 pb-1.5 flex items-center gap-2 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
<span aria-hidden className="inline-block h-px w-3 bg-border" />
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Render the "no matches" state. */
|
|
function EmptyState({ query }: { query: string }) {
|
|
return (
|
|
<div className="px-4 py-12 text-center text-muted-foreground">
|
|
<div className="mono text-[12px]">
|
|
{query ? (
|
|
<>
|
|
No matches for{" "}
|
|
<span className="text-foreground font-semibold">“{query}”</span>
|
|
</>
|
|
) : (
|
|
"Type to search across claims, remittances, and activity."
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Public ref API — the parent (SearchBar) drives navigation from
|
|
* outside the list, so we expose an imperative handle with
|
|
* `focusIndex(i)`. The implementation scrolls the matching row into
|
|
* view; we don't move the actual DOM focus (the input owns that),
|
|
* but we paint the row as selected via the parent's state.
|
|
*/
|
|
export interface SearchResultsHandle {
|
|
focusIndex: (index: number) => void;
|
|
}
|
|
|
|
export interface SearchResultsProps {
|
|
grouped: SearchResultsGrouped;
|
|
selectedIndex: number;
|
|
onSelect: (result: SearchResult) => void;
|
|
onHoverIndex: (index: number) => void;
|
|
}
|
|
|
|
export const SearchResults = forwardRef<SearchResultsHandle, SearchResultsProps>(
|
|
function SearchResults(
|
|
{ grouped, selectedIndex, onSelect, onHoverIndex },
|
|
ref
|
|
) {
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
/**
|
|
* Scroll the row at `index` into view when it changes. We do this
|
|
* imperatively (rather than relying on the browser's native focus
|
|
* scroll) because the input owns focus during keyboard nav — moving
|
|
* DOM focus would close the virtual keyboard on mobile and break
|
|
* the "input stays focused" invariant.
|
|
*/
|
|
useImperativeHandle(ref, () => ({
|
|
focusIndex: (index: number) => {
|
|
const list = listRef.current;
|
|
if (!list) return;
|
|
const el = list.querySelector<HTMLElement>(
|
|
`[data-result-index="${index}"]`
|
|
);
|
|
if (!el) return;
|
|
el.scrollIntoView({ block: "nearest" });
|
|
},
|
|
}), []);
|
|
|
|
const total = grouped.total;
|
|
const isEmpty = total === 0;
|
|
|
|
// Track the running flat-index so `data-result-index` is stable
|
|
// and matches what the parent passes in `selectedIndex`.
|
|
let runningIndex = 0;
|
|
const claimNodes = grouped.claims.map((r) => {
|
|
const idx = runningIndex++;
|
|
return (
|
|
<ResultRow
|
|
key={`claim-${r.id}-${idx}`}
|
|
result={r}
|
|
index={idx}
|
|
selected={idx === selectedIndex}
|
|
onSelect={onSelect}
|
|
onHover={onHoverIndex}
|
|
/>
|
|
);
|
|
});
|
|
const remitNodes = grouped.remittances.map((r) => {
|
|
const idx = runningIndex++;
|
|
return (
|
|
<ResultRow
|
|
key={`remit-${r.id}-${idx}`}
|
|
result={r}
|
|
index={idx}
|
|
selected={idx === selectedIndex}
|
|
onSelect={onSelect}
|
|
onHover={onHoverIndex}
|
|
/>
|
|
);
|
|
});
|
|
const activityNodes = grouped.activity.map((r) => {
|
|
const idx = runningIndex++;
|
|
return (
|
|
<ResultRow
|
|
key={`activity-${r.id}-${idx}`}
|
|
result={r}
|
|
index={idx}
|
|
selected={idx === selectedIndex}
|
|
onSelect={onSelect}
|
|
onHover={onHoverIndex}
|
|
/>
|
|
);
|
|
});
|
|
|
|
// Keep the selected row visible any time the parent updates
|
|
// `selectedIndex` (e.g. via ↑/↓). The `useImperativeHandle` above
|
|
// does the same thing on demand; this effect picks up the cases
|
|
// where the parent doesn't call into us explicitly.
|
|
useEffect(() => {
|
|
const list = listRef.current;
|
|
if (!list) return;
|
|
const el = list.querySelector<HTMLElement>(
|
|
`[data-result-index="${selectedIndex}"]`
|
|
);
|
|
if (!el) return;
|
|
el.scrollIntoView({ block: "nearest" });
|
|
}, [selectedIndex]);
|
|
|
|
return (
|
|
<div
|
|
ref={listRef}
|
|
// `role="listbox"` for the whole results container; each row
|
|
// is `role="option"`. This is what a screen reader expects
|
|
// for a "combobox with listbox popup" pattern.
|
|
role="listbox"
|
|
aria-label="Search results"
|
|
className="max-h-[60vh] overflow-y-auto py-1"
|
|
>
|
|
{isEmpty ? (
|
|
<EmptyState query={grouped.query} />
|
|
) : (
|
|
<>
|
|
{claimNodes.length > 0 && (
|
|
<>
|
|
<GroupHeader label={`Claims · ${claimNodes.length}`} />
|
|
<div className="space-y-0.5 px-1">{claimNodes}</div>
|
|
</>
|
|
)}
|
|
{remitNodes.length > 0 && (
|
|
<>
|
|
<GroupHeader label={`Remittances · ${remitNodes.length}`} />
|
|
<div className="space-y-0.5 px-1">{remitNodes}</div>
|
|
</>
|
|
)}
|
|
{activityNodes.length > 0 && (
|
|
<>
|
|
<GroupHeader label={`Activity · ${activityNodes.length}`} />
|
|
<div className="space-y-0.5 px-1 pb-1">{activityNodes}</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
); |