AccountPicker
- Status
- Implemented
- Family
- 9 of 13
- Registry
- @lopifi/account-picker
npx shadcn@latest add @lopifi/theme @lopifi/account-pickerThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
Searchable by number and name, grouped by type and sub-classification. Shows the normal balance (Dr/Cr) — a user posting to an unfamiliar account needs to know which side increases it. Non-postable rollup accounts visibly disabled with the reason. Inactive accounts hidden but searchable. Virtualized; typeahead on the account number.
Lifted verbatim from Accounting core (GL). The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/account-picker.tsx
- Lopifi dependencies · 4
- accounting.typescniconsmoney.types
- npm packages
- @base-ui-components/reactclsxtailwind-merge
- Registry entry
- /r/account-picker.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import { Popover } from "@base-ui-components/react/popover";
import * as React from "react";
import type { Account } from "@/lib/ledger/accounting.types";
import { cn } from "@/lib/ledger/cn";
import { ChevronDownIcon, LockClosedIcon, MagnifyingGlassIcon } from "@/components/ledger/icons";
export interface AccountPickerProps {
accounts: Account[];
className?: string;
/** Inactive accounts are hidden by default but reachable by search. */
includeInactive?: boolean;
label?: string;
onValueChange: (account: Account) => void;
value: string | null;
}
/**
* Shows the normal balance (Dr / Cr) — a user posting to an unfamiliar
* account needs to know which side increases it.
*
* Non-postable rollup accounts are visibly disabled with the reason:
* posting to a rollup is a data-integrity failure that surfaces months
* later at close.
*/
export function AccountPicker({
accounts,
value,
onValueChange,
includeInactive = false,
className,
label = "Account",
}: AccountPickerProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const selected = accounts.find((a) => a.id === value);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return accounts.filter((a) => {
if (a.status === "archived") return false;
if (a.status === "inactive" && !includeInactive && !q) return false;
if (!q) return true;
return a.number.toLowerCase().startsWith(q) || a.name.toLowerCase().includes(q) || a.number.includes(q);
});
}, [accounts, query, includeInactive]);
const grouped = React.useMemo(() => {
const map = new Map<string, Account[]>();
for (const a of filtered) {
const key = `${a.type}${a.subType ? ` · ${a.subType}` : ""}`;
const list = map.get(key) ?? [];
list.push(a);
map.set(key, list);
}
return [...map.entries()];
}, [filtered]);
return (
<Popover.Root onOpenChange={setOpen} open={open}>
<Popover.Trigger
aria-label={
selected ? `${label}: ${selected.number} ${selected.name}` : `Choose an ${label.toLowerCase()}`
}
className={cn(
"flex h-10 w-full items-center gap-2 rounded-sm border border-border bg-surface px-3",
"text-left transition-colors duration-1 ease-standard hover:border-border-strong",
className,
)}
>
{selected ? (
<>
<span className="reference-numeric shrink-0 text-caption text-text-tertiary">
{selected.number}
</span>
<span className="min-w-0 flex-1 truncate text-body">{selected.name}</span>
<NormalBadge normal={selected.normalBalance} />
</>
) : (
<span className="flex-1 text-body text-text-tertiary">Choose an account</span>
)}
<ChevronDownIcon aria-hidden="true" className="size-4 shrink-0 text-text-tertiary" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner align="start" className="z-dropdown" sideOffset={8}>
<Popover.Popup
className="w-[var(--anchor-width)] min-w-[360px] overflow-hidden rounded-md border border-border bg-surface-raised shadow-elevation-3"
style={{ transformOrigin: "var(--transform-origin)" }}
>
<div className="flex items-center gap-2 border-b border-border-subtle px-3">
<MagnifyingGlassIcon aria-hidden="true" className="size-4 text-text-tertiary" />
<input
aria-label="Search accounts"
autoFocus
className="reference-numeric h-10 flex-1 bg-transparent text-body outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder="Account number or name"
value={query}
/>
</div>
<div className="max-h-[320px] overflow-y-auto py-1 scrollbar-thin">
{grouped.map(([group, list]) => (
<div key={group}>
<p className="px-3 py-1 text-caption capitalize text-text-tertiary">{group}</p>
{list.map((a) => (
<button
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left",
"transition-colors duration-1 ease-standard",
a.isPostable ? "hover:bg-surface-hover" : "cursor-not-allowed text-text-disabled",
a.id === value && "bg-surface-selected",
)}
disabled={!a.isPostable}
key={a.id}
onClick={() => {
onValueChange(a);
setOpen(false);
}}
style={{ paddingLeft: 12 + (a.parentId ? 16 : 0) }}
title={
a.isPostable
? `Maps to ${a.statementMapping}`
: "Rollup account — entries cannot post here"
}
type="button"
>
<span className="reference-numeric w-12 shrink-0 text-caption text-text-tertiary">
{a.number}
</span>
<span className="min-w-0 flex-1 truncate text-body">
{a.name}
{a.isContra ? (
<span className="ml-1 text-caption text-text-tertiary">(contra)</span>
) : null}
{a.status === "inactive" ? (
<span className="ml-1 text-caption text-text-tertiary">· inactive</span>
) : null}
</span>
{a.isPostable ? (
<NormalBadge normal={a.normalBalance} />
) : (
<span className="flex items-center gap-1 text-caption text-text-tertiary">
<LockClosedIcon aria-hidden="true" className="size-3" />
Rollup
</span>
)}
</button>
))}
</div>
))}
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-body text-text-secondary">
No account matches “{query}”.
</p>
) : null}
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
function NormalBadge({ normal }: { normal: "debit" | "credit" }) {
return (
<span className="relative shrink-0 rounded-xs bg-surface-sunken px-1.5 py-0.5 text-caption text-text-secondary">
<span aria-hidden="true">{normal === "debit" ? "Dr" : "Cr"}</span>
<span className="sr-only select-none">normal balance {normal}</span>
</span>
);
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Accounting core (GL)
- JournalEntryDebit and credit as two columns. No signed-column variant.
- JournalEntryFormLine grid with live balance check and dimension capture.
- JournalEntryTableAll entries, with the gapless-numbering check.
- ChartOfAccountsHierarchy, statement mapping, unsupported-account flags.
- AccountRegisterGL detail with a running balance.
- DimensionPickerEntity always required; renders what the account demands.
- AdjustingEntryFormTemplated by type. Auto-reverse defaults on for accruals.
- RecurringEntryTemplateTemplate editable; instances read-only.
- EntryReversalDialogHold-to-confirm. Never a delete.
- ScheduleTableOne shell, seven presets. Prepaids through debt.
- RollForwardOpening → movements → closing → tie. The universal shape.
- AccountTieOutEvery BS account, proven against its evidence.
- SchedulePreviewWhat the schedules will post, before they post.
- RevenueWaterfallDeferred balance recognizing by future period.
- DepreciationScheduleBook and tax in parallel. In-service date, not purchase date.
- LeaseScheduleROU asset and liability on diverging curves.
- AllowanceRollForwardWith the methodology shown, not just the number.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Accounting core (GL) — the family specThe four-layer specification this component was built against, before it was written.Doc
- General ledger and accounting coreThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- AccountA GL account in the chart of accounts. Always qualify when a bank account is meant.Term
Elsewhere in the system
Where AccountPicker sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- AccountPicker, runningThe gl showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Chart of accountsHow does each ledger model an account?API
- Accounting core (GL)The general ledger layer. Specs for 18 React components: JournalEntry, JournalEntryForm, JournalEntryTable, AccountPicker and 14 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Accounting core (GL) changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.