- 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
126 lines
3.7 KiB
Vue
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>
|