Menus and Navigation

2 views Markdown

Navigation is data the operator edits in the panel. The theme receives finished trees and prints them. Every other address comes from a route key, never from a written path.

Overview

A theme never decides what is in the menu. The operator builds the trees in the panel. The platform resolves each node's address and hands the view ready arrays. The theme owns the markup: how a dropdown looks, where the mobile drawer lives, which node deserves an icon.

The same rule covers links a theme writes itself. A "Log in" or "Cart" link is addressed by its route key, not by a path. Route paths are translated per language and can be renamed. A written path breaks on the second language, and again on the first rename.

Structure

The Trees a Client Page Receives

$header_menu The public header. Nodes may carry children (a dropdown) or an operator-authored markup panel (a mega menu).
$footer_menu The footer. Its top level is the columns, and each node's children are that column's links. Read it one level deeper than the header.
$mobile_menu The drawer. Falls back to the header tree when the operator has not built one, so a theme can always print it.
Other types The panel also manages a client-area tree and a content-page sidebar tree. Neither is in the client pack; a view that needs one asks for it in the theme's hooks file.

The Shape of a Node

one entry of any tree
$node = [
    'id'     => 12,
    'parent' => 0,
    'icon'   => 'bi bi-hdd-rack',      // a class string, not an address
    'target' => 1,                      // 1 opens in a new tab
    'page'   => 'category/551',         // the panel's page identifier, already resolved below
    'title'  => 'Hosting',              // in the ACTIVE language
    'link'   => 'https://example.com/hosting',   // may be EMPTY: a heading-only node
    'extra'  => [
        'desc'  => 'Shared and reseller plans',   // second line inside a dropdown row
        // Badge. The two colours come from the panel's colour pickers, so they arrive as
        // hex strings with a leading hash and go straight into a style attribute.
        'tag'   => ['name' => 'New', 'color' => '#RRGGBB', 'text_color' => '#RRGGBB', 'icon' => 'bi bi-stars'],
        'mega'  => '<div class="mega">…</div>',  // operator-authored markup, links already resolved
    ],
    'children' => [ /* same shape, recursively */ ],
];

The address is resolved before the view sees it. A node bound to a panel page carries the finished link in link; no template converts an identifier itself.

Step by Step

  1. Loop the top level and branch on what the node carries. Children mean a dropdown, a mega panel means raw markup, neither means a plain item.
  2. Guard the address. A node may be a heading with no link at all. Print the attribute only when link is not empty.
  3. Print the mega panel unescaped. It is operator-authored HTML and the engine escapes by default, so otherwise the tags show up on the page.
  4. Close the list with the navigation injection point, so a module can add an item without editing the partial. The header then survives any menu the operator builds.
  1. Loop the top level as columns and print each node's title as the column heading.
  2. Loop that node's children as the column's links, with the same empty-address guard.
  3. Follow the columns with the footer injection point. The footer then grows a column the operator adds, with no template change.
  1. Use the link function with a route key for platform pages: the cart, sign-in, the account overview, a ticket form.
  2. Pass positional parameters when the route takes them, in the order the pattern declares them.
  3. Use the page form for a record the operator picked in the panel: a content page, a category. It takes the same identifier the menu system stores.
  4. Never concatenate a query string by hand. Open the page and confirm the address survives a language switch, which is the point of a key.

Hide Links to Pages the Theme Does Not Serve

  1. List the route keys your theme does not implement in the manifest's meta block. The platform then answers those addresses with a not-found instead of half a page.
  2. Expose the same list to templates as a keyed map from the theme's hooks file. The template sandbox has no array search function, so a map is what a condition can read.
  3. Wrap every link that leads there in that condition. A closed route with a visible link is a dead end the visitor finds before you do.

Reference

signatures
// $type: 'header' | 'footer' | 'mobile' | 'clientArea' | 'pages-sidebar'
// $parent: 0 for the whole tree, or a node id to start below it
// $lang: empty means the active language
public static function tree(string $type = 'header', int $parent = 0, string $lang = ''): array;

// Resolve {link page='...'} / {link route='...'} placeholders inside operator-authored
// markup. Already applied to a node's mega panel; call it for your own stored HTML.
public static function resolveLinks(string $html): string;
Caching One query per type and language, cached for an hour and memoised for the request. A panel save clears it; a theme never needs to.
filter:client.menu Fires once per tree, by reference, with the rows and a context of type and lang. A theme adds or drops a node the panel does not manage here. Edit the rows in place; the return is ignored.
Only active nodes Disabled rows never reach the tree, so a template needs no status check.
https://dev.wisecp.com/cart A route key, translated to this language's path. Keys are declared in the route language files, so one key answers in every language.
https://dev.wisecp.com/invoices/detail/(?) Positional parameters, up to five, filled into the route pattern in order. In the other tag engine they are the arguments after the route.
A panel page identifier (a content page, a category, a product group), resolved the way the menu system does.
The PHP side The same three shapes are LinkGenerator::client(), LinkGenerator::convert_to_link() and, for a query string, LinkGenerator::wQS().
the PHP signatures behind the function
// $lang: empty means the active language; pass one to build the same page in another.
public static function client($route = '', $params = [], $lang = ''): string;

// Panel identifier ("pages/4", "category/551", "home") to a finished address.
public static function convert_to_link(string $arg): string;

// Append a query string. Never concatenate one by hand: this one knows whether the
// address already carries a "?".
public static function wQS(string|bool|null $url, array|string $params = []): string;

Example

The header partial, then the hooks file that closes a section this theme does not serve. Both halves are shown: the template's condition is meaningless without the map that feeds it.

partials/header.tpl
<ul class="nav site-nav">
    {foreach $header_menu as $item}
        {if $item.children}
    <li class="nav-item dropdown">
        <a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a>
        <div class="dropdown-menu">
            {foreach $item.children as $child}
            <a class="dropdown-item"{if $child.link} href="{$child.link}"{/if}{if $child.target} target="_blank" rel="noopener"{/if}>
                <i class="{$child.icon}"></i>
                <span>{$child.title}</span>
                {if $child.extra.desc ?? ''}<span class="item-desc">{$child.extra.desc}</span>{/if}
            </a>
            {/foreach}
        </div>
    </li>
        {elseif $item.extra.mega ?? ''}
    {* Operator-authored markup: printed raw, or the tags themselves show up on the page. *}
    <li class="nav-item dropdown">
        <a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a>
        <div class="dropdown-menu">{$item.extra.mega nofilter}</div>
    </li>
        {else}
    <li class="nav-item"><a class="nav-link"{if $item.link} href="{$item.link}"{/if}>{$item.title}</a></li>
        {/if}
    {/foreach}

    {* A section this theme closed: the map comes from hooks.php, the route from its key. *}
    {if !$route_off.affiliate}
    <li class="nav-item"><a class="nav-link" href="{link route='affiliate'}">{lang key='nav_affiliate'}</a></li>
    {/if}

    {hook name='ui:client.nav.items'}
</ul>

<a class="btn" href="{link route='cart'}">{lang key='nav_cart'}</a>
<a class="btn" href="{link route='invoice-detail' p1=$latest_invoice_id}">{lang key='nav_last_invoice'}</a>
templates/website/Acme/hooks.php
Hook::add("filter:template.variables", 1, function ($template, $data) {

    // Keyed map, not a list: the template sandbox has no in_array(), so a condition
    // can only ask {if !$route_off.affiliate}.
    $data["route_off"] = array_fill_keys(Theme::active()->meta()["disabled_routes"] ?? [], true);

    return $data;
});

/*
 * A node the panel does not manage. By reference: the tree is changed in place and
 * the context says which tree this is, so the footer is not touched by a header rule.
 */
Hook::add("filter:client.menu", 10, function (&$rows, $ctx) {
    if (($ctx["type"] ?? '') !== 'header') return;

    $rows[] = [
        'id'       => 0,
        'parent'   => 0,
        'icon'     => 'bi bi-life-preserver',
        'target'   => 0,
        'page'     => '',
        'title'    => Theme::active()->lang('nav_status'),
        'link'     => LinkGenerator::client('contact'),
        'extra'    => [],
        'children' => [],
    ];
});

Pitfalls

A node's address can be empty

A parent that only opens a dropdown, or a footer column heading, carries no address. Printing the attribute unconditionally emits an empty one. The browser resolves that to the current page, and a screen reader announces a link to nowhere.

A written path is a broken path in the second language

Route paths are translated, and the operator can rename them. Address platform pages by route key and content by page identifier. The only paths a theme may write are its own file-based pages, whose slug is the address by definition.

Do not read the menu tables from a theme

The trees arrive built, resolved, filtered to active rows and cached per language. A query in a template repeats work already paid for and skips the hook every other listener relies on.

The drawer is not always its own tree

With no mobile menu built, the drawer receives the header tree. A drawer that assumes a flat list prints a dropdown's children as top-level rows, so handle children there as well.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.