Skip to content

GroupPicker

One cut through a table, with subtotals per group.
Implemented
Status
Implemented
Family
5 of 13
Registry
@lopifi/group-picker
npx shadcn@latest add @lopifi/theme @lopifi/group-picker

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/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.

components/ledger/group-picker.tsx
"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

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.