- 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
190 lines
5.9 KiB
Vue
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>
|