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:
125
app/components/Menu/Nav.vue
Normal file
125
app/components/Menu/Nav.vue
Normal 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>
|
||||
189
app/components/Menu/NavItem.vue
Normal file
189
app/components/Menu/NavItem.vue
Normal 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>
|
||||
Reference in New Issue
Block a user