@knaw-huc/panoptes-react-blocks
v0.0.11
Published
A React component library providing block renderers and a screen layout system for [Panoptes](https://github.com/knaw-huc/panoptes-react) applications.
Keywords
Readme
@knaw-huc/panoptes-react-blocks (WIP)
A React component library providing block renderers and a screen layout system for Panoptes applications.
Installation
npm install @knaw-huc/panoptes-react-blocksImport the styles in your app entry point:
import '@knaw-huc/panoptes-react-blocks/style.css';Peer dependencies
| Package | Version |
|---|---|
| react | 19 |
| react-dom | 19 |
| @knaw-huc/panoptes-react | * |
| @tanstack/react-router | * |
Block renderers
Each renderer accepts a block prop typed as Block from @knaw-huc/panoptes-react. The block's type, value, and optional config fields drive the rendering.
ExternalLinkBlockRenderer
Renders a value string as an external anchor (target="_blank").
interface ExternalLinkBlock extends Block {
type: 'external-link';
value: string; // URL
}LabelBlockRenderer
Renders a plain text value.
interface LabelBlock extends Block {
type: 'label';
value: string;
}LinkBlockRenderer
Renders an internal navigation link using @tanstack/react-router. The URL is built from config.url and optional route model params.
interface LinkBlock extends Block {
type: 'link';
value: string; // link text
config?: { url: string }; // router path
model?: Record<string, unknown>; // route params
}MarkdownBlockRenderer
Renders value as Markdown (GFM + raw HTML). HTML is sanitized via rehype-sanitize.
interface MarkDownBlock extends Block {
type: 'markdown';
value: string;
}ToggleBlockRenderer
Renders a boolean value as a check or cross icon (using Heroicons). Supports i18n via the Panoptes translateFn.
interface ToggleBlock extends Block {
type: 'toggle';
value: boolean;
}TagsBlockRenderer
Renders value as a list of tags. A single string is rendered as a one-item list; an empty array renders an em-dash placeholder.
interface TagsBlock extends Block {
type: 'tags';
value: string | string[];
}MapBlockRenderer
Renders an OpenLayers map centred on a lat/lon coordinate with a marker. Defaults to OpenStreetMap tiles.
interface MapBlock extends Block {
type: 'map';
value: { latitude: number; longitude: number };
config?: {
zoom?: number; // default 12
tileUrl?: string; // custom OSM-compatible tile URL
};
}RenderJsonBlock
Renders structured JSON data driven by a JSON Schema config.
interface JsonBlock extends Block {
type: 'json';
value: JsonData; // any JSON value
config: JsonSchema; // JSON Schema object
}ScreenBlockRenderer
Renders a full data-entry screen defined by a ScreenDefinition config. See the Screen system section below.
interface ScreenBlock extends Block {
type: 'screen';
value: ScreenBlockValue; // Record<string, unknown>
config: ScreenDefinition;
}Screen system
The screen block system (lib/components/blocks/screen/) renders structured detail screens driven by a declarative JSON configuration from the Panoptes API. A screen block is registered as a Panoptes block of type "screen" and can be used anywhere the framework renders blocks.
Architecture overview
RenderScreenBlock # Registered Panoptes block component
└── ScreenProvider # React context (screenDefinition + data + active tab)
└── ScreenRenderer # Top-level layout shell
├── ScreenLinks # Optional navigation links (header area)
├── ScreenTabs # Optional tab bar (hidden when only one tab)
├── ScreenSidebar # Optional icon sidebar (Lucide icons)
├── ScreenForm # Form body
│ └── FormRow # Recursive row rendering (header / group / footer / row)
│ └── FormColumn → FormElement # Column + element rendering
└── ScreenActions # Optional action buttons with confirmation dialogsScreenBlock config schema
A ScreenBlock received from the API has the following shape:
{
"type": "screen",
"value": { /* flat or nested data object */ },
"config": { /* ScreenDefinition — see below */ }
}ScreenDefinition
| Field | Type | Required | Description |
|---|---|----------|--------------------------------------------------------------------------------------------------------------|
| id | string | yes | Unique identifier |
| label | string | no | Screen heading passed through translateFn; omit to use the autokey screens.{id} |
| screenType | "normal" | yes | Screen layout variant (not used yet - intended for screen variants, eg., mobile, in a popover, confirmation) |
| tabs | TabDefinition[] | no | Tab list; a tab bar is shown only when there are more than one |
| activeTabId | string | no | Initially active tab (defaults to first tab) |
| links | LinkDefinition[] | no | Navigation links rendered above the tabs |
| actions | ActionDefinition[] | no | Action buttons rendered in the footer |
| form | FormDefinition | yes | Content form (rows of elements) |
| sidebar | SidebarDefinition | no | Icon sidebar rendered to the left of the form |
TabDefinition
Tabs filter which form rows are visible. A row is shown on a tab when its tabId matches the active tab; rows without a tabId are visible on every tab. The tab bar is only rendered when there is more than one tab.
| Field | Type | Required | Description |
|---|---|---|--------------------------------------------------------------|
| id | string | yes | Unique tab identifier; referenced from RowDefinition.tabId |
| label | string | no | Tab label; omit to use the autokey screens.{screenId}.tabs.{tabId} |
LinkDefinition (not used yet)
Links are intended to be 'follow-up' operations after fetching data. For example, we could envision the flow as follows:
- Fetch item data
- Link -> Fetch screen definition
- Link -> Fetch translations
- Link -> Fetch data from remote system
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Unique link identifier |
| label | string | yes | Link label |
| operation | OperationDefinition | no | API operation to execute on click |
| href | string | no | URL to navigate to on click |
ActionDefinition
Each action wraps a child block (typically a button) that renders the action's UI. The wrapping ScreenAction renderer owns the execution state and confirmation flow; the child block triggers it by calling execute(fn) from the ActionContext.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Unique action identifier |
| label | string | no | Action label exposed via ActionContext; omit to use the autokey screens.{screenId}.actions.{actionId} |
| activate | "always" \| "onDirty" \| "onValid" \| "onDirtyAndValid" | yes | When the action should be enabled |
| confirmation | ConfirmationDefinition | yes | Confirmation dialog settings |
| block | Block | yes | Child block rendered inside the action; it reads ActionContext to render its UI and invoke execute |
Please note that the block contains a value property. In the context of actions, this is not used but it might prove useful in the future, for selecting an additional value.
ConfirmationDefinition
| Field | Type | Description |
|---|---|---|
| askConfirmation | "always" \| "never" \| "onDirty" | When to show a confirmation dialog |
| labels.title | string | Dialog title; omit to use the autokey screens.{screenId}.actions.{actionId}.title |
| labels.message | string | Dialog message; omit to use the autokey screens.{screenId}.actions.{actionId}.message |
| labels.ok | string | Confirm button label; omit to use the autokey screens.{screenId}.actions.{actionId}.ok |
| labels.cancel | string | Cancel button label; omit to use the autokey screens.{screenId}.actions.{actionId}.cancel |
ActionContext
The action's child block is rendered inside an ActionContext provider. A custom block can consume it via the useActionContext hook to render its UI and trigger the action:
| Field | Type | Description |
|---|---|---|
| id | string | The action's id |
| label | string | Resolved label (translated, or derived from the autokey) |
| isEnabled | boolean | false while the action is executing |
| isExecuting | boolean | true between execute being called and the returned promise settling |
| execute | (fn: () => Promise<void>) => void | Runs fn; if confirmation.askConfirmation === 'always', the confirmation dialog is shown first and fn runs on confirm |
import { useActionContext } from '@knaw-huc/panoptes-react-blocks';
function SaveButton() {
const { label, isEnabled, execute } = useActionContext();
return (
<button disabled={!isEnabled} onClick={() => execute(async () => { /* call API */ })}>
{label}
</button>
);
}FormDefinition and rows
{
"form": {
"rows": [ /* RowDefinition[] */ ]
}
}RowDefinition
| Field | Type | Description |
|---|---|---|
| displayType | "header" \| "group" \| "footer" \| "row" | Styling variant (default: "row") |
| label | string | Optional fieldset legend; omit to use the autokey screens.{screenId}.{groupId} (requires groupId) |
| groupId | string | Used as the React key and data-group-id attribute |
| elements | ElementDefinition[] | Direct child elements (mutually exclusive with columns/rows) |
| columns | ColumnDefinition[] | Multi-column layout; each column holds its own elements |
| rows | RowDefinition[] | Nested rows (recursive) |
| visibleWhen | VisibleWhen | When present, the row is rendered only if the predicate matches — see Conditional visibility |
| tabId | string \| string[] | Restricts the row to the given tab(s); rows without a tabId are visible on every tab. Only meaningful when the screen defines tabs |
Row content is resolved in order of priority: nested rows → columns → elements.
ColumnDefinition
| Field | Type | Description |
|---|---|---|
| elements | ElementDefinition[] | Child elements rendered in this column |
Conditional visibility
RowDefinition accepts an optional visibleWhen predicate. The bound value is resolved using the standard binding syntax and compared against one of the matchers below. When the predicate evaluates to false the row is omitted entirely from the rendered output.
VisibleWhen
| Field | Type | Description |
|---|---|---|
| binding | string | Binding expression resolved against the screen data (e.g. $data#$.contentType) |
| equals | unknown | Match when the resolved value is strictly equal to the given value |
| startsWith | string | Match when the resolved value is a string that starts with the given prefix |
| oneOf | unknown[] | Match when the resolved value is one of the values in the list |
| matches | string | Match when the resolved value is a string matching the given regular expression |
| exists | boolean | When true, match when the resolved value is defined (not undefined/null); when false, match when absent |
Example — show a group of fields only for image content types:
{
"displayType": "group",
"groupId": "metadata-image",
"visibleWhen": {
"binding": "$data#$.contentType",
"startsWith": "image/"
},
"elements": [
{ "type": "label", "value": "$data#$.image.width" },
{ "type": "label", "value": "$data#$.image.height" }
]
}ElementDefinition
| Field | Type | Description |
|---|---|---|
| value | string \| string[] \| Record<string, string> | Binding expression(s) — see Bindings |
| type | string | Element type (see Element types); inferred from data when omitted |
| label | string | Field label rendered above the element; omit to use the autokey screens.{screenId}.{groupId}.{field} (only available for single-string value) |
| infoLabel | string | Secondary info text rendered below the element; omit to use the autokey screens.{screenId}.{groupId}.{field}.info (only available for single-string value) |
| hidden | boolean | Hides the element when true |
| config | object | Type-specific configuration (e.g. options for select, itemTemplate for array) |
SidebarDefinition
| Field | Type | Description |
|---|---|---|
| id | string | Unique sidebar identifier |
| width | string | Optional CSS width for the sidebar (sets --sidebar-width) |
| sections | SidebarSectionDefinition[] | Groups of navigation items separated by a divider |
Each SidebarNavItemDefinition has an icon (Lucide icon name in kebab-case, e.g. "book-open"), an optional label (autokey: screens.{screenId}.sidebar.{sectionId}.{itemId}), an operation, and an optional active flag.
Bindings
Element value fields and itemTemplate field values use binding expressions to pull data from the block's payload. A binding is a source prefix followed by # and a JSONPath expression rooted at $:
| Expression | Source |
|---|---|
| $data#$.field.subfield | The value object of the ScreenBlock |
| $itemData#$.field | The current item object when rendering inside an array element |
The portion after # is a standard JSONPath, evaluated with jsonpath-plus. Anything valid in JSONPath is supported, including:
- Dot or bracket child access —
$.user.city,$['user']['city'] - Array index access —
$.items[0].title - Bracket notation for keys with special characters —
$['Caption/Abstract'],$.metadata['dc:title'] - Wildcards —
$.items[*].title - Recursive descent —
$..title - Filter expressions —
$.items[?(@.published==true)].title
Resolution semantics:
- Single match → the matched value is returned directly (string, number, object, etc.).
- Multiple matches (wildcards, recursive descent, filters) → an array of matched values is returned.
- No match →
undefined.
The value field on an ElementDefinition supports three forms:
Single string — resolves to a single value (or array, for multi-match expressions) and enables autokey label generation:
{ "value": "$data#$.title", "type": "text" }Array of strings — each binding is resolved independently; the block receives an array of resolved values. Useful for blocks that combine multiple data fields:
{ "value": ["$data#$.firstName", "$data#$.lastName"], "type": "full-name" }Object with string values — each property's binding expression is resolved independently; the block receives a plain object with the resolved values. Useful for blocks that need named inputs (e.g. a map block needing separate latitude/longitude fields). No autokey is generated in this form:
{
"value": {
"latitude": "$data#$.plaatsBreedtegraad",
"longitude": "$data#$.plaatsLengtegraad"
},
"type": "map",
"config": { "zoom": 6 }
}Autokey label generation
All label (and infoLabel) fields are optional. When omitted, a translation key is derived automatically and passed through translateFn. The keys follow a hierarchical pattern based on the screen ID, group ID, and field path:
| Context | Autokey pattern | Example |
|---|---|---|
| Screen heading | screens.{screenId} | screens.journal-detail |
| Group legend | screens.{screenId}.{groupId} | screens.journal-detail.metadata |
| Element label | screens.{screenId}.{groupId}.{field} | screens.journal-detail.metadata.title |
| Element info label | screens.{screenId}.{groupId}.{field}.info | screens.journal-detail.metadata.title.info |
| Tab label | screens.{screenId}.tabs.{tabId} | screens.journal-detail.tabs.general |
| Sidebar nav item | screens.{screenId}.sidebar.{sectionId}.{itemId} | screens.journal-detail.sidebar.main.home |
| Action button | screens.{screenId}.actions.{actionId} | screens.journal-detail.actions.save |
| Action confirmation title | screens.{screenId}.actions.{actionId}.title | screens.journal-detail.actions.save.title |
| Action confirmation message | screens.{screenId}.actions.{actionId}.message | screens.journal-detail.actions.save.message |
| Action confirmation ok | screens.{screenId}.actions.{actionId}.ok | screens.journal-detail.actions.save.ok |
| Action confirmation cancel | screens.{screenId}.actions.{actionId}.cancel | screens.journal-detail.actions.save.cancel |
The {field} segment is derived from the binding's JSONPath — e.g. $data#$.title produces title, and $data#$.address.city produces address.city. Bracket notation is normalized into segments ($.items[0].title → items.0.title), and characters that would conflict with the dot-separated key format — colons (:) and whitespace — are replaced with _, so a binding such as $data#$.metadata['dc:title'] produces the autokey segment metadata.dc_title.
For elements rendered inside an array element's itemTemplate, an $itemData#$.field binding's autokey is prefixed with the enclosing array binding's path. For example, an array bound to $data#$.aanvullendeTitels with an item template field bound to $itemData#$.titel produces the autokey screens.{screenId}.{groupId}.aanvullendeTitels.titel. Nested arrays compose: each ItemDataProvider layer adds its array's path to the prefix.
An itemTemplate can also be expressed as { rows: RowDefinition[] } to lay out fields across multiple rows within each item — each row contains an elements array (and optionally nested rows or columns), reusing the same shape as the screen-level RowDefinition. Within a row, elements flow side by side; rows are stacked vertically inside the array item. This is useful for information-dense items or when a nested array within an item should occupy its own row.
When a label is provided explicitly it is used as-is (also passed through translateFn), which allows overriding the autokey with a custom translation key or a literal string.
Element types
The element type can be an unspecified type, or a type present in the collection of Panoptes-known blocks (list, cmdi) and/or application-specific custom blocks.
When type is not specified on an ElementDefinition the type is inferred from the resolved value:
| Inferred condition | Type |
|---|---|
| Array | array |
| Boolean | checkbox |
| Number | number |
| String matching YYYY-MM-DD… | date |
| String containing \n | textarea |
| Anything else | text |
Explicit types available:
| Type | Rendered as | Config options |
|---|---|---|
| text | <input type="text"> (read-only) | — |
| textarea | <textarea> (read-only) | — |
| number | <input type="number"> (read-only) | — |
| date | <input type="date"> (read-only) | — |
| checkbox | <input type="checkbox"> (read-only) | — |
| prose | Inline <span> | — |
| select | Resolved option label in <input type="text"> | config.options: { value, label }[] |
| array | List of text inputs, or templated item rows | config.itemTemplate: either a map of field name → ElementDefinition (single-row layout), or { rows: RowDefinition[] } to group fields across multiple rows |
Any type that matches a registered Panoptes block is rendered using that block component. If no matching block is found (or the block component throws), the element falls back to the native HTML renderer above.
Example of a full screen definition
{
"id": "tijdschrift-detail",
"screenType": "normal",
"globals": {
},
"tabs": [
],
"links": [
],
"actions": [
],
"sidebar": {
"id": "tijdschrift-sidebar",
"sections": [
{
"id": "main",
"items": [
{
"id": "tijdschriften",
"icon": "newspaper",
"label": "tijdschrift-detail.tijdschrift-sidebar.label.publications"
}
]
},
{
"id": "util",
"items": [
{
"id": "instellingen",
"icon": "settings",
"label": "tijdschrift-detail.tijdschrift-sidebar.label.settings"
}
]
}
]
},
"form": {
"rows": [
{
"displayType": "group",
"groupId": "titel",
"columns": [
{
"elements": [
{
"value": "$data#$.lidwoordTitel",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.titelVanTijdschrift",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.onderTitel",
"type": "label"
}
]
}
]
},
{
"displayType": "group",
"groupId": "publicatie",
"rows": [
{
"columns": [
{
"elements": [
{
"value": "$data#$.uitgever",
"type": "link",
"config": {
"url": "/politieke-tijdschriften-uitgever_drukker/details/$uitgeverId"
}
}
]
},
{
"elements": [
{
"value": "$data#$.drukker",
"type": "link",
"config": {
"url": "/politieke-tijdschriften-uitgever_drukker/details/$drukkerId"
}
}
]
},
{
"elements": [
{
"value": "$data#$.plaats",
"type": "link",
"config": {
"url": "/politieke-tijdschriften-plaatsnaam/details/$plaatsId"
}
}
]
}
]
},
{
"columns": [
{
"elements": [
{
"value": "$data#$.uitgeverZeker",
"type": "toggle"
}
]
},
{
"elements": [
{
"value": "$data#$.drukkerZeker",
"type": "toggle"
}
]
},
{
"elements": [
{
"value": "$data#$.nietBewaard",
"type": "toggle"
}
]
},
{
"elements": [
{
"value": "$data#$.vrijheidGelijkheidBroederschap",
"type": "toggle"
}
]
}
]
}
]
},
{
"displayType": "group",
"groupId": "periode",
"rows": [
{
"columns": [
{
"elements": [
{
"value": "$data#$.eersteNummer",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.laatsteNummer",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.prijsDuiten",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.afleveringen",
"type": "label"
}
]
}
]
},
{
"elements": [
{
"value": "$data#$.formaat",
"type": "label"
}
]
}
]
},
{
"displayType": "group",
"groupId": "classificatie",
"columns": [
{
"elements": [
{
"value": "$data#$.vormTijdschrift",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.typeTijdschrift",
"type": "label"
}
]
},
{
"elements": [
{
"value": "$data#$.politiekePositie",
"type": "label"
}
]
}
]
},
{
"displayType": "group",
"groupId": "inhoud",
"elements": [
{
"value": "$data#$.korteOmschrijvingInhoud",
"type": "markdown",
"config": {
}
},
{
"value": "$data#$.verantwoordingSelectie",
"type": "markdown",
"config": {
}
},
{
"value": "$data#$.toelichtingRedacteurAuteur",
"type": "markdown",
"config": {
}
},
{
"value": "$data#$.advertenties_en_andere_verwijsplaatsen",
"type": "markdown"
}
]
},
{
"displayType": "group",
"groupId": "aanvullende-titels",
"elements": [
{
"value": "$data#$.aanvullendeTitels",
"type": "list"
}
]
},
{
"displayType": "group",
"groupId": "artikel-types",
"elements": [
{
"value": "$data#$.artikelType",
"type": "list"
}
]
}
]
}
}Utility components
GhostLine
A decorative horizontal line placeholder used for loading states.
import { GhostLine } from '@knaw-huc/panoptes-react-blocks';
<GhostLine />Repository structure
| Path | Purpose |
|---|---|
| lib/ | Library source — compiled and published as @knaw-huc/panoptes-react-blocks |
| src/ | Example show app for local development and visual testing |
Run the example app with npm run dev to preview components in the browser.
Building
npm run build # compile once
npm run dev # watch mode (starts the example app)Output is written to dist/ as ES module (index.js) and CommonJS (index.cjs) bundles, with a bundled style.css.
