GroupPicker
- Status
- Implemented
- Family
- 5 of 13
- Registry
- @lopifi/group-picker
npx shadcn@latest add @lopifi/theme @lopifi/group-pickerWhat lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/group-picker.tsx
- Lopifi dependencies · 2
- cnicons
- npm packages
- @base-ui-components/reactclsxtailwind-merge
- Registry entry
- /r/group-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 { cn } from "@/lib/ledger/cn";
import { CheckIcon, Squares2X2Icon } from "@/components/ledger/icons";
export type GroupOption<K extends string = string> = {
key: K;
label: string;
/** Shown under the label. Say what the subtotal will mean. */
hint?: string;
};
export interface GroupPickerProps<K extends string = string> {
className?: string;
/** What the ungrouped state is called. "No grouping" for a transaction list. */
noneLabel?: string;
onValueChange: (key: K | undefined) => void;
options: GroupOption<K>[];
/** `undefined` is "no grouping", and it is a real, selectable choice. */
value?: K;
}
/**
* What the table is cut by.
*
* A single choice, not a stack of them. Nested grouping — category inside
* entity inside month — is the feature every table like this grows and the
* one nobody uses twice: three levels of header rows leave about four data
* rows on screen, and the answer it gives is a pivot table, which is a
* different tool with a different layout. One cut, with subtotals, is the
* question people actually ask.
*
* "No grouping" sits in the list rather than being a separate clear button,
* because ungrouped is a view like the others and the reader returns to it
* constantly. It is first, and it is where the tick starts.
*
* The trigger says what is in force rather than "Group" — a control whose
* label never changes is a control the reader has to open to read.
*/
export function GroupPicker<K extends string = string>({
options,
value,
onValueChange,
noneLabel = "No grouping",
className,
}: GroupPickerProps<K>) {
const [open, setOpen] = React.useState(false);
const active = options.find((o) => o.key === value);
const choose = (key: K | undefined) => {
onValueChange(key);
setOpen(false);
};
const row = (key: K | undefined, label: string, hint?: string) => {
const selected = key === value;
return (
<li key={key ?? "__none"}>
<button
aria-pressed={selected}
className={cn(
"flex w-full items-start gap-2 rounded-xs px-2 py-2 text-left",
"transition-colors duration-1 ease-standard hover:bg-surface-hover",
selected && "bg-surface-selected",
)}
onClick={() => choose(key)}
type="button"
>
{/* The tick keeps its column whether or not it is drawn, so the
labels do not shift by 20px as the selection moves. */}
<span className="grid size-4 shrink-0 place-items-center pt-0.5">
{selected ? <CheckIcon aria-hidden="true" className="size-3.5 text-accent" /> : null}
</span>
<span className="flex-1">
<span className="block text-body">{label}</span>
{hint ? <span className="block text-caption text-text-secondary">{hint}</span> : null}
</span>
</button>
</li>
);
};
return (
<Popover.Root onOpenChange={setOpen} open={open}>
<Popover.Trigger
aria-label={`Grouping: ${active?.label ?? noneLabel}`}
className={cn(
"inline-flex h-8 items-center gap-2 rounded-sm border border-border bg-surface px-3 text-label font-medium",
"transition-colors duration-1 ease-standard hover:bg-surface-hover",
className,
)}
>
<Squares2X2Icon aria-hidden="true" className="size-3.5 text-text-tertiary" />
{active ? `Grouped by ${active.label.toLowerCase()}` : "Group"}
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner align="end" className="z-dropdown" sideOffset={8}>
<Popover.Popup
className="w-[280px] rounded-md border border-border bg-surface-raised p-2 shadow-elevation-3"
style={{ transformOrigin: "var(--transform-origin)" }}
>
<ul>
{row(undefined, noneLabel)}
{options.map((o) => row(o.key, o.label, o.hint))}
</ul>
<p className="border-t border-border-subtle px-2 pt-2 text-caption text-text-secondary">
Each group subtotals the rows under it — not the filtered set behind them.
</p>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
Also in Ledger & tables
- TransactionTableThe workhorse. Virtualized, sticky, priority-collapsing.
- TransactionRowDesktop row.Spec
- TransactionCardThe mobile re-rank.
- TransactionDetailFull detail sheet with attachments and audit trail.
- LedgerTableDouble-entry with debit/credit columns and a balance check.
- AgingTableAR/AP buckets, 0-30-60-90+.
- ReconciliationViewTwo-pane matcher. The match animation.
- CategoryPickerHierarchical with rule creation.
- FilterBarURL-param-backed filters with resolved-value chips.
- BulkActionBarSelection count + actions, safe-area aware.
- ColumnManagerShow/hide/reorder with drag.
- ExportMenuCSV/XLSX/PDF with a stated scope.
- TableColumnsSort and resize for a table that renders on the server.
- ColumnHeaderThe sortable, draggable header cell.
- PeriodGridThe ruled grid. Frozen panes, hidden columns, hover change, ⌘C.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Ledger & tables — the family specThe four-layer specification this component was built against, before it was written.Doc
- Ledger tables and transaction registersThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
Elsewhere in the system
Where GroupPicker 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.
- Ledger tables and transaction registers, runningThe Ledger & tables family, live. GroupPicker has no demo of its own there yet.Live
- Ledger & tablesThe table is the product. It gets the same design investment as the hero card. Specs for 15 React components: TransactionTable, TransactionRow, TransactionCard, TransactionDetail and 11 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 Ledger & tables 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.