Use this skill when working with the navigation of a Symfony application built on the c975L ecosystem with c975l/site-bundle — the navbar, the footer, the two email menus, menu links and their targets, anchors into a page's sections, the copyright line, the logo and tagline, or exposing another bundle's route as a menu target. Triggers on: Menu entity, menu_link, menu_group, menu_dropdown, dropdown menu, hideInApp, menu-item--web-only, menu-app-only, hide in the installed app, account menu, AccountMenu, my_space, has-back-office, menu-item--back-office, nav-simple-account, menu-dropdown, MenuCrudController, menu_blocks, menu_link_url, menu_style, footer-group-flex, navbar, footer, email-header, email-footer, LinkableRouteProviderInterface, site-navbar-position, sticky navbar, navbar-z-index, site-navbar-show-name, navbar-brand, logo-on-dark, dark logo, menu-logo__on-dark, LOCATION_NAVBAR_BRAND, site-tagline, anchor, absolute_urls, translate menu, management_menu_translate, TranslationController, TranslationFormContext, ai_rephrase, translatable label, screen_languages, LocalizedUrlGenerator, linkable route locales, AiSearch:Trigger, menu-ai-search, search magnifier, data-menu-location, data-menu-translation-submit, menu-active, aria-current, active menu item, edit-shortcut, edit-shortcut:open, footer login shortcut, MenuBlockCacheTagProvider, cacheable menu_link, getMenuLinkCacheTags, LinkTargetChoices, LinkTargetProviderInterface, LinkTargetType, menu-app-only, installed app, PWA legal links, site_legal_pages, install button, menu-install, beforeinstallprompt, pwa_enabled, label.about, menu_link_members_only.
php artisan boost:add-skill c975l/site-bundle
Save this content to: .claude/skills/c975l-site-menus/SKILL.md
# c975L SiteBundle — menus and navigation
> The navbar, the footer and the two email menus, each one row of the `Menu` entity, each composed of blocks like a page is.
**Package:** `c975l/site-bundle` · **Namespace:** `c975L\SiteBundle\` · **Twig namespace:** `@c975LSite` · **Translation domain:** `site`
**Key source paths** (relative to the package root):
`src/Entity/Menu.php`, `src/Controller/Management/MenuCrudController.php`, `src/Form/Block/MenuLinkType.php`, `src/Form/Block/MenuDropdownType.php`, `src/Service/LinkTargetChoices.php`, `src/Twig/MenuExtension.php`, `src/Service/MenuBlockCacheTagProvider.php`, `assets/js/menu-active.js`, `assets/js/menu-dropdown.js`, `assets/js/edit-shortcut.js`, `src/Controller/Management/TranslationController.php`, `src/Management/MenuBlockEditUrlProvider.php`, `templates/components/General/Navbar.html.twig`, `templates/components/General/AccountMenu.html.twig`, `templates/management/menu_crud_index.html.twig`, `templates/components/General/Footer.html.twig`, `templates/blocks/`, `sass/_menu.scss`, `sass/_footer.scss`, `config/services.yaml`
**Related skills:** `c975l-site-layout`, `c975l-site-pages`, `c975l-site-seo`, `c975l-site-assets` in this same package. The block system and its contexts are in `c975l/core-bundle`.
## One entity, four locations
`Menu` carries a `location` — `navbar`, `footer`, `email-header` or `email-footer` — one row each,
unique. Every location owns a single ordered `blocks` collection, the same UiBundle blocks a page
holds, so links and anything else sort freely together with no separate "items" collection.
**Except the navbar**, whose picker only ever offers `menu_link`: a navigation bar is a plain list of
links, anything else belongs in the page. That is UiBundle's exclusive `menu_navbar` context, not a
rule written here.
There is **no "new menu" form**: a menu's only own field is its location, so the index shows one create
button per location not created yet, each creating the row and opening its edit screen in one
CSRF-protected POST (`MenuCrudController::create()`). A location that already has its row just opens it
rather than hitting the unique constraint.
The navbar and the footer are rendered by the layout already — nothing to add in an app:
```twig
<twig:c975LSite:General:Navbar/>
<twig:c975LSite:General:Footer copyright="{{ copyright }}"/>
```
The email locations render inside `@c975LSite/emails/header.html.twig` and `footer.html.twig`,
independently, so a client keeps different content for emails than for the site. A `menu_link` resolves
to a **relative** url, which `emails/fullLayout.html.twig` rewrites against `site-url` on its way out,
so an email menu links to the live site — as long as that setting is filled, since an empty one leaves
the paths relative and the links dead.
## menu_link
Its target is either an existing `Page` — linked **by id**, so renaming the slug never breaks the link,
and an unpublished page stays pickable, flagged "(draft)", resolving to no url until published — or a
route another bundle contributed. A block disappears from the rendered menu on its own if its target
stops resolving: no dangling link, ever.
Optional fields: `label` (always overrides the derived one), `primary` (renders it as a filled button,
meant for a single stand-out item), `strong` (bolds the label alone). The two emphases stack.
`visibility`: `all` (default), `guests` or `members` — never decided at render, the menu's html being
cached for everyone: the link carries `.menu-item--guests`/`--members` and the `is-guest`/`is-member`
class `layout.html.twig` puts on `<body>` hides it (`display: none`). A layout not extending
`@c975LSite/layout.html.twig` must write that body class itself; an email leaves such a link out.
A link left to `all` whose page is kept for members (`menu_link_members_only()`) is written as a
`members` one, the page's save emptying its cache entry.
### Anchors into a page
A target can point at a **section** of a page. Any UiBundle "Page sections" kind can carry an anchor;
once one block on a page has one, `MenuLinkType`'s target select lists it right under that page's own
entry (`Home → Services`) — the whole page tree is walked by UiBundle's `BlockAnchorCollector`, nested
sections included. The target is stored as `page:<id>#<fragment>` and resolved into `/home#services-42`.
The list is built by `LinkTargetChoices`, UiBundle's `LinkTargetProviderInterface`, so every block's link
field (`LinkTargetType`) offers the same pages, sections and routes.
A band rendered by the layout rather than by a page's own blocks is **not** in that list. To make one
linkable, drop its block kind into the page and give it an anchor there.
### In another language
A menu's items are read on every page of the site, so a site translated everywhere but in its navbar is
still half in the language it was written in. The **Translate** action of the Menus index
(`management_menu_translate`, `TranslationController::menu()`) writes a menu's own labels — the
`menu_link` `label` field, declared `translatable` in `config/services.yaml` — once for the whole site,
not per page. An item **deriving** its label from the page it points at carries no text of its own: it
is translated with that page, and `menu_link_label()` reads the page's translated title. `menu_link_url()`
likewise generates the localised routes while a page is read in another language, so the first click
does not send the visitor back into the writing language. Both actions show only where the site
declares more than one language. Where UiBundle's AI rephrasing is on (`ai_rephrase_enabled()`), each
label field of that screen carries its toolbar, pinned to the screen's language
(`TranslationFormContext::set()`) and handed the original text, the field itself starting empty.
The bar also carries the **language menu** itself (`General:Languages`, rendered inside the items so it
follows the mobile dropdown, and on the fallback bar too). It offers only the languages the page being
read was really written in — the list `page_languages()` answers — and links each to the **bare url with
`?_locale=xx`**, never to that language's own `/en/...`: a localised route already says which language it
answers in, so ConfigBundle's `LocaleListener` leaves the session alone there and the choice would last a
single page. A template says which `Page` the menu rewrites the url of by setting `navbarPage`, left null
on a collection item's detail view and in preview. **With no `Page` behind the screen** — a shop listing, a
product sheet, a campaign, a basket — the menu falls back on `screen_languages()` (ConfigBundle's
`LocalizedUrlGenerator::screenLanguages()`), the languages that screen itself answers in, and draws nothing
where it answers in one language alone. In the navbar's items it is drawn as a dropdown (`dropdown` prop,
same markup as `menu_dropdown`, one submit button per language), elsewhere as a `<select>`. Restyled
through `.menu-languages` and `.menu-languages-dropdown`.
### Exposing another bundle's route
Implement ConfigBundle's `LinkableRouteProviderInterface` — **never add a dependency on SiteBundle to
do it**, which is precisely why that interface lives in ConfigBundle. A provider can offer one entry
per row of its own data, each naming the route and its parameters; the url is generated at each render,
so renaming a row leaves no menu item behind. An entry can carry a `picker_label` so it says what it is
in the list while the rendered item keeps the bare title.
An entry also declares the **`locales` it answers in**, and `menu_link_url()` generates it through
ConfigBundle's `LocalizedUrlGenerator`: read in another language, `/shop` is written `/en/shop` wherever
that route has a localised twin answering in it, and exactly as it always was elsewhere — a basket, a token
url, a category nobody translated yet, whose localised url would match the route and answer 404.
## menu_group
A chromeless wrapper taking a line of its own inside the footer's flex row — social links on one line,
legal links on the next. It reuses UiBundle's `block_group` form, template and markup; its own kind
exists only so a menu's grouping stays out of a page's containers.
**`menu_group` is the only group a menu offers**, and UiBundle's own `block_group` is not: a group
taken from anywhere else would be picked happily, then refuse every link dropped into it. A group
cannot hold another group, so the tree is always one level deep.
What a group takes of the footer's row is read from `--footer-group-flex` (`0 0 100%` by default, a
whole line): a theme laying its footer out in columns gives it a shareable basis such as `1 1 200px`
and picks *Inline*, or picks *Columns* (below) and lets the grid draw them, and the *Block* style resets
it to `0 0 auto` — a basis sits on the main axis, so a group's width becomes its height once the footer
stacks.
## menu_dropdown
The one container a `navbar` offers: a title whose links unfold under it (an account menu). Its own
kind, opted into `menu_navbar` alone, its slots built with `menu_slot` like `menu_group`'s — so links
drag into it the same way and it never holds another container. `label` is required and
translatable, `visibility` is a link's own (`all`/`guests`/`members`) applied to the whole of it.
Like a `menu_link`, it has a `hideInApp` checkbox: the entry gets a `.menu-item--web-only` class, which
`sass/_navbar.scss` hides under `display-mode: standalone`, so the installed app does without it. The
rule is scoped to the navbar: ticked in a footer or an email menu, the box does nothing.
A native `<details>`, working with no javascript; `menu-dropdown.js` closes it on an outside click or
Escape on a desktop and keeps it open below 768px, where it reads as a heading inside the burger. Do
not turn a `menu_group` into a dropdown: existing menus rely on the group staying as it is.
## Account menu
A signed-in visitor always gets their links. With no visible `menu_dropdown` of visibility `members`
in the navbar, `Navbar.html.twig` draws `<twig:c975LSite:General:AccountMenu :dropdown="true" />`
("My space": account, back office for the `C975L_ACCESS_BACK_OFFICE` voter, sign out); the fallback
bar draws it as a row (`.nav-simple-account`). It is rendered live, outside the cached menu. A site's
own members' dropdown replaces it (a hidden one counts as absent) and receives the back-office link,
written for everyone and shown by the `has-back-office` body class `layout.html.twig` sets. When every
members' dropdown is ticked `hideInApp`, the built-in one is drawn too, with `:appOnly="true"`
(`.menu-app-only`), so a member keeps an account menu in the installed app.
## Footer display style
The footer's edit screen carries a **Display style** select (`Menu::$style`): the site theme's own
choice (default), *Inline*, *Block* or *Columns*. The three classes **retune `--footer-items-direction` /
`--footer-items-justify` on the `.menu-items` element** rather than writing `flex-direction`, so one
class covers the wrapper and its `.blocks` child and beats what the theme left on `:root`. Left on its
placeholder it stores `null` and adds no class: **a site that already picked its layout in
`themes/site.css` keeps rendering exactly as it did.** Anything but the three known values is stored as
`null` — the value ends up in a class name.
*Columns* retunes that pair, then writes a grid on the `.blocks` child from 768px up (the
stack is the base, mobile first): its first column stacks the blocks placed **before the first group**
— a site's name and its social links — and each group spans every row of a column of its own. Both
track lists read through `--footer-grid-columns` / `--footer-grid-rows`, so a footer counting another
number of groups is retuned from `themes/site.css` rather than from the style.
No other location offers it. The choice is cached with the menu's blocks (`menu_style()`, same
`menus_all` tag).
`MenuCacheInvalidationListener` clears `menus_all` whenever a `Menu` row or **a `Block` of any kind** is
saved or removed — a `Block` doesn't know which owner holds it, and a footer or the `navbar-brand` menu
takes any kind, not only `menu_link`/`menu_group`.
## Cached links, active item in the browser
`menu_link` and `menu_group` are **cacheable** blocks: their html is shared by every page, so
`MenuLink.html.twig` reads no request and marks no item. The `menu-active` Stimulus controller, set on the
navbar and the footer, adds `.active` and `aria-current="page"` to the item whose link is the current path,
or a path under it — never to a link leaving the site or pointing at a fragment.
The tags come from `MenuBlockCacheTagProvider`, which asks `MenuExtension::getMenuLinkCacheTags()`: a page
link is cached under `menus_all` and `PageTranslator::LOCALES_CACHE_TAG`, a route standing for a database row
under the tags its `LinkableRouteCacheTagsInterface` provider declares. It answers `null` — rendered live —
for a link left to the computed copyright notice, a row route whose provider declares no tags, and a request
read in another language than its route says (a browser's language on an unprefixed route), the cache being
keyed by `getLocale()` while the urls follow the `_locale` attribute. A `menu_group` inherits its links'
tags and vetoes as a container.
## Navbar
`Navbar` reads `site_media('logo')`, `config('site-name')` and `menu_blocks('navbar-brand')` — nothing
to pass in. `site-navbar-show-name` (bool, default true) decides whether the name shows there;
`site-navbar-position` (`relative`, `sticky`, `fixed`, `static`, `absolute`) is carried by a
`.menu.menu-position-*` class setting `--navbar-position`, **not by a `style=""` attribute a nonced
`style-src` would drop**.
`sticky` and `absolute` also raise `--navbar-z-index` to 1000, a bar overlapping the page having to be
painted above it. `sticky` sticks through the `<header>` `layout.html.twig` wraps the bar in — the bar
alone is its own containing block and has nowhere to travel — via
`header:has(> .menu.menu-position-sticky:only-child)`. **An app overriding that `header` block with
more than the navigation inside opts out of it** and has to arrange its own sticky header.
The bar also carries UiBundle's site search magnifier (`<twig:c975LUi:AiSearch:Trigger>`), written
twice: `.menu-ai-search--mobile` beside the burger, reachable without opening the menu, and
`.menu-ai-search--desktop` after the links from 768px up, one of the two shown at a time. Both rules
are **scoped under `.menu`** in `sass/_menu.scss`, UiBundle's own `.ai-search-trigger { display }`
carrying the same specificity and winning wherever the sheets load in the other order. The component
writes nothing while the search is off.
When the site ships as an installed app (`pwa_enabled()`), the bar also carries an **About** dropdown
listing the app's downloads (UiBundle's `ui_pwa_downloads` route) and the site's published legal pages (`site_legal_pages()` with no argument, drawn through
`blocks/MenuLink.html.twig` as `page:ID` targets) - never the `footer` menu's blocks, whose social links and
repeated bar entries have no place there. The installed app sheds the footer (UiBundle's `_pwa.scss`) and a
store asks for the privacy policy to stay within reach. Its `.menu-app-only` class hides it outside
`display-mode: standalone` (`sass/_navbar.scss`), so a browser tab never shows it. The same flag writes the
**install button** (`.menu-install`, a `pwa` controller `install` target) `hidden`, shown only once the
browser fires `beforeinstallprompt`. The burger's panel scrolls within the screen (`max-height` +
`overflow-y: auto`, reset from 768px up, where its dropdowns hang out of the row).
Logo and name are wrapped in **one single link** to the home page, not two adjacent ones — a screen
reader announced the same destination twice. The logo's `alt` is emptied when the name is printed
beside it.
From 768px up, a bar holding more items than its width fits **wraps them onto a second line**, flush
right (`.menu .menu-items .blocks`) — left on one line, they ran past the viewport where the root's
`overflow-x: clip` hid them. The footer lays its own row out in `sass/_footer.scss`, untouched.
A site may upload a second logo on the `logo-on-dark` site-graphic role, drawn for a dark page. Both
images are then written — on the menu brand and on the fallback bar alike — and the stylesheet paints
one, `.menu-logo__on-light` and `.menu-logo__on-dark` following the same two selectors as the dark
palette, since which one applies depends on the visitor's own system when `theme-mode` is `auto`. The
dark one is `loading="lazy"` and carries no `fetchpriority`, so a light-themed visitor never downloads
it. Both carry the same `alt`, `display: none` already taking one of the two out of the accessibility
tree — naming only one would leave the home link unnamed on the theme showing the other. A site
carrying only one of the two writes that one, unchanged and unclassed, painted on either ground.
With no navbar menu at all, the component falls back to the logo centered with the name under it, on a
`<nav class="nav-simple">` — that class, not the bare element, is what the stylesheet targets, so an
app rendering its own `<nav>` is untouched.
The tagline is the blocks of the `navbar-brand` `Menu` (`Menu::LOCATION_NAVBAR_BRAND`), the "Under the
site name" location, rendered by `Blocks` inside a `.menu-site-tagline` wrapper — so it is written and translated
like any other block, where the `site-tagline` config entry said one language to everyone.
`sass/_menu.scss` neutralizes the section gutter, the measure and the `text_hook` lead-in there. It is
hidden on a fixed navbar.
## Copyright
The "© firstYear - currentYear[ : siteName]" line is ConfigBundle's `site_copyright()` Twig function.
A `menu_link` targeting the site's own Copyright page shows that live-computed text as its label
instead of the page title, unless the link is given a `label` of its own in the menu, which then wins.
## Twig functions
`menu_blocks(location)`, `menu_link_url()`, `menu_link_label()`, `menu_link_is_copyright()`,
`menu_style()`.
Hovering a **footer** item as an editor raises UiBundle's Edit button, opening the footer's edit screen
with that row unfolded (`MenuBlockEditUrlProvider`). The navbar is deliberately left out: it is hovered
on every visit just to navigate.
Three quick clicks or taps on the footer, off its links, lead to `/login?_target_path=<current page>`
(`edit-shortcut` controller, set on the footer next to `menu-active`), so an editor signs in and lands back
on the page with its edit buttons. The footer's html stays the same for every visitor; the cancelable
`edit-shortcut:open` event carries the url, so a site can send it elsewhere.
A copyright `menu_link` writes its "©" off the link (`menu-item--copyright`), so the notice's own
sign is a place those clicks land.
## Do not
- **Do not add a dependency on SiteBundle to expose a route in a menu.** Implement
`LinkableRouteProviderInterface`, which lives in ConfigBundle for exactly that reason.
- **Do not store a page's slug in a menu link.** The id is what survives a rename.
- **Do not offer `block_group` in a menu.** Use `menu_group`.
- **Do not put anything but links and `menu_dropdown` in a navbar** — the context forbids it, and the
picker will not offer it.
- **Do not put `menu-link` on a dropdown's `<summary>`.** UiBundle's `menu.js` closes the burger on it.
- **Do not test `is_granted` inside a menu block template.** Its html is cached for everyone; use a body class.
- **Do not use a `menu_link` in an email menu while `site-url` is empty.** Nothing makes it absolute then.
- **Do not write `flex-direction` for the footer layout.** Retune the two `--footer-items-*` tokens.
- **Do not set the navbar position with a `style` attribute.** The nonce drops it.
- **Do not build a second navigation table.** A menu is a block collection like a page's.
- **Do not create a menu per language.** One menu, translated once through its Translate screen.
- **Do not print a login link in the footer for editors.** The `edit-shortcut` controller reaches it without changing the cached html.
- **Do not read the request in a menu template.** The html is cached and served to every page; mark the
active item in the browser, as `menu-active` does.
- **Do not read `page.title` for a derived menu label.** It is the writing language's, whatever
language the page is being read in.
The navbar, the footer and the two email menus, each one row of the
Menuentity, each composed of blocks like a page is.
Package: c975l/site-bundle · Namespace: c975L\SiteBundle\ · Twig namespace: @c975LSite · Translation domain: site
Key source paths (relative to the package root):
src/Entity/Menu.php, src/Controller/Management/MenuCrudController.php, src/Form/Block/MenuLinkType.php, src/Form/Block/MenuDropdownType.php, src/Service/LinkTargetChoices.php, src/Twig/MenuExtension.php, src/Service/MenuBlockCacheTagProvider.php, assets/js/menu-active.js, assets/js/menu-dropdown.js, assets/js/edit-shortcut.js, src/Controller/Management/TranslationController.php, src/Management/MenuBlockEditUrlProvider.php, templates/components/General/Navbar.html.twig, templates/components/General/AccountMenu.html.twig, templates/management/menu_crud_index.html.twig, templates/components/General/Footer.html.twig, templates/blocks/, sass/_menu.scss, sass/_footer.scss, config/services.yaml
Related skills: c975l-site-layout, c975l-site-pages, c975l-site-seo, c975l-site-assets in this same package. The block system and its contexts are in c975l/core-bundle.
Menu carries a location — navbar, footer, email-header or email-footer — one row each,
unique. Every location owns a single ordered blocks collection, the same UiBundle blocks a page
holds, so links and anything else sort freely together with no separate "items" collection.
Except the navbar, whose picker only ever offers menu_link: a navigation bar is a plain list of
links, anything else belongs in the page. That is UiBundle's exclusive menu_navbar context, not a
rule written here.
There is no "new menu" form: a menu's only own field is its location, so the index shows one create
button per location not created yet, each creating the row and opening its edit screen in one
CSRF-protected POST (MenuCrudController::create()). A location that already has its row just opens it
rather than hitting the unique constraint.
The navbar and the footer are rendered by the layout already — nothing to add in an app:
<twig:c975LSite:General:Navbar/>
<twig:c975LSite:General:Footer copyright="{{ copyright }}"/>
The email locations render inside @c975LSite/emails/header.html.twig and footer.html.twig,
independently, so a client keeps different content for emails than for the site. A menu_link resolves
to a relative url, which emails/fullLayout.html.twig rewrites against site-url on its way out,
so an email menu links to the live site — as long as that setting is filled, since an empty one leaves
the paths relative and the links dead.
Its target is either an existing Page — linked by id, so renaming the slug never breaks the link,
and an unpublished page stays pickable, flagged "(draft)", resolving to no url until published — or a
route another bundle contributed. A block disappears from the rendered menu on its own if its target
stops resolving: no dangling link, ever.
Optional fields: label (always overrides the derived one), primary (renders it as a filled button,
meant for a single stand-out item), strong (bolds the label alone). The two emphases stack.
visibility: all (default), guests or members — never decided at render, the menu's html being
cached for everyone: the link carries .menu-item--guests/--members and the is-guest/is-member
class layout.html.twig puts on <body> hides it (display: none). A layout not extending
@c975LSite/layout.html.twig must write that body class itself; an email leaves such a link out.
A link left to all whose page is kept for members (menu_link_members_only()) is written as a
members one, the page's save emptying its cache entry.
A target can point at a section of a page. Any UiBundle "Page sections" kind can carry an anchor;
once one block on a page has one, MenuLinkType's target select lists it right under that page's own
entry (Home → Services) — the whole page tree is walked by UiBundle's BlockAnchorCollector, nested
sections included. The target is stored as page:<id>#<fragment> and resolved into /home#services-42.
The list is built by LinkTargetChoices, UiBundle's LinkTargetProviderInterface, so every block's link
field (LinkTargetType) offers the same pages, sections and routes.
A band rendered by the layout rather than by a page's own blocks is not in that list. To make one linkable, drop its block kind into the page and give it an anchor there.
A menu's items are read on every page of the site, so a site translated everywhere but in its navbar is
still half in the language it was written in. The Translate action of the Menus index
(management_menu_translate, TranslationController::menu()) writes a menu's own labels — the
menu_link label field, declared translatable in config/services.yaml — once for the whole site,
not per page. An item deriving its label from the page it points at carries no text of its own: it
is translated with that page, and menu_link_label() reads the page's translated title. menu_link_url()
likewise generates the localised routes while a page is read in another language, so the first click
does not send the visitor back into the writing language. Both actions show only where the site
declares more than one language. Where UiBundle's AI rephrasing is on (ai_rephrase_enabled()), each
label field of that screen carries its toolbar, pinned to the screen's language
(TranslationFormContext::set()) and handed the original text, the field itself starting empty.
The bar also carries the language menu itself (General:Languages, rendered inside the items so it
follows the mobile dropdown, and on the fallback bar too). It offers only the languages the page being
read was really written in — the list page_languages() answers — and links each to the bare url with
?_locale=xx, never to that language's own /en/...: a localised route already says which language it
answers in, so ConfigBundle's LocaleListener leaves the session alone there and the choice would last a
single page. A template says which Page the menu rewrites the url of by setting navbarPage, left null
on a collection item's detail view and in preview. With no Page behind the screen — a shop listing, a
product sheet, a campaign, a basket — the menu falls back on screen_languages() (ConfigBundle's
LocalizedUrlGenerator::screenLanguages()), the languages that screen itself answers in, and draws nothing
where it answers in one language alone. In the navbar's items it is drawn as a dropdown (dropdown prop,
same markup as menu_dropdown, one submit button per language), elsewhere as a <select>. Restyled
through .menu-languages and .menu-languages-dropdown.
Implement ConfigBundle's LinkableRouteProviderInterface — never add a dependency on SiteBundle to
do it, which is precisely why that interface lives in ConfigBundle. A provider can offer one entry
per row of its own data, each naming the route and its parameters; the url is generated at each render,
so renaming a row leaves no menu item behind. An entry can carry a picker_label so it says what it is
in the list while the rendered item keeps the bare title.
An entry also declares the locales it answers in, and menu_link_url() generates it through
ConfigBundle's LocalizedUrlGenerator: read in another language, /shop is written /en/shop wherever
that route has a localised twin answering in it, and exactly as it always was elsewhere — a basket, a token
url, a category nobody translated yet, whose localised url would match the route and answer 404.
A chromeless wrapper taking a line of its own inside the footer's flex row — social links on one line,
legal links on the next. It reuses UiBundle's block_group form, template and markup; its own kind
exists only so a menu's grouping stays out of a page's containers.
menu_group is the only group a menu offers, and UiBundle's own block_group is not: a group
taken from anywhere else would be picked happily, then refuse every link dropped into it. A group
cannot hold another group, so the tree is always one level deep.
What a group takes of the footer's row is read from --footer-group-flex (0 0 100% by default, a
whole line): a theme laying its footer out in columns gives it a shareable basis such as 1 1 200px
and picks Inline, or picks Columns (below) and lets the grid draw them, and the Block style resets
it to 0 0 auto — a basis sits on the main axis, so a group's width becomes its height once the footer
stacks.
The one container a navbar offers: a title whose links unfold under it (an account menu). Its own
kind, opted into menu_navbar alone, its slots built with menu_slot like menu_group's — so links
drag into it the same way and it never holds another container. label is required and
translatable, visibility is a link's own (all/guests/members) applied to the whole of it.
Like a menu_link, it has a hideInApp checkbox: the entry gets a .menu-item--web-only class, which
sass/_navbar.scss hides under display-mode: standalone, so the installed app does without it. The
rule is scoped to the navbar: ticked in a footer or an email menu, the box does nothing.
A native <details>, working with no javascript; menu-dropdown.js closes it on an outside click or
Escape on a desktop and keeps it open below 768px, where it reads as a heading inside the burger. Do
not turn a menu_group into a dropdown: existing menus rely on the group staying as it is.
A signed-in visitor always gets their links. With no visible menu_dropdown of visibility members
in the navbar, Navbar.html.twig draws <twig:c975LSite:General:AccountMenu :dropdown="true" />
("My space": account, back office for the C975L_ACCESS_BACK_OFFICE voter, sign out); the fallback
bar draws it as a row (.nav-simple-account). It is rendered live, outside the cached menu. A site's
own members' dropdown replaces it (a hidden one counts as absent) and receives the back-office link,
written for everyone and shown by the has-back-office body class layout.html.twig sets. When every
members' dropdown is ticked hideInApp, the built-in one is drawn too, with :appOnly="true"
(.menu-app-only), so a member keeps an account menu in the installed app.
The footer's edit screen carries a Display style select (Menu::$style): the site theme's own
choice (default), Inline, Block or Columns. The three classes retune --footer-items-direction /
--footer-items-justify on the .menu-items element rather than writing flex-direction, so one
class covers the wrapper and its .blocks child and beats what the theme left on :root. Left on its
placeholder it stores null and adds no class: a site that already picked its layout in
themes/site.css keeps rendering exactly as it did. Anything but the three known values is stored as
null — the value ends up in a class name.
Columns retunes that pair, then writes a grid on the .blocks child from 768px up (the
stack is the base, mobile first): its first column stacks the blocks placed before the first group
— a site's name and its social links — and each group spans every row of a column of its own. Both
track lists read through --footer-grid-columns / --footer-grid-rows, so a footer counting another
number of groups is retuned from themes/site.css rather than from the style.
No other location offers it. The choice is cached with the menu's blocks (menu_style(), same
menus_all tag).
MenuCacheInvalidationListener clears menus_all whenever a Menu row or a Block of any kind is
saved or removed — a Block doesn't know which owner holds it, and a footer or the navbar-brand menu
takes any kind, not only menu_link/menu_group.
menu_link and menu_group are cacheable blocks: their html is shared by every page, so
MenuLink.html.twig reads no request and marks no item. The menu-active Stimulus controller, set on the
navbar and the footer, adds .active and aria-current="page" to the item whose link is the current path,
or a path under it — never to a link leaving the site or pointing at a fragment.
The tags come from MenuBlockCacheTagProvider, which asks MenuExtension::getMenuLinkCacheTags(): a page
link is cached under menus_all and PageTranslator::LOCALES_CACHE_TAG, a route standing for a database row
under the tags its LinkableRouteCacheTagsInterface provider declares. It answers null — rendered live —
for a link left to the computed copyright notice, a row route whose provider declares no tags, and a request
read in another language than its route says (a browser's language on an unprefixed route), the cache being
keyed by getLocale() while the urls follow the _locale attribute. A menu_group inherits its links'
tags and vetoes as a container.
Navbar reads site_media('logo'), config('site-name') and menu_blocks('navbar-brand') — nothing
to pass in. site-navbar-show-name (bool, default true) decides whether the name shows there;
site-navbar-position (relative, sticky, fixed, static, absolute) is carried by a
.menu.menu-position-* class setting --navbar-position, not by a style="" attribute a nonced
style-src would drop.
sticky and absolute also raise --navbar-z-index to 1000, a bar overlapping the page having to be
painted above it. sticky sticks through the <header> layout.html.twig wraps the bar in — the bar
alone is its own containing block and has nowhere to travel — via
header:has(> .menu.menu-position-sticky:only-child). An app overriding that header block with
more than the navigation inside opts out of it and has to arrange its own sticky header.
The bar also carries UiBundle's site search magnifier (<twig:c975LUi:AiSearch:Trigger>), written
twice: .menu-ai-search--mobile beside the burger, reachable without opening the menu, and
.menu-ai-search--desktop after the links from 768px up, one of the two shown at a time. Both rules
are scoped under .menu in sass/_menu.scss, UiBundle's own .ai-search-trigger { display }
carrying the same specificity and winning wherever the sheets load in the other order. The component
writes nothing while the search is off.
When the site ships as an installed app (pwa_enabled()), the bar also carries an About dropdown
listing the app's downloads (UiBundle's ui_pwa_downloads route) and the site's published legal pages (site_legal_pages() with no argument, drawn through
blocks/MenuLink.html.twig as page:ID targets) - never the footer menu's blocks, whose social links and
repeated bar entries have no place there. The installed app sheds the footer (UiBundle's _pwa.scss) and a
store asks for the privacy policy to stay within reach. Its .menu-app-only class hides it outside
display-mode: standalone (sass/_navbar.scss), so a browser tab never shows it. The same flag writes the
install button (.menu-install, a pwa controller install target) hidden, shown only once the
browser fires beforeinstallprompt. The burger's panel scrolls within the screen (max-height +
overflow-y: auto, reset from 768px up, where its dropdowns hang out of the row).
Logo and name are wrapped in one single link to the home page, not two adjacent ones — a screen
reader announced the same destination twice. The logo's alt is emptied when the name is printed
beside it.
From 768px up, a bar holding more items than its width fits wraps them onto a second line, flush
right (.menu .menu-items .blocks) — left on one line, they ran past the viewport where the root's
overflow-x: clip hid them. The footer lays its own row out in sass/_footer.scss, untouched.
A site may upload a second logo on the logo-on-dark site-graphic role, drawn for a dark page. Both
images are then written — on the menu brand and on the fallback bar alike — and the stylesheet paints
one, .menu-logo__on-light and .menu-logo__on-dark following the same two selectors as the dark
palette, since which one applies depends on the visitor's own system when theme-mode is auto. The
dark one is loading="lazy" and carries no fetchpriority, so a light-themed visitor never downloads
it. Both carry the same alt, display: none already taking one of the two out of the accessibility
tree — naming only one would leave the home link unnamed on the theme showing the other. A site
carrying only one of the two writes that one, unchanged and unclassed, painted on either ground.
With no navbar menu at all, the component falls back to the logo centered with the name under it, on a
<nav class="nav-simple"> — that class, not the bare element, is what the stylesheet targets, so an
app rendering its own <nav> is untouched.
The tagline is the blocks of the navbar-brand Menu (Menu::LOCATION_NAVBAR_BRAND), the "Under the
site name" location, rendered by Blocks inside a .menu-site-tagline wrapper — so it is written and translated
like any other block, where the site-tagline config entry said one language to everyone.
sass/_menu.scss neutralizes the section gutter, the measure and the text_hook lead-in there. It is
hidden on a fixed navbar.
The "© firstYear - currentYear[ : siteName]" line is ConfigBundle's site_copyright() Twig function.
A menu_link targeting the site's own Copyright page shows that live-computed text as its label
instead of the page title, unless the link is given a label of its own in the menu, which then wins.
menu_blocks(location), menu_link_url(), menu_link_label(), menu_link_is_copyright(),
menu_style().
Hovering a footer item as an editor raises UiBundle's Edit button, opening the footer's edit screen
with that row unfolded (MenuBlockEditUrlProvider). The navbar is deliberately left out: it is hovered
on every visit just to navigate.
Three quick clicks or taps on the footer, off its links, lead to /login?_target_path=<current page>
(edit-shortcut controller, set on the footer next to menu-active), so an editor signs in and lands back
on the page with its edit buttons. The footer's html stays the same for every visitor; the cancelable
edit-shortcut:open event carries the url, so a site can send it elsewhere.
A copyright menu_link writes its "©" off the link (menu-item--copyright), so the notice's own
sign is a place those clicks land.
LinkableRouteProviderInterface, which lives in ConfigBundle for exactly that reason.block_group in a menu. Use menu_group.menu_dropdown in a navbar — the context forbids it, and the
picker will not offer it.menu-link on a dropdown's <summary>. UiBundle's menu.js closes the burger on it.is_granted inside a menu block template. Its html is cached for everyone; use a body class.menu_link in an email menu while site-url is empty. Nothing makes it absolute then.flex-direction for the footer layout. Retune the two --footer-items-* tokens.style attribute. The nonce drops it.edit-shortcut controller reaches it without changing the cached html.menu-active does.page.title for a derived menu label. It is the writing language's, whatever
language the page is being read in.How can I help you explore Laravel packages today?