Navigation
pop-nav renders a navigation menu from a nested array. You describe the tree once as data, and the
component resolves the URLs, marks the current page and — when you give it an ACL object — leaves out
the branches the current role cannot reach. The markup it produces is built from pop-dom nodes, so
the element names, ids and classes are all yours to set.
composer require popphp/pop-nav
Building a Nav Tree from Config#
A nav tree is data, which puts it in a config file. Each node needs a name and an href, and may
carry children:
<?php
return [
[
'name' => 'Users',
'href' => '/users',
'children' => [
['name' => 'Roles', 'href' => 'roles'],
['name' => 'Config', 'href' => 'config'],
],
],
[
'name' => 'Orders',
'href' => '/orders',
],
];
How an href resolves depends on its shape, and the three cases are what let one array describe a
whole site:
href |
Resolves to |
|---|---|
/users |
the baseUrl prefix plus /users — absolute within the application |
roles |
its parent's already-resolved href plus /roles, so the child above becomes /users/roles |
https://..., mailto:..., #, or anything starting or ending with # |
itself, untouched, with no baseUrl prefix |
A relative href on a top-level node has no parent to join onto, so give every top-level node an
absolute one.
Nodes can also be added after construction, which is what you want when the menu is assembled from more than one source — installed modules, a plugin registry, per-user extras:
use Pop\Nav\Nav;
$nav = new Nav(include __DIR__ . '/../config/nav.php');
$nav->addBranch(['name' => 'Reports', 'href' => '/reports']);
$nav->addLeaf('Users', ['name' => 'Permissions', 'href' => 'permissions']);
addBranch() appends a top-level node, or prepends it when passed true as a second argument.
addLeaf() finds nodes by name and appends to their children, creating the key if it is not
there yet.
addLeaf() matches on name at every depth and inserts into every match, so give nodes distinct names
when you intend to add to one of them.
Rendering#
With no configuration, every node is a <nav> wrapping an <a>:
use Pop\Nav\Nav;
echo new Nav(include __DIR__ . '/../config/nav.php');
<nav>
<nav>
<a href="/users">Users</a>
<nav>
<nav>
<a href="/users/roles">Roles</a>
</nav>
<nav>
<a href="/users/config">Config</a>
</nav>
</nav>
</nav>
<nav>
<a href="/orders">Orders</a>
</nav>
</nav>
The second constructor argument names the elements and their attributes. top is the outermost
container, parent is the container that wraps a set of children, and child is the element wrapping
each individual link — which is how you get a conventional nested list:
use Pop\Nav\Nav;
$nav = new Nav(include __DIR__ . '/../config/nav.php', [
'top' => ['node' => 'ul', 'id' => 'main-nav'],
'parent' => ['node' => 'ul', 'class' => 'level'],
'child' => ['node' => 'li', 'class' => 'item'],
'indent' => ' ',
]);
echo $nav;
<ul id="main-nav">
<li class="item-1">
<a href="/users">Users</a>
<ul class="level-2">
<li class="item-2">
<a href="/users/roles">Roles</a>
</li>
<li class="item-2">
<a href="/users/config">Config</a>
</li>
</ul>
</li>
<li class="item-1">
<a href="/orders">Orders</a>
</li>
</ul>
An id or class in parent or child gets a numeric suffix appended. The class suffix is the
depth, so item-1 and item-2 are a reliable CSS hook. The id suffix is not — it's a counter that
increments once per node across the whole tree, so menu-3 becomes menu-4 the moment you add a node
above it. Style on the class, not the id.
baseUrl prefixes every absolute href, for an application served from a subdirectory:
use Pop\Nav\Nav;
$nav = new Nav(include __DIR__ . '/../config/nav.php', ['baseUrl' => '/app']);
echo $nav; // <a href="/app/users">Users</a>, <a href="/app/users/roles">Roles</a>, ...
Individual nodes can carry their own attributes, applied to the <a> tag, and returnFalse(true)
adds onclick="return false;" to any link whose resolved href is or ends with # — for menu headings
that open a submenu rather than navigating:
use Pop\Nav\Nav;
$nav = new Nav([
['name' => 'Dashboard', 'href' => '/dashboard', 'attributes' => ['data-tooltip' => 'Go to dashboard']],
['name' => 'More', 'href' => '#'],
]);
$nav->returnFalse(true);
echo $nav; // <a href="#" onclick="return false;">More</a>
Active State#
Set on and off in the config and every link gets one of the two classes, depending on whether it is
the page the reader is currently on:
use Pop\Nav\Nav;
$nav = new Nav(include __DIR__ . '/../config/nav.php', [
'on' => 'link-on',
'off' => 'link-off',
]);
echo $nav;
On a request to /orders:
<nav>
<nav>
<a href="/users" class="link-off">Users</a>
<nav>
<nav>
<a href="/users/roles" class="link-off">Roles</a>
</nav>
</nav>
</nav>
<nav>
<a href="/orders" class="link-on">Orders</a>
</nav>
</nav>
A node that already has a class in its own attributes keeps it — the on/off class is appended
rather than substituted, so ['class' => 'icon-dashboard'] renders as class="icon-dashboard link-on".
The comparison is against $_SERVER['REQUEST_URI'] with any query string stripped, so /orders and
/orders?status=open both light up the /orders link. Outside a web request there is no
REQUEST_URI to read, so set the URL yourself, either in the config or with the setter:
use Pop\Nav\Nav;
$nav = new Nav(include __DIR__ . '/../config/nav.php', ['on' => 'link-on', 'off' => 'link-off']);
$nav->setCurrentUrl('/users/roles');
echo $nav; // Roles is the link marked link-on
currentUrl in the config array does the same thing at construction time. Either one takes precedence
over $_SERVER['REQUEST_URI'] whenever it is set.
The active-state match is exact string equality against the resolved href, so pass the URI without a trailing slash.
ACL-Aware Navigation#
A node with an acl key is rendered only if the current role passes the check. Name the resource, and
optionally a permission, alongside the rest of the node — one added key on the same config file:
['name' => 'Config', 'href' => 'config', 'acl' => ['resource' => 'config']],
Give the Nav the ACL object and the role, and the tree filters itself:
use Pop\Acl\Acl;
use Pop\Acl\AclResource;
use Pop\Acl\AclRole;
use Pop\Nav\Nav;
$acl = new Acl();
$admin = new AclRole('admin');
$editor = new AclRole('editor');
$acl->addRoles([$admin, $editor]);
$acl->addResource(new AclResource('config'));
$acl->allow('admin');
$acl->deny('editor', 'config');
$nav = new Nav(include __DIR__ . '/../config/nav.php');
$nav->setAcl($acl);
$nav->setRole($editor);
echo $nav; // Users, Roles and Orders render; the Config node does not
Swap $editor for $admin and Config reappears. Nothing about the surrounding markup changes — the
denied node is left out of the tree before rendering, so there's no hidden element for a reader to
find in the page source.
ACL evaluation follows pop-acl's permissive default, so call setStrict() on the ACL when you want
nodes hidden unless a rule allows them.
Resolve the user's single applicable role before calling setRole(), or express the logic as a policy.
addRole() and addRoles() add to the set rather than replacing it, and more roles only narrow what
renders: by default a check fails as soon as any one role is explicitly denied, and in strict mode it passes
only when every role is independently allowed.
A policy key, global in the config or per node under acl, hands the decision to a callable that
returns the role whose own logic decides — a role name already on the Acl object, or an object using
Pop\Acl\Policy\PolicyTrait. The result replaces the plain resource check for that node.
Set the ACL object and the role before rendering a tree carrying acl keys.
See Also#
- Authorization — the
pop-aclroles, resources, strict mode and policies this page hands off to - Routing — the routes the hrefs in the tree point at
- Views & Templates — printing the rendered nav from a layout
- DOM — the
Childnodes the rendered markup is built from - pop-nav README — every config key and the policy API