Files
vue-menu-layer/README.md
Jakub Zych d980f4cf2f feat: MenuNav layer for the SummerCMS menu API
- typed client and useMenu/useMenuApi (SSR, menu:<code>:<locale> key,
  server and public bases, x-forwarded-for forwarded also on cached renders)
- MenuNav and recursive MenuNavItem: WAI disclosure navigation, URL
  allowlist with a text fallback, rel on _blank, active link and trail,
  mobile toggle, Esc / focus-out / outside pointer / route change closing
- menu tokens and base CSS, README
- node tests (link rules, client, server-rendered NavItem) and a playground
  SSR smoke against a fixture API
2026-10-12 18:19:53 +02:00

129 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# vue-menu-layer
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
```bash
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.1.0
```
## Overview
The layer is consumed with `extends: ['vue-menu-layer']`. It reads one route of the menu plugin's anonymous API, `GET /_menu/api/v1/menus/{code}`, during SSR, so the first paint already has the menu, and renders it with `<MenuNav code="…">`. Editors manage menus in the SummerCMS admin; the site needs no menu data of its own.
The layer is neutral: it ships no site colours, no site copy and no Nuxt modules. Its look comes from `--menu-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
## Features
- **`MenuNav`**: a `<nav>` with the menu's `css_class`, a list of entries, and a 44×44 mobile toggle below 768px. Fetched during SSR through `useAsyncData` (key `menu:<code>:<locale>`).
- **Disclosure navigation** (WAI pattern): an entry with children and a URL keeps its link and gets a sibling toggle button with `aria-expanded` and `aria-controls`; an entry with children and no URL (or the placeholder `#`) is one toggle button. Submenus open on click, and on hover for hover-capable pointers on the desktop layout; Esc (focus returns to the toggle), focus leaving the entry, a pointer-down outside the nav and every route change close them. Opening one submenu closes its open siblings.
- **Links**: a site path renders as `NuxtLink`; an absolute, `mailto:`, `tel:` or `#fragment` URL as a plain anchor. A URL outside the allowlist (`http`, `https`, `mailto`, `tel`, a leading `/` or `#`) renders as `<span class="menu-nav__text">` without an `href`, even though the API already refuses such URLs. A `_blank` link carries `rel="noopener noreferrer"`.
- **Active state**: the entry whose URL path equals the current route path (decoded, query, hash and trailing slash ignored; an absolute URL only on the request host) gets `aria-current="page"` and `menu-nav__link--active`; its ancestors get `menu-nav__item--trail`.
- **Dividers**: `aria-hidden` list items, a horizontal line in panels and a short vertical line at the top level on the desktop.
- **Empty states**: a missing menu (404), a failed request or a menu without entries renders nothing, or the `#fallback` slot. There is never an empty `<nav>`.
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
## Usage
```ts
// nuxt.config.ts of the app
export default defineNuxtConfig({
extends: ['vue-menu-layer'],
runtimeConfig: {
menu: { apiBase: 'http://127.0.0.1:8096/_menu/api/v1' }, // NUXT_MENU_API_BASE
},
})
```
```vue
<MenuNav
code="main"
label="Main menu"
open-label="Open menu"
close-label="Close menu"
submenu-label="Show submenu: :title"
/>
```
| Prop | Default | Meaning |
|---|---|---|
| `code` | required | The menu code. |
| `locale` | the API's default locale | Sent as `?locale=`; picks the locale alternates of resolved entries. |
| `label` | the menu code | `aria-label` of the `<nav>`. |
| `openLabel` / `closeLabel` | `Open menu` / `Close menu` | Visually hidden text of the mobile toggle. |
| `submenuLabel` | `Show submenu: :title` | Visually hidden text of a submenu toggle next to a link; `:title` is the entry title. |
Slots:
- `#item="{ entry, depth, active }"`: the content of an entry's link, button or text, for example to render the API's `icon`, which the layer does not render.
- `#fallback`: rendered instead of the nav when there is no menu to show.
`useMenu(code, { locale })` returns the `useAsyncData` result (`data` is `null` for a missing menu) for an app that renders the menu its own way; `useMenuApi()` returns the typed client.
## Configuration
| Key | Default | Environment |
|---|---|---|
| `runtimeConfig.menu.apiBase` | `http://127.0.0.1:8096/_menu/api/v1` | `NUXT_MENU_API_BASE` (server only, used for SSR) |
| `runtimeConfig.public.menu.apiBase` | `/_menu/api/v1` | `NUXT_PUBLIC_MENU_API_BASE` (browser, same origin) |
The base URLs always come from runtime config, never from request input. In production the web server routes `/_menu/` to the SummerCMS binary.
## API
The layer expects the menu plugin's public API:
```json
GET /_menu/api/v1/menus/main?locale=en
{"data":{"code":"main","name":"Main","css_class":null,"items":[
{"id":1,"title":"Home","type":"url","url":"/","target":"_self","css_class":null,"icon":null,"code":"home","items":[]},
{"id":3,"title":"Pages","type":"acme-list","url":"/pages","target":null,"css_class":null,"icon":null,"code":null,"items":[
{"id":null,"title":"About","type":"acme-list","url":"/about","target":null,"css_class":null,"icon":null,"code":null,"items":[]}]},
{"id":5,"title":"","type":"divider","url":null,"target":null,"css_class":null,"icon":null,"code":null,"items":[]}]}}
```
An unknown or inactive menu answers `404 {"error":"Menu not found"}`. The types are in `app/utils/types.ts` (`Menu`, `MenuEntry`).
## Theming
Override tokens on `:root:root` (higher specificity than the layer's `:root`, independent of CSS order):
```css
:root:root {
--menu-color-accent: #2f6b3a;
--menu-font: 'Jost', sans-serif;
}
```
| Group | Tokens |
|---|---|
| Colour | `--menu-color-text`, `--menu-color-muted`, `--menu-color-bg`, `--menu-color-surface`, `--menu-color-border`, `--menu-color-accent` |
| Type | `--menu-font`, `--menu-font-size`, `--menu-line-height` |
| Spacing | `--menu-space-1` … `--menu-space-4` |
| Radii | `--menu-radius`, `--menu-radius-panel` |
| Layout | `--menu-submenu-min-width`, `--menu-toggle-size` |
| Shadow | `--menu-shadow` |
The mobile breakpoint (767px) is fixed in `menu-base.css`, because custom properties cannot drive media queries. The mobile panel is positioned against the nearest positioned ancestor (a sticky header, for example).
To replace a component, add a file with the same path in the app, for example `app/components/Menu/NavItem.vue`.
## Local development
```bash
pnpm install
pnpm dev # the playground in .playground/
```
## Releasing
Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their dependency.
## Testing
```bash
pnpm test # node --test: link rules, client, rendered NavItem markup
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
```
`tests/render.ts` compiles `Menu/NavItem.vue` with Vue's SFC compiler and renders it on the server, so the URL allowlist and `rel` rules are asserted on the markup.