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-setsize follow 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
dropCollision decides 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+F3 on 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
250 entries · rolled-up balances · status dots via setAccountStatus() · search expands ancestors of every match and restores the previous expansion on clear.
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.
API reference
The full export surface of @cynco/accounts. Types are transcribed from the
source; defaults that live in constants.ts cite the constant's value.
AccountTree options
AccountTreeOptions extends AccountTreeControllerOptions — the controller
subset (first block) also constructs a headless AccountTreeController
directly.
| Option | Type | Default | Description |
|---|---|---|---|
entries | readonly LedgerEntry[] | — | Entries whose posting accounts seed the tree and its balances. |
accounts | readonly string[] | — | Explicit paths to include with zero activity; invalid paths are skipped silently. |
initialExpansion | 'all' | 'top-level' | string[] | 'all' | Initial expansion state (AccountTreeInitialExpansion). |
density | 'compact' | 'default' | 'relaxed' | 'default' | Row density preset (AccountTreeDensity): 24 / 30 / 36 px rows. |
currency | string | 'MYR' (DEFAULT_CURRENCY) | Primary display currency for the balance column. |
showBalances | boolean | true | Whether rows render the right-aligned balance column. |
amountFormat | AmountFormat | AMOUNT_FORMAT_COMMA_DOT | Descriptor for the balance column; pass the same one to preloadAccountTreeHTML. |
flattenEmptyGroups | boolean | false | Collapses single-child group chains into one row; projection-level only. |
loadChildren | (path: string) => Promise<readonly string[]> | — | Async child loader for lazy subtrees; resolves to canonical child paths. |
initiallyUnloaded | readonly string[] | — | Paths whose children are unfetched at construction; meaningful with loadChildren. |
onChildLoadError | (path: string, error: unknown) => void | — | Fired once per failed loadChildren attempt with the original rejection value. |
id | string | auto-generated | Stable instance id baked into row ids; pass the same id to preloadAccountTreeHTML. |
ariaLabel | string | 'Accounts' | Accessible name for the tree. |
colorScheme | 'light' | 'dark' | 'system' | 'system' | Pins how light-dark() colors resolve on the host element. |
overscanRows | number | 10 (DEFAULT_OVERSCAN_ROWS) | Extra rows rendered above and below the pixel window. |
unsafeCSS | string | — | Raw CSS appended into the shadow root's unsafe layer — an escape hatch. |
dropCollision | 'reject' | 'skip' | 'replace' | 'reject' | How drops resolve leaf-name collisions (AccountDropCollision). |
dragExpandDelayMs | number | 700 (DRAG_EXPAND_DELAY_MS) | Spring-loaded expansion delay for drags hovering a collapsed group. |
icons | AccountTreeIconOptions | no icons | Per-row icon resolver over the built-in closed icon set. |
renderDecorations | AccountRowDecorationsRenderer | — | Host-driven trailing decoration lane; at most 3 render per row. |
nameTruncation | 'end' | 'middle' | 'end' | Name overflow handling (AccountTreeNameTruncation); middle measures and rewrites. |
stickyAncestors | 'nearest' | 'stack' | 'nearest' | Sticky ancestor header shape (AccountTreeStickyAncestors). |
contextMenu | AccountTreeContextMenuOptions | — | Context-menu composition surface; the host renders the menu from onOpen. |
onSelect | AccountTreeSelectCallback | — | Fired when the selection changes, with selected paths and the focused path. |
onRename | AccountRenameListener | — | Fired after a committed inline rename with (oldPath, newPath). |
onMove | AccountMoveListener | — | Fired after a drag & drop re-parenting with the applied AccountMove[]. |
onDropComplete | (result: AccountDropCompleteEvent) => void | — | Fired after every applied drop with moves, skipped candidates, and replaced roots. |
onDropError | (error: AccountDropErrorEvent) => void | — | Fired when a drop applies nothing for an error-shaped AccountDropErrorReason. |
Utilities
Every remaining runtime export, one line each. Entries marked internal are plumbing shared by the client and SSR paths — stable exports, but not the intended host API.
| Export | Purpose |
|---|---|
createDefaultAccountIconResolver() | Pragmatic default AccountIconResolver over top-level segment heuristics. |
computeMiddleTruncation(fullText, fullWidth, availableWidth) | Code-point-safe head…tail truncation; tail keeps ~3/4 of the budget, null when nothing fits. |
accountsThemeVariables(roles) | Maps a @cynco/theme role set onto the --accounts-theme-* variable layer. |
formatMinorUnits(amount, currency, options?) | Integer minor units to a display string via digit slicing; FormatMinorUnitsOptions carries sign and amountFormat. |
getCurrencyDecimals(currency) | Minor-unit decimal places from CURRENCY_DECIMALS; unknown codes use 2. |
resolveAmountFormat(locale) | Resolves a locale tag to an AmountFormat preset once at the host boundary — renderers never consult Intl. |
escapeHtml(value) | HTML-escapes interpolated text; every renderer routes through it. |
applyHostColorScheme(container, colorScheme) | Applies or removes the inline color-scheme pin on a host element. |
isComposingEvent(event) | True for keydown events inside an active IME composition (including legacy keyCode === 229). |
isValidMinorUnits(value) | True for safe-integer minor-unit amounts. |
warnInvalidMinorUnits(context, value) | Warn-once console report for an unsafe amount, keyed by context. |
warnIfInvalidLedgerEntries(entries) | Boundary sweep over entries entering the store. |
resetInvalidMinorUnitsWarnings() | Resets the warn-once state — exported for tests. |
makeChildLoadPlaceholderPath(parentPath) internal | Synthetic non-path projection marker for a group's load placeholder row. |
getChildLoadPlaceholderParent(value) internal | The owning group path of a placeholder marker, or null. |
isChildLoadPlaceholderPath(value) internal | True when a projection value is a placeholder marker, not an account path. |
renderAccountRowHTML / renderAccountRowsHTML / renderStickyRowHTML / renderAccountTreeShellHTML internal | DOM-free string builders (driven by AccountTreeRenderOptions) shared by client and SSR. |
Constants
| Export | Value | Meaning |
|---|---|---|
ACCOUNTS_TAG_NAME | 'accounts-container' | The custom element the tree renders into. |
DENSITY_ROW_HEIGHTS | { compact: 24, default: 30, relaxed: 36 } | Fixed pixel row height per density preset; mirrors --accounts-row-height. |
DENSITY_SCALE_FACTORS | { compact: 0.8, default: 1, relaxed: 1.2 } | Density scale mirrored by --accounts-density-scale. |
DEFAULT_OVERSCAN_ROWS | 10 | Extra rows rendered above/below the visible window. |
DEFAULT_VIEWPORT_HEIGHT | 420 | Viewport height assumed when the scroller has no measurable layout. |
DEFAULT_CURRENCY | 'MYR' | Primary display currency when the caller does not specify one. |
SSR_MAX_PRELOADED_ROWS | 512 | preloadAccountTreeHTML row cap; the client re-windows on first scroll. |
DRAG_EXPAND_DELAY_MS | 700 | Spring-loaded expansion delay for drags. |
STICKY_ANCESTOR_STACK_MAX | 4 | Mirror-row cap for stickyAncestors: 'stack'; nearest ancestors win. |
MINUS_SIGN | '\u2212' | Proper minus for negative balances. |
AMOUNT_FORMAT_COMMA_DOT / AMOUNT_FORMAT_DOT_COMMA / AMOUNT_FORMAT_SPACE_COMMA / AMOUNT_FORMAT_APOSTROPHE_DOT / AMOUNT_FORMAT_INDIAN | frozen presets | 1,234.56, 1.234,56, 1 234,56 (U+202F), 1'234.56, 12,34,567.89. |
CURRENCY_DECIMALS | the engine's table | ISO 4217 minor-unit exceptions, aliased from @cynco/ledger-core; unlisted codes use 2. |
ACCOUNT_ICON_PATHS | 10-entry record | SVG path data for the closed AccountIconName set — the XSS boundary's render source. |
AccountsContainerLoaded | true | Custom-element registration flag — component wiring, not host API. |
Types
AccountTreeRenderProps pairs an optional container / parentNode mount
target for render. Everything else, grouped:
- Core data —
MinorUnits,EntryFlag('cleared' | 'pending' | 'flagged' | 'void'),Posting,LedgerEntry,AmountFormat,ColorScheme,RowRange. - Rows & status —
AccountTreeRowData(the materialized row shape fromAccountTreeController.getRows),AccountStatusKind('unreconciled' | 'flagged' | 'pending'),AccountStatusEntry,StatusAggregate,AccountChildLoadState('loaded' | 'unloaded' | 'loading' | 'error'),AccountChildLoadPlaceholder. - Controller —
AccountTreeChange(the honest per-facet invalidation record),AccountTreeChangeListener,SelectPathOptions(additive/range),BeginSearchOptions,AccountTreeSearchMode('expand-matches' | 'collapse-non-matches' | 'hide-non-matches'),AccountSearchMatchState,AccountSearchResult. - Icons & decorations —
AccountIconName(the closed 10-name union),AccountIconContext,AccountDecorationTone('neutral' | 'info' | 'success' | 'warn' | 'danger'),AccountRowDecoration(text badge or dot),AccountRowDecorationContext. - Rename & moves —
RenameErrorReason('unknown-path' | 'invalid-name' | 'collision'),RenameResult({ ok: true; newPath } | { ok: false; reason }),AccountMove,AccountMovePlan. - Context menus —
AccountTreeContextMenuSource('pointer' | 'keyboard' | 'button'),AccountTreeContextMenuAnchor({ x, y } | { rect: DOMRect }),AccountTreeContextMenuRequest,AccountTreeContextMenuCloseOptions(restoreFocus, defaulttrue).