07 — Navigation and mega menu#
Navigation is where "editable by a non-technical person" usually breaks down. KodePress treats menus as data (a tree of items with rules) and mega-menu panels as content (a block tree), which is why a mega panel is built with the page editor rather than a bespoke screen.
7.1 Menus#
Menus in the sidebar lists every menu with its location and item count.
| Concept | Rule |
|---|---|
| Multiple menus | any number per site |
| Locations | header, topbar, footer-1..footer-4, mobile, none |
| Location is a hint | a menu_slot block names a menu explicitly; location is the fallback used by header presets and by the "use the header menu" default |
| Depth | 3 levels (depth 0, 1, 2). The editor refuses a deeper drop and says why |
| Mobile menu | if a menu has location = mobile it is used on small screens; otherwise the header menu is reused |
Menu screen layout: the tree on the left (drag and drop, collapse/expand, multi-select), the selected item's settings in the middle, and a live preview of the rendered menu on the right. The preview uses the real renderer inside the real header, so the owner sees the actual result.
Bulk operations: select several items to move them under a new parent, delete them, toggle
new_tab, or set visibility in one go.
Menu-level settings (menus.settings): desktop alignment (left/centre/right), submenu open trigger
(hover/click), submenu animation (none/fade/slide), submenu indicator (chevron on/off), mobile
behaviour (accordion/drawer), and auto_children (see 7.4).
Import/export: a menu exports as JSON (items with targets resolved to slugs, not ids) and imports
into another menu or another site, re-resolving slugs and reporting anything it could not match.
Copy menu duplicates within the site.
7.2 Item types#
| Type | Target | Renders as |
|---|---|---|
page |
target_id -> pages.id (type page/landing) |
link to the page path, resolved at render |
post |
target_id -> pages.id (type post) |
link to the post path |
category |
target_id -> categories.id |
link to the category archive |
tag |
target_id -> tags.id |
link to the tag archive |
url |
url |
external or internal link, optional new_tab, rel=nofollow option |
anchor |
url = #section-id, optional page |
smooth-scroll link; if the anchor is on another page it links to path#anchor |
phone |
url = tel:... |
tap-to-call, phone icon by default |
email |
url = mailto:... |
mail link |
button |
any of the above targets | styled as a theme button, not a plain link |
dropdown |
none | a label that opens its children and is not itself a link |
mega |
none, plus a mega tree |
opens a mega panel (7.5) |
divider |
none | a separator inside a dropdown |
heading |
none | a non-clickable group label inside a dropdown or mega panel |
Per-item settings:
| Setting | Column | Notes |
|---|---|---|
| Label | label |
defaults to the target title, then becomes independent |
| Icon | icon |
from the bundled icon set, searchable picker |
| Badge | badge_text, badge_color |
e.g. New, Hot |
| Open in new tab | new_tab |
|
| CSS class | css_class |
Advanced |
| Highlight colour | highlight_color |
token first, hex under Advanced |
| Visibility | visibility JSON |
see 7.3 |
| Active | is_active |
hide an item without deleting it |
7.3 Visibility rules#
menu_items.visibility:
{
"auth": "any", // any | guest | user
"roles": ["admin", "editor"], // empty = no role restriction (only meaningful with auth=user)
"devices": ["desktop", "mobile"], // empty = all
"locales": ["bn"] // empty = all
}
Evaluation:
authandrolesare evaluated server-side. An item a visitor must not see is never emitted into the HTML.devicesis applied with CSS classes (kp-hide-mobile), because the HTML cache is shared across devices. Hiding a link on mobile is a layout choice, not a security boundary — the docs for the field say exactly that.localesis evaluated server-side; each locale has its own cached HTML anyway.- A rule on a parent hides the whole branch.
Because auth-dependent menus cannot be shared with anonymous visitors, a menu containing any
auth != any item makes its containing header cacheable: false for logged-in users only:
anonymous visitors still get the cached file (built with the guest view of the menu), and logged-in
users render dynamically. See 13-caching-and-performance.md.
7.4 Auto-sync and integrity#
| Event | Effect on menus |
|---|---|
| Page title changes | items whose label was never edited by hand follow the new title; edited labels stay |
| Page slug or parent changes | nothing to update — targets are ids and URLs are resolved at render |
| Page unpublished | the item renders only for users who can see drafts; for visitors it is dropped, and the menu screen flags it Not published |
| Page or category deleted | the item is flagged Broken link in the menu screen and in Dashboard -> Needs attention; publicly the item is dropped |
Category gains children + auto_children on |
children appear as a sub-menu automatically, ordered by categories.position |
auto_children is a per-item toggle (settings.auto_children) that expands a category item with
its child categories, optionally with a View all first entry. Manually added children and
auto-children can coexist; manual ones come first.
Active-item highlighting: the renderer marks an item aria-current="page" when its resolved URL
equals the current path, and adds kp-menu-ancestor to its ancestors. Because HTML is cached per
path, the active state is baked into each page's cached copy — correct by construction, with no
JavaScript.
7.5 Mega menus#
Any item at depth 0 has a Mega menu toggle. Turning it on opens the panel editor: the same
section/column/block editor used for pages, in the mega context.
- The tree is stored in
menu_items.mega. - An empty panel means the item behaves as a normal dropdown. Turning the toggle off keeps the tree, so experimenting is safe.
- Any block whose
contextsincludemegacan be used:icon_list,heading,text,image,button,post_list,video,search,language_switcher,divider,spacer.
Ready-made panel layouts#
Offered when the toggle is first turned on (each is a templates row with kind = section and
category mega):
| Layout | Structure |
|---|---|
| 3-column links | three columns, each a heading + icon_list |
| Links + featured card | two link columns + one column with an image, heading, text and button |
| Categories + latest posts | one column of category links + a post_list filtered by the hovered category |
| Tabbed panel | a left column of headings acting as tabs, right column swapping content |
| Full width | a single full-bleed section, free-form |
The tabbed layout is the only one with behaviour beyond layout: tab state is Alpine-local, panels
are all rendered and toggled with hidden, so there is no request on hover.
Panel settings (menu_items.settings)#
| Setting | Values | Default |
|---|---|---|
panel_width |
container, full, item (aligned to the trigger) |
container |
background |
token name, or image with background_media_id |
surface |
columns |
1–6 (a convenience that sets the section columns) | 3 |
open_on |
hover, click |
hover |
animation |
none, fade, slide |
fade |
max_height |
px, 0 = auto; scrolls past it | 0 |
mobile_mode |
accordion, drawer, hidden |
accordion |
Behaviour#
- Desktop:
open_on: hoveropens after a 120 ms intent delay and closes after a 240 ms grace period, so a diagonal mouse path does not close the panel.open_on: clicktoggles on click and onEnter/Space, and closes onEscapeor an outside click. - Keyboard: the trigger is a
<button aria-expanded aria-controls>;Tabmoves into the panel;Escapecloses and returns focus to the trigger. Hover-only menus are still fully operable by keyboard because hover and click both open. - Mobile: the panel becomes an accordion (the panel tree rendered stacked, single column) or a
drawer, per
mobile_mode. Columns collapse to one;icon_liststays a list; apost_listinside a panel caps at 3 items on mobile. This is a render-time transformation of the same tree, not a second tree the owner must maintain. - Touch on desktop widths: the first tap opens, the second follows the link.
7.6 Rendering a menu#
MenuSlot block (menu_id, depth limit, style overrides)
-> MenuRenderer::render(menu, ctx)
|-- load items once, ordered by (parent_id, position), build the tree in PHP
|-- resolve each target to a URL (page path, category path, raw url, tel:, mailto:)
|-- drop items failing server-side visibility; add device CSS classes
|-- mark active / ancestor against ctx.path
|-- for each item with a non-empty `mega`: PanelRenderer::render(tree, ctx)
+-- emit nav > ul > li markup with ARIA
One query loads all items for a menu; nothing in the loop touches the database. Target resolution batches its lookups (all page ids in one query, all category ids in one query). A menu with 60 items and 4 mega panels costs 4 queries, and in practice zero, because the result lands in the cached page HTML.
Markup contract:
<nav class="kp-menu kp-menu--header" aria-label="Main menu">
<ul class="kp-menu__list">
<li class="kp-menu__item kp-menu__item--has-panel">
<button class="kp-menu__link" aria-expanded="false" aria-controls="kp-panel-12">
Products <span class="kp-menu__chevron" aria-hidden="true"></span>
</button>
<div class="kp-panel" id="kp-panel-12" hidden> ... panel tree ... </div>
</li>
<li class="kp-menu__item">
<a class="kp-menu__link" href="/about" aria-current="page">About</a>
</li>
</ul>
</nav>
7.7 Permissions#
| Action | Admin | Editor | Writer | Designer |
|---|---|---|---|---|
| Create / delete a menu | yes | no | no | yes |
| Add / edit / reorder items | yes | yes | no | yes |
| Edit a mega-menu panel layout | yes | no | no | yes |
| Change menu-level settings | yes | no | no | yes |
An Editor can keep navigation current (add a link to a new page) without being able to restructure
the design. This split is enforced by MenuPolicy, not by hiding buttons.
7.8 Tests for this document#
| Test | Asserts |
|---|---|
three_level_menu_renders |
nested ul depth is 3 and no deeper |
depth_limit_enforced |
an attempt to nest at depth 3 is rejected |
visibility_role_rule_hides_item_server_side |
the HTML for a guest contains no trace of the item |
device_rule_adds_class_not_removal |
the item is present with kp-hide-mobile |
mega_panel_renders_desktop_and_mobile |
the same tree produces a panel and an accordion |
empty_mega_behaves_as_dropdown |
no panel markup, children render as a dropdown |
page_rename_updates_unedited_label |
and leaves a hand-edited label alone |
deleted_target_drops_item_publicly |
and flags it in the admin |
active_item_marked |
aria-current="page" on the matching item, ancestor class on parents |
menu_renders_in_constant_queries |
query count does not grow with item count |
editor_cannot_edit_mega_layout |
MenuPolicy denies it, 403 |
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.