Files

253 lines
11 KiB
TypeScript

/**
* 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 à
* `<VueFiches :vf="vf">`. 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<MiniFiche[]>,
config: MaybeRefOrGetter<ConfigVueFiches>,
) {
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<typeof setTimeout> | null = null
watch(() => brut.value.q, (v) => {
if (v !== qEcrit) { q.value = v; qEcrit = v }
})
const etat = computed<EtatFiches>(() => ({
...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<string, string> | null = null
watch(() => route.query, () => { enAttente = null })
const queryBase = () => enAttente ?? route.query
function naviguer(query: Record<string, string>, mode: 'push' | 'replace' = 'replace') {
enAttente = query
if (mode === 'push') router.push({ query })
else router.replace({ query })
}
function etatDepuis(base: Record<string, unknown>): EtatFiches {
const b = lireQuery(base, cfg.value)
return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) }
}
function ecrire(partiel: Partial<EtatFiches>) {
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<string, string[]> = {}
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<Largeur>(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<EtatVue | 'auto'>(() => {
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<string | number | null>(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<MiniFiche | null>(() => {
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<typeof useVueFiches>