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).
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).
Declare it, then place it
Section titled “Declare it, then place it”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.
Where a component can stand
Section titled “Where a component can stand”| 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,fieldandcontext; 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 innavorhome. A reader who cannot read every resource inreadsis not served the room;useAddressState('list')keeps its place in?list=12.
Props, typed from your declaration
Section titled “Props, typed from your declaration”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 SDK: tablewalk/ui
Section titled “The SDK: tablewalk/ui”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. |
Commands: useCommand
Section titled “Commands: useCommand”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 undefinedreact.reason(post); // 'signed-out', 'not-granted', 'unavailable' or undefinedreact.run(post, { kind: 'like' }); // sends when the input is complete, else opens the formreact.open(post); // always the platform's command formIt 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.
Styling
Section titled “Styling”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>-….
Build and serve
Section titled “Build and serve”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 buildwrites acomponents/graph pinned to its version; a server starting from a build needs no compiler.react,react-domandtablewalk/uiare never bundled; the page’s import map shares its copies. Anything else imported is bundled.
Security
Section titled “Security”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 inauthority.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.publicopens 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.
Test it
Section titled “Test it”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.
Examples double as stories
Section titled “Examples double as stories”Put a component’s examples beside it in widgets/Thing.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.tsxconst 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
Section titled “tablewalk check”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.envand 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 namedc-<component-id>-…, andgetBoundingClientRectpositioning.
In a tenant App
Section titled “In a tenant App”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.
Not yet
Section titled “Not yet”Custom inputs inside a command’s groups, and an isolated host for third-party components. See the roadmap.