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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7762b31f1d
commit
79424d29cf
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
|
||||
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL (B10 : échelles S / M / XL) -->
|
||||
<header
|
||||
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
|
||||
class="app-header flex items-center px-4 shrink-0 relative z-[9999] shadow-sm gap-3"
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<!-- Logo : texte complet sauf 768-1023 et 1280-1439 (place des onglets) -->
|
||||
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
||||
<div
|
||||
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
@@ -20,112 +20,38 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||
<NuxtLink
|
||||
to="/outils"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
||||
>
|
||||
Outils
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||
>
|
||||
Écosystème Entraide
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/agences"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||
>
|
||||
Réseaux AEP
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/trouver-du-taf"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
||||
>
|
||||
Jobs
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/codev"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
||||
>
|
||||
Codev
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||
>
|
||||
recherche-média
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
|
||||
<div class="hidden sm:flex lg:hidden flex-1 mx-2">
|
||||
<label class="flex items-center gap-2 w-full px-3 py-1.5 rounded-xl border" style="background: var(--nav-bg); border-color: var(--nav-bg-alt);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="headerSearch"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
class="flex-1 bg-transparent border-0 outline-none text-sm"
|
||||
style="color: var(--nav-text); font-family: var(--nav-font);"
|
||||
autocomplete="off"
|
||||
@input="onHeaderSearch"
|
||||
@keydown.enter="onHeaderSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="headerSearch"
|
||||
type="button"
|
||||
@click.stop="clearHeaderSearch"
|
||||
class="flex-shrink-0"
|
||||
style="color: var(--nav-text-muted);"
|
||||
aria-label="Effacer la recherche"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Échelles : trois groupes ≥ 1280, groupe courant 768-1279, rien < 768 (fil d'échelle) -->
|
||||
<NavEchelles />
|
||||
<div class="flex-1 md:hidden" aria-hidden="true" />
|
||||
|
||||
<!-- Actions droite -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<NuxtLink
|
||||
to="/manifeste"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
|
||||
class="header-lien hidden md:inline-flex"
|
||||
:aria-current="route.path === '/manifeste' ? 'page' : undefined"
|
||||
>
|
||||
Manifeste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/a-propos"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
style="color: var(--nav-text-muted);"
|
||||
class="header-lien header-xl"
|
||||
:aria-current="route.path === '/a-propos' ? 'page' : undefined"
|
||||
>
|
||||
À propos
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/signaler"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden lg:inline-flex items-center gap-1"
|
||||
style="color: var(--nav-text-muted);"
|
||||
<a
|
||||
:href="SOUTENIR_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="header-soutenir header-xl"
|
||||
>
|
||||
Signaler
|
||||
</NuxtLink>
|
||||
<!-- Proposer une ressource (5 onglets) -->
|
||||
Soutenir
|
||||
</a>
|
||||
<!-- Proposer une ressource -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
class="header-proposer hidden md:inline-flex"
|
||||
>
|
||||
+ Proposer
|
||||
</NuxtLink>
|
||||
@@ -153,68 +79,51 @@
|
||||
<!-- Mobile : proposer icône -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
class="sm:hidden p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
class="md:hidden p-2 rounded-lg"
|
||||
style="background: var(--accent); color: var(--on-accent);"
|
||||
title="Proposer une ressource"
|
||||
aria-label="Proposer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="hamburgerOpen = !hamburgerOpen"
|
||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
:aria-label="hamburgerOpen ? 'Fermer le menu' : 'Menu'"
|
||||
:aria-expanded="hamburgerOpen"
|
||||
>
|
||||
<svg v-if="!hamburgerOpen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" 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
|
||||
v-if="hamburgerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[210px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
|
||||
<NuxtLink to="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Menu (< 1280) : ouvre le tiroir ; le mot est écrit à partir de 768 -->
|
||||
<button
|
||||
type="button"
|
||||
class="header-menu"
|
||||
aria-label="Ouvrir le menu"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="menuOpen"
|
||||
@click="menuOpen = true"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<span class="header-menu__mot" aria-hidden="true">Menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fil d'échelle (< 768) : dans le flux de la colonne, hors du conteneur de page -->
|
||||
<FilEchelle />
|
||||
|
||||
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<div class="flex-1 min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
<NavTiroir :open="menuOpen" @close="menuOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
import { SOUTENIR_URL } from '~/utils/liens'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const hamburgerOpen = ref(false)
|
||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||
const menuOpen = ref(false)
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||
const isDark = ref(false)
|
||||
@@ -237,40 +146,14 @@ function toggleDark() {
|
||||
localStorage.setItem('aep_theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Barre de recherche header mobile ─────────────────────────────────────
|
||||
const headerSearch = ref((route.query.q as string) ?? '')
|
||||
|
||||
// Sync depuis URL quand la route change
|
||||
watch(() => route.query.q, (v) => {
|
||||
headerSearch.value = (v as string) ?? ''
|
||||
})
|
||||
|
||||
function onHeaderSearch() {
|
||||
const q = headerSearch.value.trim()
|
||||
if (route.path === '/') {
|
||||
router.replace({ query: q ? { ...route.query, q } : { ...route.query, q: undefined } })
|
||||
} else if (q) {
|
||||
router.push({ path: '/', query: { q } })
|
||||
}
|
||||
}
|
||||
|
||||
function clearHeaderSearch() {
|
||||
headerSearch.value = ''
|
||||
if (route.path === '/') {
|
||||
const q = { ...route.query }
|
||||
delete q.q
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fiche aléatoire ───────────────────────────────────────────────────────
|
||||
function goRandom() {
|
||||
router.push({ path: '/', query: { random: '1' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── En-tête ──────────────────────────────────────────────────────────── */
|
||||
.app-header {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
||||
.logo-text {
|
||||
line-height: 1.05;
|
||||
@@ -285,39 +168,96 @@ function goRandom() {
|
||||
@media (min-width: 1024px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
||||
}
|
||||
/* Le groupe courant (768-1023) et les trois groupes (1280-1439) prennent la place du texte */
|
||||
@media (min-width: 768px) and (max-width: 1023px), (min-width: 1280px) and (max-width: 1439px) {
|
||||
.logo-text { display: none; }
|
||||
}
|
||||
|
||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||
.nav-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
/* ── Liens et boutons de droite ───────────────────────────────────────── */
|
||||
.header-lien {
|
||||
align-items: center;
|
||||
padding: 6px 16px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
gap: 2px;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
.header-lien:hover,
|
||||
.header-lien[aria-current="page"] {
|
||||
color: var(--text);
|
||||
}
|
||||
.header-lien[aria-current="page"] {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: var(--nav-text);
|
||||
.header-soutenir {
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border: 1.5px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab--active {
|
||||
color: var(--nav-text);
|
||||
border-bottom-color: var(--nav-primary-solid);
|
||||
font-weight: 600;
|
||||
.header-proposer {
|
||||
align-items: center;
|
||||
padding: 6px 11px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-tab-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--nav-text-muted);
|
||||
opacity: 0.65;
|
||||
.header-soutenir:hover,
|
||||
.header-proposer:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-lien:focus-visible,
|
||||
.header-soutenir:focus-visible,
|
||||
.header-proposer:focus-visible,
|
||||
.header-menu:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* À propos et Soutenir : barre complète seulement (≥ 1280), sinon dans le tiroir */
|
||||
.header-xl { display: none; }
|
||||
@media (min-width: 1280px) {
|
||||
.header-xl { display: inline-flex; }
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.header-menu__mot { display: none; }
|
||||
@media (min-width: 768px) {
|
||||
.header-menu {
|
||||
padding: 5px 10px;
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
.header-menu__mot { display: inline; }
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.header-menu { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
FilEchelle — B10-M4
|
||||
< 768 px : une ligne de 28 px sous la barre, « S · Partir de soi · Outils · Jobs » :
|
||||
l'échelle courante, l'onglet courant, puis ses voisins du groupe (liens).
|
||||
Absente sur les pages hors groupe (Manifeste, À propos, Proposer, Signaler, fiche).
|
||||
Montée dans le flux de la colonne d'app.vue, AVANT le conteneur de page : la carte
|
||||
de / perd ces 28 px à vue, jamais en dessous de la ligne de flottaison.
|
||||
-->
|
||||
<template>
|
||||
<nav v-if="courant" class="fil-echelle" aria-label="Échelle courante">
|
||||
<span class="fil-echelle__pastille">{{ courant.echelle }}</span>
|
||||
<span class="fil-echelle__titre">{{ ECHELLES[courant.echelle].titre }}</span>
|
||||
<span class="fil-echelle__sep" aria-hidden="true">·</span>
|
||||
<span class="fil-echelle__courant" aria-current="page">{{ courant.onglet.libelle }}</span>
|
||||
<template v-for="o in voisins" :key="o.to">
|
||||
<span class="fil-echelle__sep" aria-hidden="true">·</span>
|
||||
<NuxtLink :to="o.to" class="fil-echelle__lien">{{ o.libelle }}</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, echelleDeRoute } from '~/utils/echelles'
|
||||
|
||||
const route = useRoute()
|
||||
const courant = computed(() => echelleDeRoute(route))
|
||||
const voisins = computed(() =>
|
||||
courant.value ? ECHELLES[courant.value.echelle].onglets.filter((o) => o !== courant.value!.onglet) : [],
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fil-echelle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-sm);
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--bg-alt);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.fil-echelle::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fil-echelle__pastille {
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.fil-echelle__courant {
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fil-echelle__lien {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.fil-echelle__lien:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fil-echelle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
NavEchelles — B10-M2
|
||||
Barre des trois échelles S / M / XL, lue depuis utils/echelles.ts (source unique).
|
||||
≥ 1280 px : les trois groupes ; 768-1279 px : le seul groupe de la page courante ;
|
||||
< 768 px : rien (FilEchelle + NavTiroir prennent le relais).
|
||||
Chaque groupe = <ul> étiqueté par son accroche (pastille + titre de l'échelle).
|
||||
-->
|
||||
<template>
|
||||
<nav class="nav-echelles" aria-label="Échelles">
|
||||
<div
|
||||
v-for="id in ORDRE_ECHELLES"
|
||||
:key="id"
|
||||
class="nav-echelles__grp"
|
||||
:class="{ 'nav-echelles__grp--courant': courant?.echelle === id }"
|
||||
>
|
||||
<span :id="`nav-echelle-${id}`" class="nav-echelles__kick">
|
||||
<span class="nav-echelles__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
|
||||
</span>
|
||||
<ul class="nav-echelles__tabs" :aria-labelledby="`nav-echelle-${id}`">
|
||||
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
|
||||
<NuxtLink
|
||||
:to="o.to"
|
||||
class="nav-echelles__tab"
|
||||
:class="{ 'nav-echelles__tab--actif': o.actif(route) }"
|
||||
:aria-current="o.actif(route) ? 'page' : undefined"
|
||||
>{{ o.libelle }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, ORDRE_ECHELLES, echelleDeRoute } from '~/utils/echelles'
|
||||
|
||||
const route = useRoute()
|
||||
const courant = computed(() => echelleDeRoute(route))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-echelles {
|
||||
display: none;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-echelles__grp {
|
||||
position: relative;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-echelles__kick {
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: 0.66rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.nav-echelles__grp--courant .nav-echelles__kick {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-echelles__pastille {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.nav-echelles__tabs {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-echelles__tab {
|
||||
display: inline-block;
|
||||
padding: 4px 9px 9px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--fw-medium);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.nav-echelles__tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-echelles__tab:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.nav-echelles__tab--actif {
|
||||
color: var(--text);
|
||||
font-weight: var(--fw-semibold);
|
||||
border-bottom-color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
/* 768-1279 : le seul groupe courant, aligné à gauche après le logo */
|
||||
@media (min-width: 768px) {
|
||||
.nav-echelles {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-echelles__grp--courant {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≥ 1280 : les trois groupes, centrés, séparés par un filet */
|
||||
@media (min-width: 1280px) {
|
||||
.nav-echelles {
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-echelles__grp {
|
||||
display: flex;
|
||||
}
|
||||
.nav-echelles__grp + .nav-echelles__grp::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(var(--space-lg) / -2);
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-echelles__tab { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -3,6 +3,7 @@ export default defineNuxtConfig({
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Lien de don (Liberapay), partagé par la barre, le tiroir et À propos. */
|
||||
export const SOUTENIR_URL = 'https://liberapay.com/trans-former.fr/donate'
|
||||
Reference in New Issue
Block a user