Files
Jules NenyandClaude Opus 5.5 79424d29cf feat(nav): barre par échelles S / M / XL, tiroir < 1280, fil d'échelle mobile
- NavEchelles : trois groupes (accroche pastille + titre, <ul> étiqueté,
  aria-current) ≥ 1280 ; groupe courant seul 768-1279 ; lu depuis utils/echelles.ts
- NavTiroir remplace le menu déroulant : tiroir à droite, voile, Échap, focus
  piégé et rendu, cibles 44 px ; bouton « Menu » masqué ≥ 1280
- FilEchelle < 768, dans le flux avant le conteneur de page
- Soutenir et À propos dans la barre ≥ 1280 ; Signaler quitte la barre
- barre de recherche d'en-tête retirée (/ garde sa recherche dans MobileSheet
  à 768 et 1000, vérifié au DOM)
- html lang="fr" (Lighthouse html-has-lang)
B10 M2-M4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:10 +02:00

314 lines
8.2 KiB
Vue

<!--
NavTiroir — B10-M3
Menu < 1280 px : tiroir pleine hauteur à droite, voile, Échap, focus piégé,
focus rendu à l'élément qui l'a ouvert. Les trois échelles dans l'ordre de la
barre (utils/echelles.ts), puis Manifeste, À propos, Signaler, et Soutenir.
Se ferme seul au changement de route et si la fenêtre passe à ≥ 1280 px.
-->
<template>
<Teleport to="body">
<Transition name="nav-tiroir">
<div v-if="open" class="nav-tiroir" @keydown="onKeydown">
<div class="nav-tiroir__voile" aria-hidden="true" @click="fermer" />
<div
ref="panneau"
class="nav-tiroir__panneau"
role="dialog"
aria-modal="true"
aria-labelledby="nav-tiroir-titre"
>
<div class="nav-tiroir__tete">
<h2 id="nav-tiroir-titre" class="nav-tiroir__titre">Menu</h2>
<button type="button" class="nav-tiroir__fermer" aria-label="Fermer le menu" @click="fermer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<nav class="nav-tiroir__corps" aria-label="Échelles et pages">
<p class="nav-tiroir__cadre">{{ ECHELLES_CADRE_COURT }}</p>
<div v-for="id in ORDRE_ECHELLES" :key="id" class="nav-tiroir__grp">
<span :id="`nav-tiroir-echelle-${id}`" class="nav-tiroir__kick">
<span class="nav-tiroir__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
</span>
<ul class="nav-tiroir__liste" :aria-labelledby="`nav-tiroir-echelle-${id}`">
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
<NuxtLink
:to="o.to"
class="nav-tiroir__lien"
:class="{ 'nav-tiroir__lien--actif': o.actif(route) }"
:aria-current="o.actif(route) ? 'page' : undefined"
@click="fermer"
>{{ o.libelle }}</NuxtLink>
</li>
</ul>
</div>
<hr class="nav-tiroir__sep" />
<ul class="nav-tiroir__liste">
<li v-for="p in PAGES" :key="p.to">
<NuxtLink
:to="p.to"
class="nav-tiroir__second"
:aria-current="route.path === p.to ? 'page' : undefined"
@click="fermer"
>{{ p.libelle }}</NuxtLink>
</li>
</ul>
<a :href="SOUTENIR_URL" target="_blank" rel="noopener noreferrer" class="nav-tiroir__soutenir">Soutenir</a>
</nav>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ECHELLES, ECHELLES_CADRE_COURT, ORDRE_ECHELLES } from '~/utils/echelles'
import { SOUTENIR_URL } from '~/utils/liens'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const PAGES = [
{ libelle: 'Manifeste', to: '/manifeste' },
{ libelle: 'À propos', to: '/a-propos' },
{ libelle: 'Signaler', to: '/signaler' },
]
const route = useRoute()
const panneau = ref<HTMLElement | null>(null)
let retourFocus: HTMLElement | null = null
function fermer() {
emit('close')
}
function focusables(): HTMLElement[] {
if (!panneau.value) return []
return [...panneau.value.querySelectorAll<HTMLElement>('a[href], button:not([disabled])')]
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
fermer()
return
}
if (e.key !== 'Tab') return
const els = focusables()
if (!els.length) return
const premier = els[0]
const dernier = els[els.length - 1]
if (e.shiftKey && document.activeElement === premier) {
e.preventDefault()
dernier.focus()
} else if (!e.shiftKey && document.activeElement === dernier) {
e.preventDefault()
premier.focus()
}
}
watch(() => props.open, async (ouvert) => {
if (ouvert) {
retourFocus = document.activeElement as HTMLElement | null
await nextTick()
focusables()[0]?.focus()
} else if (retourFocus) {
// Le bouton d'ouverture peut ne plus être visible (fenêtre élargie) : focus() est alors sans effet.
retourFocus.focus()
retourFocus = null
}
})
watch(() => route.fullPath, () => { if (props.open) fermer() })
let mq: MediaQueryList | null = null
function onLarge(e: MediaQueryListEvent) { if (e.matches && props.open) fermer() }
onMounted(() => {
mq = window.matchMedia('(min-width: 1280px)')
mq.addEventListener('change', onLarge)
})
onBeforeUnmount(() => mq?.removeEventListener('change', onLarge))
</script>
<style scoped>
.nav-tiroir {
position: fixed;
inset: 0;
z-index: 10000;
}
.nav-tiroir__voile {
position: absolute;
inset: 0;
background: rgba(var(--nav-primary-raw), 0.35);
}
.nav-tiroir__panneau {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-lg);
}
.nav-tiroir__tete {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm) var(--space-xs) var(--space-md);
border-bottom: 1px solid var(--border);
}
.nav-tiroir__titre {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
}
.nav-tiroir__fermer {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
cursor: pointer;
}
.nav-tiroir__corps {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-sm) var(--space-md) var(--space-lg);
}
.nav-tiroir__cadre {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__grp {
margin-top: var(--space-sm);
}
.nav-tiroir__kick {
display: block;
margin-bottom: var(--space-2xs);
font-size: 0.72rem;
font-weight: var(--fw-bold);
letter-spacing: 0.04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__pastille {
display: inline-block;
margin-right: 6px;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
}
.nav-tiroir__liste {
margin: 0;
padding: 0;
list-style: none;
}
.nav-tiroir__lien,
.nav-tiroir__second {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 var(--space-xs);
border-radius: var(--radius-md);
text-decoration: none;
}
.nav-tiroir__lien {
font-size: var(--fs-base);
color: var(--text);
}
.nav-tiroir__lien--actif {
background: var(--bg-alt);
font-weight: var(--fw-semibold);
}
.nav-tiroir__second {
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__lien:focus-visible,
.nav-tiroir__second:focus-visible,
.nav-tiroir__soutenir:focus-visible,
.nav-tiroir__fermer:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.nav-tiroir__sep {
margin: var(--space-sm) 0;
border: 0;
border-top: 1px solid var(--border);
}
.nav-tiroir__soutenir {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-sm);
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-decoration: none;
}
.nav-tiroir-enter-active,
.nav-tiroir-leave-active {
transition: opacity var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: transform var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-from,
.nav-tiroir-leave-to {
opacity: 0;
}
.nav-tiroir-enter-from .nav-tiroir__panneau,
.nav-tiroir-leave-to .nav-tiroir__panneau {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.nav-tiroir-enter-active,
.nav-tiroir-leave-active,
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: none;
}
}
</style>