feat(vue-fiches): panneau de détail à droite, plein écran au téléphone ; clavier, balayage, copier le lien (B12-M1/M2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
This commit is contained in:
Jules Neny
2026-10-03 08:57:16 +02:00
co-authored by Claude Opus 5.5
parent fe1043e321
commit 1bb55538a4
+322 -7
View File
@@ -11,9 +11,9 @@
Contrat complet : DESIGN-SYSTEM.md §Vue fiches.
-->
<template>
<div class="vf" :class="`vf--${vf.vueAffichee}`">
<div class="vf" :class="[`vf--${vf.vueAffichee}`, { 'vf--detail': vf.ficheId != null }]">
<!-- ═══════════ Barre d'outils -->
<div class="vf-barre">
<div class="vf-barre" :inert="detailCouvrant || undefined">
<div class="vf-seg" role="group" aria-label="Affichage">
<button
v-for="e in etatsBoutons"
@@ -100,7 +100,7 @@
</div>
<!-- Téléphone, état Fiches : les pastilles actives sous la barre (les chips sont dans la feuille) -->
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel">
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel" :inert="detailCouvrant || undefined">
<Chip
v-for="p in pastilles"
:key="p.cle"
@@ -116,9 +116,11 @@
<!-- ═══════════ Corps : liste | carte -->
<div class="vf-corps">
<section
ref="liste"
class="vf-liste"
:aria-label="`Liste des fiches`"
:aria-hidden="vf.vueAffichee === 'carte' ? 'true' : undefined"
:aria-hidden="vf.vueAffichee === 'carte' || detailCouvrant ? 'true' : undefined"
:inert="detailCouvrant || undefined"
>
<p v-if="intention" class="vf-intention">{{ intention }}</p>
@@ -154,11 +156,12 @@
<FicheMiniCard
v-for="f in g.fiches"
:key="`${f.kind}-${f.id}`"
:data-fiche-id="String(f.id)"
:fiche="f"
etiquettes-cliquables
:etiquettes-actives="libellesActifs"
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === String(selectedId ?? '')"
@open="emit('select', f)"
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === (vf.ficheId ?? String(selectedId ?? ''))"
@open="ouvrir(f)"
@etiquette="(l) => ajouterDepuisCarte(f, l)"
@mouseenter="survoler(f)"
@mouseleave="survoler(null)"
@@ -168,7 +171,7 @@
</template>
</section>
<div class="vf-carte">
<div class="vf-carte" :inert="detailCouvrant || undefined">
<button
type="button"
class="vf-poignee"
@@ -190,6 +193,71 @@
</button>
</div>
</div>
<!-- ═══════════ Détail (B12) : panneau à droite (≥ 768), plein écran au téléphone -->
<aside
v-if="vf.ficheId != null"
ref="detail"
class="vf-detail"
tabindex="-1"
:aria-label="vf.ficheOuverte ? `Fiche : ${vf.ficheOuverte.nom}` : 'Fiche'"
@keydown="clavierDetail"
@touchstart.passive="debutBalayage"
@touchend="finBalayage"
>
<div class="vf-detail__tete">
<button
type="button"
class="vf-detail__fermer"
:aria-label="`Fermer la fiche et revenir aux ${vf.resultat.length} fiches`"
@click="vf.fermerFiche()"
>
<span class="vf-detail__fermer-tel" aria-hidden="true">‹ {{ vf.resultat.length }} {{ vf.resultat.length > 1 ? 'fiches' : 'fiche' }}</span>
<span class="vf-detail__fermer-ordi" aria-hidden="true">✕</span>
</button>
<span class="vf-detail__espace" aria-hidden="true" />
<button
v-if="vf.ficheOuverte"
type="button"
class="vf-detail__partager"
aria-label="Copier le lien de cette fiche"
@click="copierLien"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
</button>
</div>
<!-- Fiche à fiche : en tête sur ordinateur, en pied au téléphone (même élément, ordre CSS) -->
<nav v-if="vf.position" class="vf-detail__nav" aria-label="Fiche à fiche">
<button type="button" class="vf-detail__pas" :disabled="!vf.position.precedente" @click="vf.fichePrecedente()">
<span aria-hidden="true">‹ </span>Précédente
</button>
<span class="vf-detail__rang" aria-live="polite">
<span class="vf-sr">Fiche </span>{{ vf.position.rang }}<span class="vf-sr"> sur </span><span aria-hidden="true"> / </span>{{ vf.position.total }}
</span>
<button type="button" class="vf-detail__pas" :disabled="!vf.position.suivante" @click="vf.ficheSuivante()">
Suivante<span aria-hidden="true"> ›</span>
</button>
</nav>
<div ref="corpsDetail" class="vf-detail__corps">
<p v-if="vf.ficheOuverte && !vf.position" class="vf-detail__hors">
Hors de vos filtres : cette fiche n'est pas dans la liste affichée.
</p>
<template v-if="vf.ficheOuverte">
<div class="vf-detail__actions">
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button>
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
<span class="vf-detail__statut" role="status">{{ statutLien }}</span>
</div>
<slot name="detail" :fiche="vf.ficheOuverte" :filtrer="filtrerDepuisDetail" />
<p class="vf-detail__aide">← → pour passer d'une fiche à l'autre · Échap pour fermer</p>
</template>
<p v-else class="vf-etat-vide">{{ pending ? 'Chargement de la fiche…' : 'Fiche introuvable : elle a peut-être été retirée.' }}</p>
</div>
</aside>
</div>
<!-- ═══════════ Feuille « Filtrer » (téléphone) -->
@@ -241,6 +309,8 @@ const props = defineProps<{
libelleRecherche: string
pending?: boolean
selectedId?: string | number | null
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
lienPage?: (f: MiniFiche) => string | null
}>()
const emit = defineEmits<{
@@ -336,6 +406,99 @@ function pieger(e: KeyboardEvent) {
}
// La feuille n'existe que sous 768 px : elle se ferme si l'écran s'élargit.
watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuverte.value = false })
// ═══════════ Détail et fiche à fiche (B12) ═══════════
const liste = ref<HTMLElement | null>(null)
const detail = ref<HTMLElement | null>(null)
const corpsDetail = ref<HTMLElement | null>(null)
/** Téléphone : le détail couvre la vue, le reste sort du clavier et des lecteurs d'écran. */
const detailCouvrant = computed(() => props.vf.ficheId != null && props.vf.largeur === 'telephone')
const lienPageFiche = computed(() => (props.vf.ficheOuverte && props.lienPage ? props.lienPage(props.vf.ficheOuverte) : null))
/** « Voir sur la carte » : la fiche a une épingle, et l'épingle est sur la carte (la carte ne montre que le résultat). */
const peutCentrer = computed(() => {
const f = props.vf.ficheOuverte
return !!f && f.geoloc && props.vf.cfg.etats.includes('carte') && props.vf.idsResultat.has(String(f.id))
})
function ouvrir(f: MiniFiche) {
props.vf.ouvrirFiche(f.id)
emit('select', f)
}
/** Étiquette cliquée dans le détail : filtre, et au téléphone referme la fiche (on veut voir la liste filtrée). */
function filtrerDepuisDetail(dim: string, valeur: string) {
props.vf.filtrerDepuisFiche(dim, valeur, props.vf.largeur === 'telephone')
}
// Ouverture : on retient la position de défilement de la liste et la carte-fiche d'origine.
let defilementListe = 0
let origineId: string | null = null
watch(() => props.vf.ficheId, (id, avant) => {
if (!import.meta.client) return
if (id != null && avant == null) {
defilementListe = liste.value?.scrollTop ?? 0
origineId = id
nextTick(() => detail.value?.focus({ preventScroll: true }))
} else if (id != null && avant != null) {
// Fiche suivante : le corps repart en haut ; si le bouton pressé est devenu inactif (bout de liste), le focus reste dans le détail.
nextTick(() => {
corpsDetail.value?.scrollTo({ top: 0 })
if (!detail.value?.contains(document.activeElement)) detail.value?.focus({ preventScroll: true })
})
} else if (id == null && avant != null) {
// Fermeture : même position de défilement, focus sur la carte-fiche de la dernière fiche lue (sinon celle d'origine).
nextTick(() => {
if (liste.value) liste.value.scrollTop = defilementListe
const cible = [avant, origineId].map((x) => x && liste.value?.querySelector<HTMLElement>(`[data-fiche-id="${CSS.escape(x)}"] .fiche-mini-card__ouvrir`)).find(Boolean)
if (cible) { cible.focus({ preventScroll: true }); cible.scrollIntoView({ block: 'nearest' }) }
origineId = null
})
}
})
function clavierDetail(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); props.vf.fermerFiche(); return }
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
const t = e.target as HTMLElement
if (t.closest('input, textarea, select, [contenteditable="true"]')) return
e.preventDefault()
if (e.key === 'ArrowLeft') props.vf.fichePrecedente()
else props.vf.ficheSuivante()
}
// Balayage horizontal, au téléphone, dans le détail seulement (la carte garde le doigt).
let toucheX = 0
let toucheY = 0
function debutBalayage(e: TouchEvent) {
toucheX = e.changedTouches[0].clientX
toucheY = e.changedTouches[0].clientY
}
function finBalayage(e: TouchEvent) {
if (props.vf.largeur !== 'telephone') return
const dx = e.changedTouches[0].clientX - toucheX
const dy = e.changedTouches[0].clientY - toucheY
if (Math.abs(dx) < 60 || Math.abs(dx) < 1.5 * Math.abs(dy)) return
if (dx < 0) props.vf.ficheSuivante()
else props.vf.fichePrecedente()
}
// « Copier le lien » : l'URL courante dit tout (filtres, tri, vue, fiche).
const statutLien = ref('')
let minuteurLien: ReturnType<typeof setTimeout> | null = null
async function copierLien() {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
statutLien.value = 'Lien copié.'
} catch {
statutLien.value = `Copie impossible ici : ${url}`
}
if (minuteurLien) clearTimeout(minuteurLien)
minuteurLien = setTimeout(() => { statutLien.value = '' }, 4000)
}
</script>
<style scoped>
@@ -811,4 +974,156 @@ watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuvert
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ═══════════ Détail (B12) : panneau de 380 px à droite, plein écran au téléphone */
.vf { position: relative; }
.vf-detail {
grid-row: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--surface);
color: var(--text);
border-left: 1px solid var(--border);
}
.vf-detail:focus { outline: none; }
.vf-detail:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.vf-detail__tete {
order: 0;
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--border);
}
.vf-detail__espace { flex: 1 1 0; }
.vf-detail__fermer,
.vf-detail__partager {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
padding: 0 var(--space-xs);
border: none;
border-radius: var(--radius-md);
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.vf-detail__fermer { order: 2; }
.vf-detail__fermer-tel { display: none; }
.vf-detail__fermer-ordi { font-size: 1.05rem; }
.vf-detail__partager { order: 1; }
.vf-detail__nav {
order: 1;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
padding: var(--space-2xs) var(--space-sm);
border-bottom: 1px solid var(--border);
}
.vf-detail__pas {
min-height: 40px;
padding: 0 var(--space-xs);
border: none;
border-radius: var(--radius-md);
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
cursor: pointer;
}
.vf-detail__pas:disabled { color: color-mix(in srgb, var(--text) 45%, transparent); cursor: default; }
.vf-detail__rang { font-size: var(--fs-sm); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.vf-detail__corps {
order: 2;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-sm) var(--space-md) var(--space-lg);
}
.vf-detail__hors {
margin: 0 0 var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-md);
background: var(--bg-alt);
font-size: var(--fs-sm);
}
.vf-detail__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs) var(--space-sm);
margin: 0 0 var(--space-sm);
font-size: var(--fs-sm);
}
.vf-detail__actions .vf-lien { min-height: 32px; display: inline-flex; align-items: center; }
.vf-detail__statut { flex-basis: 100%; font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); overflow-wrap: anywhere; }
.vf-detail__statut:empty { display: none; }
.vf-detail__aide {
margin: var(--space-md) 0 0;
font-size: var(--fs-xs, 0.75rem);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vf-detail__fermer:focus-visible,
.vf-detail__partager:focus-visible,
.vf-detail__pas:focus-visible,
.vf-detail__actions .vf-lien:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Tablette : une colonne de liste ou de carte, le détail à côté */
@media (min-width: 768px) {
.vf--detail .vf-corps { grid-template-columns: minmax(0, 1fr) 340px; }
.vf-detail { grid-column: 2; }
}
/* Ordinateur : troisième colonne de 380 px, quel que soit l'état ; la grille passe à 2 colonnes */
@media (min-width: 1024px) {
.vf-detail { grid-column: 3; }
.vf--detail .vf-corps { grid-template-columns: var(--vf-liste) minmax(0, 1fr) 380px; }
.vf--detail.vf--fiches .vf-corps { grid-template-columns: minmax(0, 1fr) 0 380px; }
.vf--detail.vf--carte .vf-corps { grid-template-columns: 0 minmax(0, 1fr) 380px; }
}
@media (min-width: 1280px) {
.vf--detail.vf--fiches .vf-grille { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Téléphone : plein écran par-dessus la vue (barre comprise), fiche à fiche en pied, à portée de pouce */
@media (max-width: 767.98px) {
/* le détail se place par rapport à .vf (barre comprise), pas au corps */
.vf--detail .vf-corps { position: static; }
.vf-detail {
position: absolute;
inset: 0;
z-index: 1100;
border-left: none;
}
.vf-detail__fermer { order: 0; padding: 0 var(--space-xs) 0 0; }
.vf-detail__fermer-tel { display: inline; }
.vf-detail__fermer-ordi { display: none; }
.vf-detail__partager { order: 2; }
.vf-detail__nav {
order: 3;
padding: var(--space-xs) var(--space-sm);
border-top: 1px solid var(--border);
border-bottom: none;
}
.vf-detail__aide { display: none; }
}
</style>