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: {
        id: 'contextNavigation',
        title: 'Context Navigation',
        icon: contextNavigationIcon,
        group: 'common',
        blockSchema: {
            fieldsets: [
                {
                    id: 'default',
                    title: 'Default',
                    fields: ['ariaLabel', 'expandCurrentOnly', 'includeTop', 'items'],
                },
            ],
            properties: {
                ariaLabel: {
                    title: 'Aria label',
                    type: 'string',
                    default: 'Section navigation',
                },
                expandCurrentOnly: {
                    // Smart-expansion: when true, only show items whose
                    // URL is on the path to (or within) the current page.
                    // Descendants of unrelated siblings are hidden. Matches
                    // the typical docs sidebar UX (Sphinx, Docusaurus, etc.).
                    title: 'Expand current section only',
                    type: 'boolean',
                    default: true,
                },
                includeTop: {
                    // Include the section root as the first item in the
                    // nav (e.g. show /concepts itself above /concepts/x).
                    // Matches Volto's `includeTop` contextnavigation param.
                    title: 'Include section root',
                    type: 'boolean',
                    default: false,
                },
                items: {
                    title: 'Items',
                    widget: 'blocks_layout',
                    allowedBlocks: ['navItem', 'listing'],
                    // No defaultBlockType — author picks which kind to add
                },
            },
            required: [],
        },
    }

JSON Block Data

Example block JSON (as stored in the CMS). This is the data structure your component will receive in the block prop.

{
  "@type": "contextNavigation",
  "ariaLabel": "Section navigation",
  "blocks": {
    "navItem-1": {
      "@type": "navItem",
      "label": "Architecture",
      "href": [
        {
          "@id": "/docs/architecture"
        }
      ]
    },
    "navItem-2": {
      "@type": "navItem",
      "label": "Custom blocks",
      "href": [
        {
          "@id": "/docs/frontend-guide/custom-blocks"
        }
      ]
    },
    "navItem-3": {
      "@type": "navItem",
      "label": "Listings",
      "href": [
        {
          "@id": "/docs/frontend-guide/listings"
        }
      ]
    }
  },
  "blocks_layout": {
    "items": [
      "navItem-1",
      "navItem-2",
      "navItem-3"
    ]
  }
}

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>
  );
}