Accounts
@cynco/accounts renders the chart of accounts as a virtualized, keyboard-navigable tree — rolled-up balances, status dots, search — into an <accounts-container> custom element with an open shadow root. Account paths are canonical colon-delimited strings at every public boundary; numeric node ids never leak out of the store.
Installation
Install with the package manager of your choice. React is an optional peer dependency.
pnpm add @cynco/accounts| Entry point | What it exports |
|---|---|
@cynco/accounts | The vanilla AccountTree view, the headless AccountTreeController, pure renderers, and utilities like createDefaultAccountIconResolver |
@cynco/accounts/react | <AccountTree /> plus the useAccountTree hook for imperative access |
@cynco/accounts/ssr | preloadAccountTreeHTML for server prerendering |
Vanilla API
Construct with options, mount with render, then drive data changes through the imperative API. Selection follows file-tree conventions: click, meta-click (additive), shift-click (range), and full keyboard navigation with aria-activedescendant.
import { AccountTree } from '@cynco/accounts';
const tree = new AccountTree({
entries, // postings seed the tree and its balances
accounts: [ // zero-activity accounts to include anyway
'Equity:Retained-Earnings',
],
initialExpansion: 'top-level', // 'all' | 'top-level' | string[]
density: 'default', // 'compact' 24px | 'default' 30px | 'relaxed' 36px
currency: 'MYR', // primary display currency
onSelect(selectedPaths, focusedPath) {
console.log(selectedPaths, focusedPath);
},
});
tree.render(document.querySelector('#host')!);
// Data changes go through the imperative API, not options:
tree.setEntries(nextEntries);
tree.setAccountStatus([
{ path: 'Assets:Current:Cash-Maybank', status: 'unreconciled', count: 4 },
]);
tree.setExpanded('Expenses', false);
tree.scrollToPath('Liabilities:Current:SST-Payable', { focus: true });
tree.cleanUp();// The controller is the headless model behind the view — use it for
// programmatic reads without touching the DOM.
const controller = tree.getController();
controller.getVisibleCount(); // rows currently visible
controller.getRows(0, 50); // materialized AccountTreeRowData slice
controller.selectPath('Assets:Current', { additive: true });
controller.beginSearch('cash'); // expands ancestors of every matchStatus decorations
Status dots are the accounting analog of git file status: unreconciled, flagged, or pending, with an optional item count. Collapsed ancestor groups roll up the highest-severity status of their descendants, so nothing hides below the fold.
tree.setAccountStatus([
// kind decides the dot color (warn / danger / info); counts roll up
// onto collapsed ancestor groups.
{ path: 'Assets:Current:Cash-Maybank', status: 'unreconciled', count: 4 },
{ path: 'Assets:Current:AR', status: 'pending', count: 2 },
{ path: 'Liabilities:Current:SST-Payable', status: 'flagged' },
]);Account icons
Rows can render an icon between the chevron and the name, resolved per row from a built-in, closed icon set. The resolver returns an AccountIconName or null (no icon — with the icons option absent, row markup is byte-identical to a tree without icons).
import { AccountTree, createDefaultAccountIconResolver } from '@cynco/accounts';
const tree = new AccountTree({
entries,
icons: { resolver: createDefaultAccountIconResolver() },
});
// Or your own resolver — return a built-in name, or null for no icon:
const custom = new AccountTree({
entries,
icons: {
resolver({ path, name, isGroup, depth }) {
if (isGroup) return 'folder';
return path.startsWith('Assets:') ? 'wallet' : null;
},
},
});The closed union is the XSS boundary: resolvers never return markup; the renderer only interpolates its own built-in SVG path data and validates the returned name at runtime, so untyped hosts cannot inject HTML through the icon lane. And the hot-path contract: the resolver runs once per rendered row per window commit — never per selection/focus patch — so keep it cheap and pure (same input, same output, no I/O). Icons are decorative (aria-hidden), colored by currentColor, and sized by the density scale (--accounts-icon-size, override with --accounts-icon-size-override). Sticky mirror rows and renaming rows keep their icon.
createDefaultAccountIconResolver() is a pragmatic default over top-level segment heuristics — replace it when you have real account-type metadata:
| Icon | Default resolver assignment |
|---|---|
folder | Every group, any depth — groups read as containers. |
cash | Assets leaves whose name contains “cash” or “petty” — checked first, so Cash-Maybank reads as cash held at a bank. |
bank | Assets leaves whose name contains “bank”. |
receivable | Assets leaves whose name contains “receivable” or “debtor”. |
wallet | Every other Assets leaf. |
payable | Liabilities leaves. |
income | Income / Revenue leaves. |
expense | Expenses leaves. |
equity | Equity / Capital leaves. |
chart | Never assigned by the default resolver — available to custom resolvers. |
Leaves under any other top-level segment get no icon — the same look as an unresolved row.
Row decorations
renderDecorations adds a host-driven trailing lane between the name and the balance — small text badges and colored dots. Decorations are recomputed per window commit; controller-driven status dots (setAccountStatus, with ancestor roll-up) stay a separate lane right before them. Same hot-path contract as icon resolvers: cheap and pure.
const tree = new AccountTree({
entries,
renderDecorations({ path, name, isGroup, depth, visibleChildCount }) {
const count = postingCounts.get(path);
return [
...(count ? [{ kind: 'text' as const, text: `${count}×` }] : []),
...(isStale(path)
? [{ kind: 'dot' as const, tone: 'warn' as const }]
: []),
];
},
});- Tones (
neutral | info | success | warn | danger) map onto the theme state colors (--accounts-tone-*, resolving through--accounts-theme-states-*;neutraluses the muted foreground). - At most 3 decorations render per row — rows are fixed-height by contract (all virtualization math is
index * rowHeight), and an unbounded lane would break that. - Text decorations are escaped and contribute to the row’s accessible name as ordinary text content; dots are
aria-hidden(a bare colored circle has no announceable meaning). visibleChildCountis the number of child rows an expanded group currently contributes to the projection (0 for leaves and collapsed groups).
Flattening empty groups
flattenEmptyGroups collapses single-child group chains into one row labeled with the joined segments (Income : Sales, separators in punctuation color). It is purely a projection feature: canonical topology, expansion state, selection, and focus all keep canonical colon-delimited paths, and the flattened row stands in for its deepest group — expanding or collapsing the row toggles that node. aria-posinset/aria-setsizefollow the visible projection, and the row shows the deepest group’s rolled balance.
const tree = new AccountTree({
entries,
flattenEmptyGroups: true, // 'Income' + 'Income:Sales' → one 'Income : Sales' row
});
// Live projection toggle — no rebuild, expansion state untouched:
tree.setFlattenEmptyGroups(false);
// The flattened row represents its deepest group; every public API keeps
// canonical paths:
tree.setExpanded('Income:Sales', false); // toggles the chain's row
const controller = tree.getController();
controller.getPathIndex('Income'); // -1: mid-chain paths have no row
controller.getRow('Income:Sales')?.flattenedNames; // ['Income', 'Sales']Inline rename
Press F2 on the focused row or double-click an already-selected row to rename it in place. Names are validated (non-empty, no :, no sibling collision); a commit remaps the account and its whole subtree — postings, rolled balances, expansion, selection, focus, and status decorations follow. The remap rebuilds the path-derived store from remapped entries (single-digit milliseconds at 10k entries).
// View: F2 (focused row) or double-click an already-selected row opens the
// inline editor. Enter commits, Escape cancels, blur commits. The editor
// survives virtualization: session + draft live in the controller and the
// input re-attaches when the row re-enters the window.
const tree = new AccountTree({
entries,
onRename(oldPath, newPath) {
console.log(oldPath, '→', newPath); // 'Assets:Current' → 'Assets:Ops'
},
});
tree.beginRename('Assets:Current'); // programmatic entry point
// Controller: validation + remap without any DOM.
const controller = tree.getController();
controller.beginRename('Assets:Current');
controller.setRenameDraft('Ops');
const result = controller.commitRename('Assets:Current', 'Ops');
// { ok: true, newPath: 'Assets:Ops' } — descendants, balances, expansion,
// selection, focus, and status decorations all follow the remap.
// Failures: { ok: false, reason: 'unknown-path' | 'invalid-name' | 'collision' }
controller.cancelRename();Drag & drop re-parenting
Drag a leaf or a group onto a group row to re-parent it — dropping Assets:Current:Cash-Wise on Assets:Reserve yields Assets:Reserve:Cash-Wise, subtree included. The dragged rows dim; a valid target shows the accent-subtle tint with a 1px accent inset ring; invalid targets show nothing.
// Rows are HTML5 drag sources; group rows are drop targets. Guards:
// no self-drops, no drops into an own descendant, drops on
// the current parent are no-ops, and leaf-name collisions at the target are
// skipped. Dragging a selected row moves the whole selection (descendants
// of dragged groups ride along); hovering a collapsed group for 700ms
// spring-loads it open.
const tree = new AccountTree({
entries,
onMove(moves) {
// [{ from: 'Assets:Current:Cash-Wise', to: 'Assets:Reserve:Cash-Wise' }]
},
dragExpandDelayMs: 700, // spring-load delay override
});
// The same machinery, programmatically:
const controller = tree.getController();
controller.getMovePlan(['Assets:Current:Cash-Wise'], 'Assets:Reserve'); // dry run
controller.movePaths(['Assets:Current:Cash-Wise'], 'Assets:Reserve'); // applies + fires onMoveDrop collision strategies
dropCollisiondecides what happens when a dragged account’s leaf name already exists under the drop target:
| Strategy | Behavior |
|---|---|
reject (default) | Any collision blocks the whole drop — nothing moves, onDropError fires with reason: 'collision' and the full attempted batch. The colliding target still accepts the drop gesture so the error is surfaced instead of the cursor being silently refused. |
skip | Colliding moves drop out of the plan; the rest proceed and onDropComplete.skipped lists what stayed put. When every candidate collides, the drop is a silent no-op (no event). |
replace | The existing account at each colliding destination — and its whole subtree — is removed, then the move proceeds. onDropComplete.replaced lists the removed roots. |
const tree = new AccountTree({
entries,
dropCollision: 'skip', // 'reject' (default) | 'skip' | 'replace'
onMove(moves) {
// fires first — the original event, applied moves only
},
onDropComplete({ moves, skipped, replaced }) {
// fires second — the richer superset
},
onDropError({ reason, attempted }) {
// reason: 'collision' | 'invalid-target' | 'self-drop'
},
});
// Programmatic movers share the exact same path via the controller:
const controller = tree.getController();
controller.applyMovePlan(
controller.planMovePaths(sources, target, 'replace')
);Under replace, removal runs through the same remap rebuild as the move itself: exactly one change event; selection/focus/status/search state on removed paths is dropped (never remapped); and ledger entries with any posting inside a replaced subtree are dropped whole (a partial entry would not balance) — sync your own store from the replaced list. Ordering: onMove (back-compat, applied moves only) always fires before onDropComplete; onDropError fires alone — an erroring drop applies nothing. getMovePlan / movePaths keep their original skip-shaped behavior.
Context menus
Context menus are a composition surface, not a widget. Triggers: right-click on a row (the row is focused and selected first when it was not already in the selection), Shift+F10 and the dedicated ContextMenu key (focused row, rect anchor), and — with rowButton: true— a trailing “Row actions” button per row revealed on hover / focus-within. When configured, rows carry aria-haspopup="menu".
// The tree never renders a menu itself — it owns triggering, target
// normalization, positioning data, ARIA, and the focus lifecycle; the
// host renders whatever menu it likes (Radix, native, hand-rolled).
const tree = new AccountTree({
entries,
contextMenu: {
rowButton: true, // optional per-row "…" button lane
onOpen(request: AccountTreeContextMenuRequest) {
// request.path — the row the menu is for
// request.paths — effective targets: the whole selection when the
// row is part of the current multi-selection,
// otherwise just [path] (DnD-style normalization)
// request.anchor — { x, y } pointer coords for right-click,
// { rect: DOMRect } for keyboard / button opens
// request.source — 'pointer' | 'keyboard' | 'button'
showMyMenu(request);
},
},
});The close contract: close() (default restoreFocus: true) returns focus to the tree and the originating row, re-materializing the row if virtualization evicted it. close({ restoreFocus: false }) is the rename handoff: call it and then tree.beginRename(request.path) so the rename input keeps focus without the tree stealing it back. Exactly one session is live at a time — opening a new menu supersedes the previous session, whose close() becomes a no-op, so a late close is always safe.
// A realistic host menu (Radix-style). The menu MUST call request.close()
// when it dismisses:
<DropdownMenu.Root
open={menu != null}
onOpenChange={(open) => {
if (!open) {
menu?.close(); // restoreFocus: true — back to the originating row
setMenu(null);
}
}}
>
<DropdownMenu.Content style={positionFromAnchor(menu?.anchor)}>
<DropdownMenu.Item
onSelect={() => {
const path = menu!.path;
menu!.close({ restoreFocus: false }); // the rename handoff
setMenu(null);
treeRef.current!.beginRename(path);
}}
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => archive(menu!.paths)}>
Archive {menu!.paths.length} account(s)
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>Search modes & match navigation
beginSearch(query, options?) starts (or refines) a search session; options.mode picks how matches reshape the tree. expand-matches (default) auto-expands ancestors of every match; collapse-non-matches additionally collapses every group with no match in its subtree — the minimal expansion revealing all matches; hide-non-matches filters the visible projection to matches plus their ancestors. hide-non-matches is projection-level only (like flattenEmptyGroups): canonical topology is untouched, and aria-posinset / aria-setsize are recomputed over the filtered visible siblings, so assistive tech never hears counts for rows that are not there.
const controller = tree.getController();
// Case-insensitive substring match against each path segment. The
// expansion state from before the session is snapshotted once and
// restored exactly by endSearch().
controller.beginSearch('cash', { mode: 'hide-non-matches' });
// mode: 'expand-matches' (default) | 'collapse-non-matches'
// | 'hide-non-matches'
controller.focusNextSearchMatch(); // cyclic, projection order
controller.focusPreviousSearchMatch();
controller.getSearchMatchState(); // { index: 1, total: 2 } — 1-based,
// or null with no active session
controller.endSearch(); // restores the pre-search expansionWhile a session is active, F3 / Shift+F3on the tree step to the next / previous match (IME-guarded like every other key). Hosts building a search input should call the controller’s focusNextSearchMatch / focusPreviousSearchMatch directly and render getSearchMatchState() as the {index}/{total} readout. Search mutations report an honest searchChanged facet on onChange events, so hosts can track match decorations without inferring them from expansion changes.
Lazy child loading
Huge charts (or remote ones) don’t need every subtree up front. Mark groups as unloaded and give the tree an async loader; expanding an unloaded group fetches its children on demand:
const tree = new AccountTree({
accounts: ['Assets:Current:Cash', 'Archive'],
initiallyUnloaded: ['Archive'],
loadChildren: async (path) => {
const response = await fetch(`/api/accounts?parent=${path}`);
return response.json(); // canonical child paths, e.g. ['Archive:2024']
},
onChildLoadError(path, error) {
console.warn(`loading ${path} failed`, error);
},
});An unloaded group renders as a collapsed, expandable group even with zero children in the store — the chevron affordance is truthful because “unloaded” means “children exist but are unfetched”. Expanding it (chevron click, →, programmatic setExpanded) starts exactly one load: loadChildren(path)resolves to the group’s canonical child paths (nested descendants allowed; ancestors auto-create; invalid paths are skipped). Loaded children then flow through the normal projection/window pipeline. The controller surface is markUnloaded(paths), getChildLoadState(path), requestChildLoad(path), and cancelChildLoads(), backed by a per-path store state machine (unloaded → loading → loaded / error).
While a fetch is in flight the group row carries aria-busy="true" and an expanded group shows one fixed-height loading row (CSS-animated dots honoring prefers-reduced-motion; aria-hidden, since the group’s aria-busy already tells assistive tech). A rejection swaps it for an error row with the failure message and a real, labeled Retry <button>. Placeholder rows are projection-level view rows, not store rows: never selectable, never drag sources or drop targets, and keyboard navigation / type-ahead skip them — with one deliberate exception to the roving-tabindex pattern: the Retry button keeps tabindex="0", because the row is not a treeitem (aria-activedescendant can never reach it) and the only recovery control must stay keyboard-reachable. Collapsing and re-expanding an error group does not auto-retry; Retry (or requestChildLoad) is the explicit gesture, so a failing endpoint is never hammered by browsing.
- Expand-all never loads.
expandAll()skips unloaded groups by design: it is one gesture, and fanning it out into N network fetches would be surprising, slow, and unbounded. Expand the specific group you want fetched. - Stale responses are discarded. Each attempt carries a token: a load that settles after
cleanUp(), after the group was removed or moved (rename / drag & drop), or after a newer attempt for the same path is discarded instead of resurrecting rows the tree moved on from. The store double-guards this — a completion for a machine no longer inloadingis refused. - Search & flatten can’t see unfetched children. Under
hide-non-matchesan unloaded group stays visible only when the group itself matches, andflattenEmptyGroupsnever flattens into or through a group with a pending load — the placeholder needs an honest anchor row.
Sticky ancestor stack
The sticky header mirrors the top visible row’s off-screen ancestor(s) above the tree. nearest (default) shows the single nearest ancestor; stack shows the whole breadcrumb, capped at 4 mirror rows with the nearest ancestors winning — unbounded sticky stacks would eat the viewport.
const tree = new AccountTree({
entries,
stickyAncestors: 'stack', // 'nearest' (default) | 'stack'
});Mirrors are visually identical to real rows but aria-hidden with no treeitem semantics, and clicking one scrolls to and focuses the real ancestor row. Under flattenEmptyGroups and hide-non-matches the stack follows the visible-parent chain, so hidden mid-chain groups never surface. The scroller’s spacer math accounts for the stack height, keeping virtualized rows at exact pixel positions.
Middle name truncation
Deep charts produce names (and flattened chain labels) longer than the row. end (default) keeps plain CSS ellipsis; middle turns on measured middle truncation:
const tree = new AccountTree({
entries,
nameTruncation: 'middle', // 'end' (default) | 'middle'
});After every window commit — and on container resize — the view measures the rendered name elements in one batched pass (all reads, then all writes: at most one reflow) and rewrites only the overflowing ones as head…tail, keeping the leaf’s tail visible since account names distinguish at the end (Ve…-Maybank, not VeryLongAcc…). Truncated rows (and only those) carry title with the full name; selection/focus-only patches skip the pass. The full name always stays in controller state — inline rename edits the real name, never the truncated presentation text.
IME input
Every keydown surface (navigation, type-ahead, the rename editor) ignores events that belong to an active IME composition (event.isComposing, or the legacy keyCode === 229 older engines report). Enter during composition confirms the IME candidate — it never commits a rename — and Escape dismisses the candidate without cancelling the rename session.
React API
The component form covers declarative use; the hook form exposes getInstance() when you need the imperative surface (status decorations, scrolling, programmatic selection).
import { AccountTree } from '@cynco/accounts/react';
export function ChartOfAccounts({ entries }: { entries: LedgerEntry[] }) {
return (
<AccountTree
options={{
entries,
currency: 'MYR',
initialExpansion: 'top-level',
flattenEmptyGroups: true,
onSelect: (paths, focused) => setSelected(paths),
}}
onRename={(oldPath, newPath) => console.log(oldPath, '→', newPath)}
onMove={(moves) => console.log(moves)}
style={{ height: 420 }}
/>
);
}import { templateRender, useAccountTree } from '@cynco/accounts/react';
// The hook variant exposes the vanilla instance for imperative calls
// (setAccountStatus, scrollToPath, …).
export function DecoratedTree({ entries, ssrHTML }: Props) {
const { ref, getInstance } = useAccountTree({ id: 'coa', entries });
useEffect(() => {
getInstance()?.setAccountStatus(statusEntries);
}, [getInstance]);
return (
<accounts-container ref={ref} style={{ height: 420 }}>
{templateRender(null, ssrHTML)}
</accounts-container>
);
}SSR
preloadAccountTreeHTML returns shadow-root HTML: stylesheet, scroller shell, and a bounded leading row window with a correctly sized after-spacer, so scrollbar geometry is right before hydration. The client re-windows rows on its first scroll.
// Server component
import { preloadAccountTreeHTML } from '@cynco/accounts/ssr';
const ssrHTML = await preloadAccountTreeHTML({
id: 'coa', // must match the client id so hydrated row ids line up
entries,
currency: 'MYR',
initialExpansion: 'top-level',
initialWindowRows: 64, // leading window; capped at 512
});Theming
Identical model to journals — zero class selectors, and every color reads a three-step chain from override to theme role to built-in light-dark() default:
/* Every color resolves override → theme → built-in default: */
--accounts-bg: var(
--accounts-bg-override,
var(--accounts-theme-bg-editor, light-dark(#ffffff, #0a0a0a))
);accounts-container {
--accounts-font-family: var(--font-mono);
--accounts-accent-override: #009fff;
--accounts-row-height-override: 28px;
--accounts-status-unreconciled-override: #d5a910;
}Layout hooks include --accounts-font-family, --accounts-row-height-override, --accounts-density-scale-override, and --accounts-border-radius-override. Balances always render with tabular-nums.
Every demo on this site renders in Paper Mono (SIL OFL 1.1) via --accounts-font-family. The package bundles no font — download Paper Mono from its repo and set the hook to match this look, or point it at your own mono stack.
Virtualization
Each density preset maps to a fixed pixel row height (compact 24 / default 30 / relaxed 36), so the visible range is pure arithmetic over the scroll position. Give the host element a fixed height to turn the internal scroller into a window; a 10,000-account chart renders the same handful of rows as a 50-account one.