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
This commit is contained in:
2026-10-12 18:19:53 +02:00
commit d980f4cf2f
27 changed files with 8529 additions and 0 deletions

125
app/components/Menu/Nav.vue Normal file
View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
// MenuNav: a menu of the menu plugin as a site navigation. Fetched during
// SSR (useMenu), so the first paint has it. A missing menu (404), a failed
// request or a menu without entries renders nothing, or the #fallback slot;
// never an empty <nav>. Below 768px a 44x44 toggle opens the list as a
// panel; Esc and every route change close it.
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import type { MenuEntry } from '../../utils/types.ts'
const props = withDefaults(
defineProps<{
/** The menu code (GET /menus/{code}). */
code: string
/** ?locale= for the API; the API's default locale when unset. */
locale?: string
/** aria-label of the <nav>; the menu code when unset. */
label?: string
openLabel?: string
closeLabel?: string
/** Label of a submenu toggle next to a link; ":title" is the entry title. */
submenuLabel?: string
}>(),
{
locale: undefined,
label: undefined,
openLabel: 'Open menu',
closeLabel: 'Close menu',
submenuLabel: 'Show submenu: :title',
},
)
const slots = defineSlots<{
item?(props: { entry: MenuEntry; depth: number; active: boolean }): unknown
fallback?(): unknown
}>()
const { data: menu, error } = await useMenu(
() => props.code,
{ locale: () => props.locale },
)
const items = computed<MenuEntry[]>(() => (error.value ? [] : (menu.value?.items ?? [])))
// Dividers alone are no menu.
const visible = computed(() => items.value.some((entry) => entry.type !== 'divider'))
const route = useRoute()
const host = useRequestURL().host
const uid = useId()
const listId = `${uid}-list`
const panelOpen = ref(false)
const openIndex = ref<number | null>(null)
const navEl = ref<HTMLElement | null>(null)
const toggleEl = ref<HTMLButtonElement | null>(null)
watch(
() => route.path,
() => {
panelOpen.value = false
openIndex.value = null
},
)
function setOpen(index: number, value: boolean): void {
if (value) openIndex.value = index
else if (openIndex.value === index) openIndex.value = null
}
// Esc that no open submenu took closes the mobile panel.
function onEscape(): void {
if (!panelOpen.value) return
panelOpen.value = false
toggleEl.value?.focus()
}
// A pointer-down outside the nav closes its open submenus.
function onPointerDown(event: PointerEvent): void {
if (navEl.value && !navEl.value.contains(event.target as Node)) openIndex.value = null
}
onMounted(() => document.addEventListener('pointerdown', onPointerDown))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown))
</script>
<template>
<nav
v-if="visible"
ref="navEl"
class="menu-nav"
:class="[menu?.css_class, { 'menu-nav--open': panelOpen }]"
:aria-label="label || code"
@keydown.esc="onEscape"
>
<button
ref="toggleEl"
type="button"
class="menu-nav__toggle"
:aria-controls="listId"
:aria-expanded="panelOpen ? 'true' : 'false'"
@click="panelOpen = !panelOpen"
>
<span class="menu-nav__toggle-bars" aria-hidden="true" />
<span class="menu-nav__sr">{{ panelOpen ? closeLabel : openLabel }}</span>
</button>
<ul :id="listId" class="menu-nav__list">
<MenuNavItem
v-for="(entry, i) in items"
:key="entry.id ?? `generated-${i}`"
:entry="entry"
:depth="0"
:id-base="`${uid}-${i}`"
:open="openIndex === i"
:route-path="route.path"
:host="host"
:submenu-label="submenuLabel"
@update:open="(value: boolean) => setOpen(i, value)"
>
<template v-if="slots.item" #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
</MenuNavItem>
</ul>
</nav>
<slot v-else name="fallback" />
</template>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
// One menu entry (MenuNavItem, recursive): a link, a disclosure toggle with
// its submenu, plain text, or a divider. The WAI disclosure navigation
// pattern: an entry with children and a URL keeps its link and gets a
// sibling toggle button; an entry with children and no URL is one button.
import { computed, ref, watch } from 'vue'
import type { MenuEntry } from '../../utils/types.ts'
import { entryHref, fillTitle, inTrail, isActive, linkKind, linkRel } from '../../utils/menuLinks.ts'
const props = defineProps<{
entry: MenuEntry
depth: number
/** Unique, SSR-stable id prefix of this entry (submenu ids). */
idBase: string
open: boolean
routePath: string
host: string
submenuLabel: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
defineSlots<{
item?(props: { entry: MenuEntry; depth: number; active: boolean }): unknown
}>()
const isDivider = computed(() => props.entry.type === 'divider')
const children = computed(() => (isDivider.value ? [] : (props.entry.items ?? [])))
const hasChildren = computed(() => children.value.length > 0)
const href = computed(() => entryHref(props.entry))
const kind = computed(() => linkKind(props.entry))
const active = computed(() => isActive(props.entry, props.routePath, props.host))
const trail = computed(() => !active.value && inTrail(props.entry, props.routePath, props.host))
const target = computed(() => (props.entry.target === '_blank' ? '_blank' : undefined))
const rel = computed(() => linkRel(props.entry.target))
const submenuId = computed(() => `${props.idBase}-sub`)
const linkClass = computed(() => ['menu-nav__link', props.entry.css_class, { 'menu-nav__link--active': active.value }])
const openChild = ref<number | null>(null)
const itemEl = ref<HTMLElement | null>(null)
const toggleEl = ref<HTMLButtonElement | null>(null)
// A closed submenu closes its own open submenus too.
watch(
() => props.open,
(open) => {
if (!open) openChild.value = null
},
)
// Hover opens a submenu only for hover-capable pointers on the desktop
// layout; a click on the toggle then pins it open.
const HOVER_QUERY = '(hover: hover) and (min-width: 768px)'
let hoverOpened = false
function canHover(): boolean {
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(HOVER_QUERY).matches
}
function setOpen(value: boolean): void {
if (!value) hoverOpened = false
emit('update:open', value)
}
function toggle(): void {
if (hoverOpened) {
hoverOpened = false
return
}
setOpen(!props.open)
}
function onEnter(): void {
if (hasChildren.value && !props.open && canHover()) {
hoverOpened = true
emit('update:open', true)
}
}
function onLeave(): void {
if (hoverOpened) setOpen(false)
}
function onEscape(event: KeyboardEvent): void {
if (!props.open || !hasChildren.value) return
event.stopPropagation()
setOpen(false)
toggleEl.value?.focus()
}
function onFocusOut(event: FocusEvent): void {
if (!props.open) return
const next = event.relatedTarget as Node | null
if (!next || !itemEl.value?.contains(next)) setOpen(false)
}
function setChild(index: number, value: boolean): void {
if (value) openChild.value = index
else if (openChild.value === index) openChild.value = null
}
</script>
<template>
<li v-if="isDivider" class="menu-nav__divider" aria-hidden="true" />
<li
v-else
ref="itemEl"
class="menu-nav__item"
:class="{
'menu-nav__item--parent': hasChildren,
'menu-nav__item--open': hasChildren && open,
'menu-nav__item--trail': trail,
}"
:data-depth="depth"
@mouseenter="onEnter"
@mouseleave="onLeave"
@focusout="onFocusOut"
@keydown.esc="onEscape"
>
<button
v-if="hasChildren && href === null"
ref="toggleEl"
type="button"
class="menu-nav__link menu-nav__subtoggle"
:class="entry.css_class"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="submenuId"
@click="toggle"
>
<span class="menu-nav__label"><slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot></span>
<span class="menu-nav__chevron" aria-hidden="true" />
</button>
<NuxtLink
v-else-if="kind === 'router'"
:to="href!"
:class="linkClass"
:target="target"
:rel="rel"
:aria-current="active ? 'page' : undefined"
>
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
</NuxtLink>
<a
v-else-if="kind === 'anchor'"
:href="href!"
:class="linkClass"
:target="target"
:rel="rel"
:aria-current="active ? 'page' : undefined"
>
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
</a>
<span v-else class="menu-nav__text" :class="entry.css_class">
<slot name="item" :entry="entry" :depth="depth" :active="active">{{ entry.title }}</slot>
</span>
<button
v-if="hasChildren && href !== null"
ref="toggleEl"
type="button"
class="menu-nav__subtoggle"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="submenuId"
@click="toggle"
>
<span class="menu-nav__chevron" aria-hidden="true" />
<span class="menu-nav__sr">{{ fillTitle(submenuLabel, entry.title) }}</span>
</button>
<ul v-if="hasChildren" :id="submenuId" class="menu-nav__submenu">
<MenuNavItem
v-for="(child, i) in children"
:key="child.id ?? `generated-${i}`"
:entry="child"
:depth="depth + 1"
:id-base="`${idBase}-${i}`"
:open="openChild === i"
:route-path="routePath"
:host="host"
:submenu-label="submenuLabel"
@update:open="(value: boolean) => setChild(i, value)"
>
<template v-if="$slots.item" #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
</MenuNavItem>
</ul>
</li>
</template>