Context Navigation Block
A vertical navigation list for grouped pages — a left sidebar on desktop and a collapsible disclosure at the top on mobile. Each row is a navItem (hand-added link) and/or a listing (auto-populated from a path query). The active link is detected from the current URL and gets aria-current="page" plus a .current class. Named after Plone's @contextnavigation endpoint, which serves the same purpose.
Developer Reference
Schema
Pass this object inside the blocks option when calling initBridge() to register this block type with the admin UI. See Custom Blocks for the full setup guide.
{
"contextNavigation": {
"blockSchema": {
"properties": {
"ariaLabel": {
"title": "Aria label",
"default": "Section navigation"
},
"expandCurrentOnly": {
"title": "Expand current section only",
"type": "boolean",
"default": true
},
"includeTop": {
"title": "Include section root",
"type": "boolean",
"default": false
},
"items": {
"title": "Items",
"widget": "blocks_layout",
"allowedBlocks": [
"navItem",
"listing"
]
}
}
}
},
"navItem": {
"blockSchema": {
"properties": {
"label": {
"title": "Label"
},
"href": {
"title": "Link",
"widget": "object_browser",
"mode": "link"
}
}
}
}
}JSON Block Data
Example JSON as stored in the Plone content API. This is the data structure your component will receive in the block prop.
{
"@type": "contextNavigation",
"ariaLabel": "Section navigation",
"blocks": {
"nav-1": {
"@type": "navItem",
"label": "Introduction",
"href": [
{
"@id": "/docs/introduction"
}
]
},
"nav-2": {
"@type": "navItem",
"label": "Custom blocks",
"href": [
{
"@id": "/docs/custom-blocks"
}
]
},
"nav-2a": {
"@type": "navItem",
"label": "Schema",
"href": [
{
"@id": "/docs/custom-blocks/schema"
}
]
},
"nav-2b": {
"@type": "navItem",
"label": "Rendering",
"href": [
{
"@id": "/docs/custom-blocks/rendering"
}
]
},
"nav-3": {
"@type": "navItem",
"label": "Listings",
"href": [
{
"@id": "/docs/listings"
}
]
}
},
"blocks_layout": {
"items": [
"nav-1",
"nav-2",
"nav-2a",
"nav-2b",
"nav-3"
]
}
}
{
"@type": "contextNavigation",
"items": { "items": ["cnav-listing"] },
"blocks": {
"cnav-listing": {
"@type": "listing",
"variation": "navItem",
"querystring": {
"query": [
{ "i": "path",
"o": "plone.app.querystring.operation.string.relativePath",
"v": "." },
{ "i": "exclude_from_nav",
"o": "plone.app.querystring.operation.boolean.isFalse",
"v": "" }
],
"sort_on": "getObjPositionInParent",
"depth": 2
}
}
}
}Rendering
How this block renders in your frontend. Add its handling to your renderer, or — for list-style blocks — register a fetcher and reuse your list rendering.
function ContextNavigationBlock({ block, blocks }) {
const items = block.blocks_layout?.items || [];
return (
<nav
data-block-uid={block['@uid']}
aria-label={block.ariaLabel || 'Section navigation'}
className="context-navigation"
>
<ul role="list" className="context-navigation-list">
{items.map(id => {
const child = blocks[id];
if (!child) return null;
if (child['@type'] === 'listing') {
return <ListingNav key={id} block={child} blockId={id} />;
}
return <NavItem key={id} block={{ ...child, '@uid': id }} />;
})}
</ul>
</nav>
);
}
function NavItem({ block }) {
// Both manual and listing-synth items share shape: `href` is the
// object_browser array `[{ '@id': string }]` (the listing variation's
// fieldMappings.@default maps `@id` → `href` via type='link'). `label`
// is a string. `_level` is set by the parent ContextNavigationBlock
// after computing minDepth across all sibling hrefs.
const here = window.location.pathname.replace(/\/edit$/, '');
const itemPath = new URL(block.href[0]['@id'], window.location.origin).pathname;
const active = itemPath === here;
const inPath = !active && here.startsWith(itemPath + '/');
return (
<li>
<a
href={itemPath}
data-block-uid={block['@uid']}
data-edit-link="href"
className={`nav-item level-${block._level} ${active ? 'current' : ''} ${inPath ? 'in-path' : ''}`}
aria-current={active ? 'page' : undefined}
>
<span data-edit-text="label">{block.label}</span>
</a>
</li>
);
}