@knaw-huc/panoptes-react-blocks
v0.0.4
Published
A React component library providing block renderers and a screen layout system for [Panoptes](https://github.com/knaw-huc/panoptes-react) applications.
Downloads
276
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;
}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 (not used yet)
Tabs are .... tabs, and meant for executing an operation after clicking a tab.
| Field | Type | Required | Description |
|---|---|---|--------------------------------------------------------------|
| id | string | yes | Unique tab identifier |
| label | string | no | Tab label; omit to use the autokey screens.{screenId}.tabs.{tabId} |
| operation | OperationDefinition | no | API operation to call when the tab is selected |
| operationList | OperationListItem[] | no | Sub-navigation items shown beneath the active tab |
Each OperationListItem has an id, an optional label (autokey: screens.{screenId}.tabs.{tabId}.{itemId}), and an operation.
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 (not used yet)
Actions are meant to be simple API operations, executed after action button clicks.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Unique action identifier |
| label | string | no | Button label; omit to use the autokey screens.{screenId}.actions.{actionId} |
| activate | "always" \| "onDirty" \| "onValid" \| "onDirtyAndValid" | yes | When the button is enabled |
| confirmation | ConfirmationDefinition | yes | Confirmation dialog settings |
| operation | OperationDefinition | yes | API operation to call on confirm |
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 |
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) |
Row content is resolved in order of priority: nested rows → columns → elements.
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:
| Expression | Source |
|---|---|
| $data#/field/subfield | The value object of the ScreenBlock |
| $itemData#/field | The current item object when rendering inside an array element |
Path segments are separated by /.
The value field on an ElementDefinition supports three forms:
Single string — resolves to a single value 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 |
| Tab operation list item | screens.{screenId}.tabs.{tabId}.{itemId} | screens.journal-detail.tabs.general.volume-1 |
| 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 the path from the binding expression — e.g. $data#/title produces title, and $data#/address/city produces address.city.
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: map of field name → ElementDefinition |
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.
