Files
vue-menu-layer/app/components/Menu/Nav.vue
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

126 lines
3.7 KiB
Vue

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