Skip to content

Custom components

This page shows how to draw what no built-in block does with a React module of your own (preview in 0.8.0).

components.ts
import { component, prop } from 'tablewalk/app';
export const payrollTimeline = component('payroll-timeline', {
module: './widgets/PayrollTimeline.tsx',
props: { months: prop.number({ default: 9, min: 1, max: 36 }) },
});

The App still says what is read and who may read it; the component draws, and changes data only through the App’s commands. Check the built-ins first (sections, related lists, metrics, charts, boards, calendars, display rules): a component is for a drawing none of them makes. The examples below come from Payday (a payroll timeline), Commons (a post stream) and Depot (a bin map and a pick walk).

A component is an id and a module path inside the App directory. props are JSON-safe: prop.number({ default, min, max }), prop.text({ default, max }), prop.bool({ default }), prop.choice(['week', 'month'], { default }).

Place it with custom() on a page or a dashboard, declaring what it reads:

import { custom } from 'tablewalk/app';
import { payrollTimeline } from '../components.ts';
export const payHistory = custom('Pay history', payrollTimeline, {
record: ['id', 'title'],
rows: { runs: { from: 'pay_run', show: ['id', 'title', 'period_start', 'period_end', 'pay_date', 'status'], sort: 'pay_date desc', limit: 18 } },
});
Key Says
record Pages only: the record’s own fields, label paths welcome.
rows Up to four named reads, { from, show, query?, sort?, limit? }. On a page whose ties a read to the record; on a dashboard over names the date column the window narrows.
props, width, region, tab, when, hidden As on any section.

Every read is compiled at load (a misspelled column is refused) and runs under the reader’s grants, field projection and row policy.

Kind Declared Placed Props type
Section (default) component(id, { module }) custom(title, comp, opts) SectionProps
Value kind: 'value' resources.<r>.display.<column> ValueProps
Input kind: 'input' resources.<r>.inputs, an action’s or a commandForm’s inputs InputProps
List kind: 'list' presentation: customList(comp, { show }) on a list view ListProps
Room kind: 'room' room(label, comp, { reads, params }) in views RoomProps
export const hoursBar = component('hours-bar', { module: './widgets/HoursBar.tsx', kind: 'value' });
resources: { timesheet: { display: { total_hours: hoursBar } } }
views: {
'pick-walk': room('Pick walk', pickWalk, { icon: 'list-checks', reads: ['pick_list', 'order_line'], params: ['list', 'line'] }),
}
  • Value renderers only draw: sort, filter, export and search keep the raw value, and one that throws falls back to the platform’s drawing.
  • Inputs get value, onChange, id, describedBy, invalid, disabled, required, label, field and context; the form still judges the value. A public form draws none.
  • Lists keep the platform’s query, tabs, filters, pager and bulk bar; the component gets one page of rows, total, page, open(row) and, when bulk actions exist, selection.
  • Rooms have an address (/depot/pick-walk) and can sit in nav or home. A reader who cannot read every resource in reads is not served the room; useAddressState('list') keeps its place in ?list=12.
import { Button, Stack, Value, useNavigate, type SectionProps } from 'tablewalk/ui';
import type { Rows } from '../tablewalk-schema.d.ts';
import type { payHistory } from '../features/payroll.ts';
type Props = SectionProps<typeof payHistory, Rows, 'pay_run'>;
export default function PayrollTimeline({ record, rows, props }: Props) {
// rows.runs[0].title is a string, props.months a number
}

A section receives record, rows (each read by name, carrying exactly its show), props, section and state ('ready' or 'empty'). Loading and errors are the platform’s to draw. Ids past 2^53 arrive as strings: never run one through Number(); the kit’s Value keeps every digit.

The only tablewalk module a component imports.

Export For
React and its hooks The page’s own React (one copy, shared).
Card, Stack, Row, Heading, Button, Value, Tone, Meter, Empty, Icon The kit, dressed by every look.
useRows, useRecord, useBreakdown, useFile Extra reads by resource name, under the same grants. Prefer declared reads.
useCommand Run one of the App’s commands (below).
useNavigate openRecord(resource, key, { mode }), openView(id), openForm(view, prefill).
useViewer { signedIn, account }; it decides nothing.
useFormat, useLook, useToken, useSectionSize Locale formats, the current look and mode, a token’s value, and compact below 420px.
useAddressState A room’s place in its address.
import { useCommand } from 'tablewalk/ui';
import { reactToPost } from '../features/posting.ts';
const react = useCommand(reactToPost);
react.available(post); // the command's `when` for this record: true, false or undefined
react.reason(post); // 'signed-out', 'not-granted', 'unavailable' or undefined
react.run(post, { kind: 'like' }); // sends when the input is complete, else opens the form
react.open(post); // always the platform's command form

It is the same pipeline an action uses: prepared at the record’s revision, sent once, refused in the server’s words ({ status: 'refused', message }), and the page’s reads refreshed after. A command is reached through an action that offers it, so its label, form and grant are the action’s. A precondition only the handler checks (“join first”) is yours to say: Commons draws “Join to react” for a reader with no member row.

Use the design tokens (var(--surface), var(--text), var(--text-dim), var(--border), var(--accent), var(--ok), var(--warn), var(--danger), var(--radius)) and every look, palette and dark mode dresses the component. Import a stylesheet from the module; it is served in @layer components, scoped to the component’s element. Name classes c-<component-id>-….

Install the optional peers: npm i -D rolldown @types/react. An App that declares components without Rolldown is refused at start; an App without components needs neither.

  • From source, each module compiles at load and on save, served as /{app}/_components/<id>-<hash>.js, fetched only when a page draws it.
  • tablewalk build writes a components/ graph pinned to its version; a server starting from a build needs no compiler.
  • react, react-dom and tablewalk/ui are never bundled; the page’s import map shares its copies. Anything else imported is bundled.

A component is deployment code, trusted like app.ts, running in the reader’s browser under the reader’s session.

  • The server is the boundary. Every read and command is authorized per request, exactly as for the App.
  • Egress is declared. The page’s Content-Security-Policy allows only its own origin; ui: { connect: ['https://api.example.com'], images: [...] } opens exactly those origins, recorded in authority.lock.
  • Refused at compile: importing tablewalk/commands, tablewalk/policy, tablewalk/jobs, tablewalk/maintenance, server code, Node modules, TanStack Query, or files outside the App directory.
  • Signed out, a component over what auth.public opens is drawn, its reads held to the public grant at load; it runs no command. Rooms, landings and public forms never draw one. In an App that signs people in, component source maps are served only to signed-in readers.

tablewalk/ui/testing draws a component as the page would, over typed fixtures, in Vitest with happy-dom (npm i -D vitest happy-dom react react-dom @testing-library/react).

import { fixtures, renderSection } from 'tablewalk/ui/testing';
import { payHistory } from '../features/payroll.ts';
import PayrollTimeline from './PayrollTimeline.tsx';
const data = fixtures<Rows>({ pay_run: [/* rows */] });
test('opens a run as a peek', () => {
const view = renderSection(PayrollTimeline, payHistory, { data, record: { id: 3 }, width: 390 });
fireEvent.click(view.getByRole('button', { name: /^August 16–31/ }));
expect(view.navigations).toEqual([{ kind: 'record', resource: 'pay_run', key: { id: 2 }, mode: 'peek' }]);
});

Reads are answered from the fixtures; navigations and commands are recorded (view.navigations, view.commands), never followed. renderValue, renderList, renderRoom and renderInput draw the other kinds; options set look, mode, width, locale, viewer and commands.

Put a component’s examples beside it in widgets/Thing.examples.tsx:

PostStream.examples.tsx
export default examples(PostStream, threadPosts, { data, files });
export const Member = example({ width: 900, viewer: { signedIn: true, account: 'acct-ines' }, commands });
export const Visitor = example({ width: 900 });
// PostStream.test.tsx
const view = renderExample(stream, Member);

A test draws one with renderExample, and the repository’s Storybook turns each into a story. The sample Apps’ components are in the component showcase under Sample Apps/Commons and Sample Apps/Depot, in every look.

tablewalk check adds a components step for an App that declares any.

  • Fails (exit 5) on turning text into markup or code, or reaching for the server: dangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, document.write, eval, new Function, a string timer, javascript: addresses, process.env, import.meta.env and similar.
  • Warns on what a look cannot dress: literal colours or radii, motion over 200ms or outside prefers-reduced-motion: no-preference, box-shadow, classes not named c-<component-id>-…, and getBoundingClientRect positioning.

A tenant App serves a component’s files to signed-in members only, and each read it declares runs in the member’s own tenant, as every list does.

Custom inputs inside a command’s groups, and an isolated host for third-party components. See the roadmap.