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:
co-authored by
Claude Opus 5.5
parent
fe1043e321
commit
1bb55538a4
+322
-7
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user