Files
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

190 lines
5.9 KiB
Vue

<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>