/** * useVueFiches — B11-M1. L'état de la vue fiches, branché sur l'URL. * * L'URL est la source de vérité (SPEC-vue-fiches §5) : l'état se LIT dans * `route.query` (surveillée : un lien interne vers `?vue=fiches` depuis la même * page change l'écran) et s'ÉCRIT par `router.replace` (pas d'entrée * d'historique par clic de filtre). Seule la recherche a un tampon local, pour * ne pas perdre de lettres pendant la frappe : elle part dans l'URL 250 ms * après la dernière touche. * * La page crée l'état (elle en a besoin pour sa carte) et le passe tel quel à * ``. Logique pure et tests : `utils/vueFiches.ts`, * `scripts/test-vue-fiches.mjs`. */ import type { MaybeRefOrGetter } from 'vue' import type { MiniFiche } from '~/composables/useFicheAdapter' import { lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes, triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs, lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe, type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches, } from '~/utils/vueFiches' /** Largeur d'écran ; null avant le montage (SSR) : la CSS tranche alors seule. */ export type Largeur = 'telephone' | 'tablette' | 'ordinateur' | null const CLE_REPLI = 'aep_vue_fiches_liste_repliee' export function useVueFiches( fiches: MaybeRefOrGetter, config: MaybeRefOrGetter, ) { const route = useRoute() const router = useRouter() const cfg = computed(() => toValue(config)) // ── Lecture ── const brut = computed(() => lireQuery(route.query, cfg.value)) const q = ref(brut.value.q) let qEcrit = q.value let minuteurQ: ReturnType | null = null watch(() => brut.value.q, (v) => { if (v !== qEcrit) { q.value = v; qEcrit = v } }) const etat = computed(() => ({ ...brut.value, q: q.value, filtres: filtresValides(brut.value.filtres, cfg.value), })) // ── Écriture ── // La navigation est asynchrone : une query écrite n'est dans `route.query` // qu'au tick suivant. Deux écritures du même tick (« Voir sur la carte » : // Mixte, puis la sous-vue de la page) partent donc de la dernière query // écrite, sinon la seconde efface la première. let enAttente: Record | null = null watch(() => route.query, () => { enAttente = null }) const queryBase = () => enAttente ?? route.query function naviguer(query: Record, mode: 'push' | 'replace' = 'replace') { enAttente = query if (mode === 'push') router.push({ query }) else router.replace({ query }) } function etatDepuis(base: Record): EtatFiches { const b = lireQuery(base, cfg.value) return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) } } function ecrire(partiel: Partial) { const base = queryBase() const nouvel = { ...etatDepuis(base), ...partiel } qEcrit = nouvel.q if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null } naviguer(ecrireQuery(nouvel, cfg.value, base)) } function setQ(v: string) { q.value = v if (minuteurQ) clearTimeout(minuteurQ) minuteurQ = setTimeout(() => { minuteurQ = null; ecrire({ q: v }) }, 250) } function avecFiltre(dim: string, valeurs: string[]) { return { ...etat.value.filtres, [dim]: valeurs } } function toggle(dim: string, valeur: string) { const vals = etat.value.filtres[dim] ?? [] ecrire({ filtres: avecFiltre(dim, vals.includes(valeur) ? vals.filter((v) => v !== valeur) : [...vals, valeur]) }) } function ajouterFiltre(dim: string, valeur: string) { const vals = etat.value.filtres[dim] ?? [] if (!vals.includes(valeur)) ecrire({ filtres: avecFiltre(dim, [...vals, valeur]) }) } function effacerTout() { q.value = '' const vides: Record = {} for (const d of cfg.value.dimensions) vides[d.cle] = [] ecrire({ q: '', filtres: vides, sansAdresse: false }) } const setTri = (tri: TriFiches) => ecrire({ tri }) const setGroupe = (groupe: string | null) => ecrire({ groupe }) const setSousVue = (sousVue: string) => ecrire({ sousVue }) const setSansAdresse = (sansAdresse: boolean) => ecrire({ sansAdresse }) // ── Largeur et état affiché ── const largeur = ref(null) const listeRepliee = ref(false) onMounted(() => { const mq768 = window.matchMedia('(min-width: 768px)') const mq1024 = window.matchMedia('(min-width: 1024px)') const maj = () => { largeur.value = mq1024.matches ? 'ordinateur' : mq768.matches ? 'tablette' : 'telephone' } maj() mq768.addEventListener('change', maj) mq1024.addEventListener('change', maj) onBeforeUnmount(() => { mq768.removeEventListener('change', maj) mq1024.removeEventListener('change', maj) }) try { listeRepliee.value = sessionStorage.getItem(CLE_REPLI) === '1' } catch {} }) /** * État rendu. `auto` avant le montage quand l'URL ne tranche pas (ou dit * `mixte`) : la CSS rend Mixte à partir de 1024 px et Fiches en dessous, sans * saut à l'hydratation. Mixte n'existe pas sous 1024 px : il se lit Fiches. */ const vueAffichee = computed(() => { const etats = cfg.value.etats const v = etat.value.vue // `auto` rend Mixte à partir de 1024 px : seulement pour une page qui a cet état if (largeur.value === null) return v && v !== 'mixte' ? v : etats.includes('mixte') ? 'auto' : 'fiches' const ordinateur = largeur.value === 'ordinateur' if (v === 'mixte' || v === null) { if (!ordinateur || !etats.includes('mixte')) return 'fiches' if (v === null && listeRepliee.value) return 'carte' return 'mixte' } return v }) function setVue(v: EtatVue) { if (largeur.value === 'ordinateur' && (v === 'mixte' || v === 'carte')) { listeRepliee.value = v === 'carte' try { sessionStorage.setItem(CLE_REPLI, listeRepliee.value ? '1' : '0') } catch {} } ecrire({ vue: v }) } // ── Résultat ── const sansAdresseIgnore = computed(() => ({ ...etat.value, sansAdresse: false })) const filtrees = computed(() => filtrerFiches(toValue(fiches), etat.value, cfg.value)) const tri = computed(() => triEffectif(etat.value, cfg.value)) const resultat = computed(() => trierFiches(filtrees.value, tri.value, etat.value)) const groupeDef = computed(() => cfg.value.groupes.find((g) => g.cle === etat.value.groupe) ?? null) const groupes = computed(() => regrouper(resultat.value, groupeDef.value, cfg.value)) const ordre = computed(() => ordreFiches(groupes.value)) const compteurs = computed(() => compterEtiquettes(toValue(fiches), etat.value, cfg.value)) const idsResultat = computed(() => new Set(resultat.value.map((f) => String(f.id)))) const nbSansAdresse = computed(() => filtrerFiches(toValue(fiches), sansAdresseIgnore.value, cfg.value).filter((f) => !f.geoloc).length, ) const total = computed(() => toValue(fiches).length) const nbActifs = computed(() => nbFiltresActifs(etat.value)) function voirSansAdresse() { ecrire({ sansAdresse: true, vue: 'fiches' }) } /** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */ const survolId = ref(null) // ── Fiche ouverte (B12) : `fiche=` dans l'URL, lu aussi au rendu serveur ── const ficheId = computed(() => lireFiche(route.query)) /** La fiche ouverte, cherchée dans TOUTES les fiches (elle peut être hors filtres). null = pas encore chargée ou inconnue. */ const ficheOuverte = computed(() => { const id = ficheId.value return id == null ? null : toValue(fiches).find((f) => String(f.id) === id) ?? null }) /** Rang dans l'ordre courant ; null = hors de vos filtres (ou pas de fiche). */ const position = computed(() => positionFiche(ordre.value, ficheId.value)) /** * Empreinte de la liste au moment où CETTE page a ajouté l'entrée * d'historique de la fiche ; null = fiche venue d'un lien partagé. */ let listeALOuverture: string | null = null function ouvrirFiche(id: string | number) { if (String(id) === lireFiche(queryBase())) return const base = queryBase() const mode = modeOuverture(lireFiche(base)) if (mode === 'push') listeALOuverture = empreinteListe(base) naviguer(queryAvecFiche(base, id), mode) } function fermerFiche() { if (lireFiche(queryBase()) == null) return const mode = modeFermeture(listeALOuverture != null, listeALOuverture === empreinteListe(queryBase())) listeALOuverture = null if (mode === 'back') { enAttente = null; router.back() } else naviguer(queryAvecFiche(queryBase(), null)) } /** Étiquette cliquée dans le détail : ajoute le filtre ; `fermer` (téléphone) referme la fiche dans le même geste. */ function filtrerDepuisFiche(dim: string, valeur: string, fermer: boolean) { const base = queryBase() const e = etatDepuis(base) const vals = e.filtres[dim] ?? [] const filtres = vals.includes(valeur) ? e.filtres : { ...e.filtres, [dim]: [...vals, valeur] } const query = ecrireQuery({ ...e, filtres }, cfg.value, base) if (fermer) listeALOuverture = null naviguer(fermer ? queryAvecFiche(query, null) : query) } // Retour arrière du navigateur (ou lien interne) : l'URL a perdu `fiche=`. watch(ficheId, (id) => { if (id == null) listeALOuverture = null }) const fichePrecedente = () => { if (position.value?.precedente) ouvrirFiche(position.value.precedente.id) } const ficheSuivante = () => { if (position.value?.suivante) ouvrirFiche(position.value.suivante.id) } /** * « Voir sur la carte » : ordinateur → Mixte, la fiche reste ouverte ; * téléphone et tablette (pas de Mixte) → Carte, la fiche se ferme (elle * couvrirait la carte). La page lit `centrage` et centre son épingle. */ const centrage = ref<{ id: string; n: number } | null>(null) function voirSurCarte(id: string | number) { centrage.value = { id: String(id), n: (centrage.value?.n ?? 0) + 1 } if (largeur.value === 'ordinateur' && cfg.value.etats.includes('mixte')) { setVue('mixte') return } listeALOuverture = null const base = queryBase() naviguer(queryAvecFiche(ecrireQuery({ ...etatDepuis(base), vue: 'carte' }, cfg.value, base), null)) } return reactive({ cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs, idsResultat, nbSansAdresse, total, nbActifs, survolId, ficheId, ficheOuverte, position, centrage, setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue, setSansAdresse, voirSansAdresse, ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche, }) } export type VueFichesEtat = ReturnType