feat(vue-fiches): composant VueFiches (Carte · Mixte · Fiches, poignée, barre d'outils) et filtres par dimension (B11-M3, M4)

Filtres à un seul endroit par état, chips à 0 grisées, repli au-delà de 14,
feuille « Filtrer » sous 768 px, pastilles actives en état Carte.
FicheMiniCard : racine <article> + bouton étiré pour rendre les chips cliquables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
This commit is contained in:
Jules Neny
2026-10-02 17:57:27 +02:00
co-authored by Claude Opus 5.5
parent 38df1d5eb5
commit 5a4c7af79c
9 changed files with 1011 additions and 844 deletions
-40
View File
@@ -1,40 +0,0 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="option in ECHELLES"
:key="option"
:label="option"
variant="echelle"
:active="isSelected(option)"
@toggle="toggle(option)"
/>
</ChipGroup>
</div>
</template>
<script setup lang="ts">
const ECHELLES = ['National', 'Régional', 'Local'] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function isSelected(option: string): boolean {
return props.modelValue.includes(option)
}
function toggle(option: string) {
if (isSelected(option)) {
emit('update:modelValue', props.modelValue.filter(v => v !== option))
} else {
emit('update:modelValue', [...props.modelValue, option])
}
}
</script>
+81 -14
View File
@@ -1,12 +1,22 @@
<!--
FicheMiniCard — B3-M2, B11-M2
La carte entière ouvre la fiche (bouton étiré par ::after) ; avec
`etiquettesCliquables`, les chips deviennent des boutons qui émettent
`etiquette` (la vue fiches ajoute le filtre). Un bouton ne pouvant pas en
contenir d'autres, la racine est un <article>, plus un <button> : apparence
inchangée, page /dev/fiche-mini-card comprise.
-->
<template> <template>
<button <article class="fiche-mini-card" :class="{ 'fiche-mini-card--actif': actif }">
type="button" <button
class="fiche-mini-card" type="button"
:aria-label="fiche.nom" class="fiche-mini-card__ouvrir"
@click="emit('open')" :aria-label="fiche.nom"
> @click="emit('open')"
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span> >
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span> <span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
</button>
<ChipGroup <ChipGroup
v-if="fiche.chips.length || fiche.echelle" v-if="fiche.chips.length || fiche.echelle"
@@ -14,11 +24,24 @@
class="fiche-mini-card__chips" class="fiche-mini-card__chips"
> >
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" /> <Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" /> <template v-if="etiquettesCliquables">
<Chip
v-for="chip in fiche.chips"
:key="chip"
variant="fonction"
:label="chip"
:active="etiquettesActives?.includes(chip)"
:aria-label="`Filtrer par ${chip}`"
@toggle="emit('etiquette', chip)"
/>
</template>
<template v-else>
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
</template>
</ChipGroup> </ChipGroup>
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span> <span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
</button> </article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -26,30 +49,37 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
defineProps<{ defineProps<{
fiche: MiniFiche fiche: MiniFiche
etiquettesCliquables?: boolean
/** Libellés de chips déjà filtrés (affichés pressés). */
etiquettesActives?: string[]
/** Fiche survolée sur la carte ou sélectionnée. */
actif?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
open: [] open: []
etiquette: [label: string]
}>() }>()
</script> </script>
<style scoped> <style scoped>
.fiche-mini-card { .fiche-mini-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
min-width: 0;
padding: var(--space-sm); padding: var(--space-sm);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
text-align: left; text-align: left;
cursor: pointer;
font-family: inherit;
box-sizing: border-box; box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-out), transition: background-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out); box-shadow var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -63,11 +93,42 @@ const emit = defineEmits<{
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.fiche-mini-card:focus-visible { /* Survol d'une épingle ou fiche sélectionnée : liseré safran */
.fiche-mini-card--actif {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.fiche-mini-card:has(.fiche-mini-card__ouvrir:focus-visible) {
outline: 2px solid var(--focus-ring); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
/* Bouton étiré : toute la carte ouvre la fiche, les chips cliquables passent dessus */
.fiche-mini-card__ouvrir {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-width: 0;
padding: 0;
border: none;
background: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.fiche-mini-card__ouvrir::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
.fiche-mini-card__ouvrir:focus-visible {
outline: none;
}
.fiche-mini-card__nom { .fiche-mini-card__nom {
width: 100%; width: 100%;
font-size: var(--fs-sm); font-size: var(--fs-sm);
@@ -92,7 +153,13 @@ const emit = defineEmits<{
} }
.fiche-mini-card__chips { .fiche-mini-card__chips {
position: relative;
z-index: 1;
margin-top: var(--space-xs); margin-top: var(--space-xs);
pointer-events: none;
}
.fiche-mini-card__chips :deep(button) {
pointer-events: auto;
} }
.fiche-mini-card__no-geo { .fiche-mini-card__no-geo {
-168
View File
@@ -1,168 +0,0 @@
<!--
FichesPanel — B3-M3
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
expose ses propres chips de filtrage branchées sur le MÊME état que la
page appelante (pas un second état — voir vérification du brief).
Ne crée pas de second champ de recherche : `query` sert uniquement à
nuancer le message d'état vide.
-->
<template>
<div class="fiches-panel">
<div class="fiches-panel__head">
<p class="fiches-panel__intention">{{ intention }}</p>
<div class="fiches-panel__count">
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
</div>
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
<Chip
v-for="tag in visibleHashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selected.includes(tag)"
@toggle="toggle(tag)"
/>
<Chip
v-if="isCollapsible"
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
variant="echelle"
:active="expanded"
@toggle="expanded = !expanded"
/>
</ChipGroup>
</div>
<div class="fiches-panel__body">
<p v-if="fiches.length === 0" class="fiches-panel__empty">
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
</p>
<div v-else class="fiches-panel__grid">
<FicheMiniCard
v-for="fiche in fiches"
:key="`${fiche.kind}-${fiche.id}`"
:fiche="fiche"
@open="emit('open', fiche)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
const props = defineProps<{
fiches: MiniFiche[]
hashtags: string[]
selected: string[]
query: string
intention: string
}>()
const emit = defineEmits<{
'update:selected': [value: string[]]
open: [fiche: MiniFiche]
}>()
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
// déplié non persistant (ref locale, reset à chaque montage du composant).
const COLLAPSE_THRESHOLD = 14
const expanded = ref(false)
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
const visibleHashtags = computed(() => {
if (expanded.value || !isCollapsible.value) return props.hashtags
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
const headSet = new Set(head)
const activeOutsideHead = props.hashtags.filter(
(tag) => props.selected.includes(tag) && !headSet.has(tag)
)
return [...head, ...activeOutsideHead]
})
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
function toggle(tag: string) {
if (props.selected.includes(tag)) {
emit('update:selected', props.selected.filter((t) => t !== tag))
} else {
emit('update:selected', [...props.selected, tag])
}
}
</script>
<style scoped>
.fiches-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fiches-panel__head {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.fiches-panel__intention {
margin: 0 0 var(--space-xs);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: var(--text-muted);
}
.fiches-panel__count {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-subtle);
}
.fiches-panel__chips {
margin-top: var(--space-2xs);
}
.fiches-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
}
.fiches-panel__empty {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.fiches-panel__grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
-52
View File
@@ -1,52 +0,0 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
:label="fn"
variant="fonction"
:active="modelValue.includes(fn)"
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
@toggle="toggle(fn)"
/>
</ChipGroup>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
</p>
</div>
</template>
<script setup lang="ts">
const FONCTIONS = [
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
"Gestion d'agence",
'Santé mentale',
] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(fn: string) {
if (props.modelValue.includes(fn)) {
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
} else {
emit('update:modelValue', [...props.modelValue, fn])
}
}
</script>
-95
View File
@@ -1,95 +0,0 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
<span
v-for="fam in FAMILLES"
:key="fam.id"
class="chip"
:style="selectedFamille === fam.id
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
@click="toggleFamille(fam.id)"
>{{ fam.shortLabel }}</span>
</div>
</div>
<!-- Filtres hashtags avec toggle -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="filter-label">HASHTAGS</span>
<button
@click="hashtagsVisible = !hashtagsVisible"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<Chip
v-for="tag in props.allHashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selectedHashtags.includes(tag)"
@toggle="toggleHashtag(tag)"
/>
</ChipGroup>
</div>
</div>
</template>
<script setup lang="ts">
const FAMILLES = [
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
]
const props = defineProps<{
allHashtags: string[]
selectedHashtags: string[]
selectedFamille: number | null
}>()
const emit = defineEmits<{
'update:selectedHashtags': [v: string[]]
'update:selectedFamille': [v: number | null]
}>()
const hashtagsVisible = ref(false)
function toggleFamille(id: number) {
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
}
function toggleHashtag(tag: string) {
const current = props.selectedHashtags
emit('update:selectedHashtags',
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
)
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
</style>
-218
View File
@@ -1,218 +0,0 @@
<!--
MobileSheet — Bottom sheet swipable 3 états (collapsed / half / full)
Pattern Google Maps mobile
États :
- collapsed : juste la poignée + compteur (~56px)
- half : ~50dvh (état initial)
- full : ~92dvh (plein écran)
Déclenché par touch/drag sur la poignée
-->
<template>
<div
class="mobile-sheet"
:class="`mobile-sheet--${state}`"
:style="{ transform: `translateY(${dragOffset}px)` }"
>
<!-- Poignée drag -->
<div
class="sheet-handle-area"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet-handle-bar" />
</div>
<!-- Compteur compact (toujours visible) -->
<div class="sheet-header" @click="onHeaderClick">
<span class="sheet-counter">
<span v-if="pending">Chargement…</span>
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
class="sheet-chevron"
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
style="color: var(--nav-text-muted); flex-shrink: 0;"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
<!-- Contenu scrollable (caché si collapsed) -->
<div
v-show="state !== 'collapsed'"
ref="contentEl"
class="sheet-content"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
type SheetState = 'collapsed' | 'half' | 'full'
const props = defineProps<{
resultCount: number
pending?: boolean
}>()
const emit = defineEmits<{
'state-change': [state: SheetState]
}>()
const state = ref<SheetState>('half')
const dragOffset = ref(0)
const contentEl = ref<HTMLElement | null>(null)
let touchStartY = 0
let touchCurrentY = 0
let isDragging = false
// Cycle états au clic header
function onHeaderClick() {
if (state.value === 'collapsed') {
setSheetState('half')
} else if (state.value === 'half') {
setSheetState('full')
} else {
setSheetState('collapsed')
}
}
function setSheetState(next: SheetState) {
state.value = next
dragOffset.value = 0
emit('state-change', next)
}
// Touch handlers
function onTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
isDragging = true
dragOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return
touchCurrentY = e.touches[0].clientY
const delta = touchCurrentY - touchStartY
// Limiter le drag visuellement (résistance)
dragOffset.value = delta * 0.6
}
function onTouchEnd() {
if (!isDragging) return
isDragging = false
const delta = touchCurrentY - touchStartY
const threshold = 60 // px minimum pour changer d'état
if (delta > threshold) {
// Swipe vers le bas → état inférieur
if (state.value === 'full') setSheetState('half')
else if (state.value === 'half') setSheetState('collapsed')
else setSheetState('collapsed')
} else if (delta < -threshold) {
// Swipe vers le haut → état supérieur
if (state.value === 'collapsed') setSheetState('half')
else if (state.value === 'half') setSheetState('full')
else setSheetState('full')
} else {
// Snap back
dragOffset.value = 0
}
// Transition smooth au relâchement
dragOffset.value = 0
}
</script>
<style scoped>
.mobile-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 500;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
display: flex;
flex-direction: column;
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
will-change: height, transform;
overflow: hidden;
}
/* ── États hauteur ───────────────────────────────────────────────────── */
.mobile-sheet--collapsed {
height: 56px;
}
.mobile-sheet--half {
height: 50dvh;
min-height: 200px;
}
.mobile-sheet--full {
height: 92dvh;
}
/* ── Poignée ─────────────────────────────────────────────────────────── */
.sheet-handle-area {
display: flex;
justify-content: center;
padding: 10px 0 4px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle-area:active {
cursor: grabbing;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--nav-bg-alt);
}
/* ── Header compteur ─────────────────────────────────────────────────── */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
cursor: pointer;
flex-shrink: 0;
gap: 8px;
}
.sheet-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--nav-text-muted);
}
.sheet-chevron {
transition: transform 0.2s ease;
}
.sheet-chevron--up {
transform: rotate(180deg);
}
/* ── Contenu scrollable ──────────────────────────────────────────────── */
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
</style>
-257
View File
@@ -1,257 +0,0 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE (tout en haut) -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une organisation">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une organisation…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<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>
<!-- ═══════════════════════════════════ FILTRES (haut, compact) -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b"
style="border-color: var(--nav-bg-alt);"
>
<!-- Échelle (checkbox compactes) -->
<EchelleFilter
:modelValue="echelle"
:counts="echelleCount"
@update:modelValue="emit('update:echelle', $event)"
/>
<!-- Fonctions (checkbox compactes) -->
<FonctionFilter
:modelValue="fonctions"
:counts="fonctionCount"
@update:modelValue="emit('update:fonctions', $event)"
/>
</div>
<!-- ═══════════════════════════════════ LISTE FICHES (milieu, scrollable) -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Header liste -->
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<!-- Liste scrollable -->
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement…
</div>
<div v-else-if="orgs.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<!-- Card fiche compacte -->
<div
v-for="org in orgs"
:key="org.Id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-org', org.Id)"
@mouseenter="emit('hover-org', org.Id)"
@mouseleave="emit('hover-org', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span
class="font-semibold text-sm leading-snug"
style="color: var(--nav-text);"
>{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ org.echelle }}</span>
</div>
<div v-if="orgFonctions(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in orgFonctions(org)"
:key="fn"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
interface Org {
Id: number
nom: string
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
}
const props = defineProps<{
search: string
modeValue: string // 'metropole' | 'outremer'
echelle: string[]
fonctions: string[]
territoire: string | null
echelleCount: Record<string, number>
fonctionCount: Record<string, number>
territoireCount: Record<string, number>
resultCount: number
orgs: Org[] // fiches filtrées à afficher dans la liste
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:mode': [value: string]
'update:echelle': [value: string[]]
'update:fonctions': [value: string[]]
'update:territoire': [value: string | null]
'select-org': [id: number]
'hover-org': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map(f => f.trim()).filter(Boolean).slice(0, 2)
}
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>
+812
View File
@@ -0,0 +1,812 @@
<!--
VueFiches — B11-M3/M4. La vue fiches partagée (SPEC-vue-fiches) : barre
d'outils, trois états Carte / Mixte / Fiches, filtres à un seul endroit,
grille, poignée de repli. Elle ne charge pas les données et ne connaît pas la
carte : la page crée l'état (`useVueFiches`) et fournit le slot #carte.
États (classe racine) : carte · mixte · fiches · auto. `auto` = avant le
montage, quand l'URL ne tranche pas : la CSS rend Mixte à partir de 1024 px
et Fiches en dessous, pour qu'un lien s'affiche sans saut à l'hydratation.
Contrat complet : DESIGN-SYSTEM.md §Vue fiches.
-->
<template>
<div class="vf" :class="`vf--${vf.vueAffichee}`">
<!-- ═══════════ Barre d'outils -->
<div class="vf-barre">
<div class="vf-seg" role="group" aria-label="Affichage">
<button
v-for="e in etatsBoutons"
:key="e"
type="button"
class="vf-seg__btn"
:class="`vf-seg__btn--${e}`"
:aria-pressed="vf.vueAffichee === e"
@click="vf.setVue(e)"
>{{ LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
</div>
<label class="vf-recherche">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span class="vf-sr">{{ libelleRecherche }}</span>
<input
type="search"
:value="vf.q"
:placeholder="libelleRecherche"
autocomplete="off"
@input="vf.setQ(($event.target as HTMLInputElement).value)"
/>
<button v-if="vf.q" type="button" class="vf-recherche__effacer" aria-label="Effacer la recherche" @click="vf.setQ('')">
<svg width="13" height="13" 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>
</label>
<button
ref="boutonFiltrer"
type="button"
class="vf-filtrer"
aria-haspopup="dialog"
:aria-expanded="feuilleOuverte"
@click="ouvrirFeuille"
>Filtrer<template v-if="vf.nbActifs"> · {{ vf.nbActifs }}</template></button>
<!-- État Carte : seules les pastilles actives restent visibles -->
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--barre">
<Chip
v-for="p in pastilles"
:key="p.cle"
as="span"
removable
active
:variant="p.variante"
:label="p.libelle"
@remove="p.retirer()"
/>
</div>
<span class="vf-espace" aria-hidden="true" />
<p class="vf-compteur" aria-live="polite">
{{ vf.resultat.length }} {{ vf.resultat.length > 1 ? 'fiches' : 'fiche' }} sur {{ vf.total }}
<template v-if="vf.nbSansAdresse && !vf.etat.sansAdresse">
<span class="vf-compteur__sep" aria-hidden="true">·</span>
<button type="button" class="vf-lien" @click="vf.voirSansAdresse()">{{ vf.nbSansAdresse }} sans adresse</button>
</template>
</p>
<label class="vf-select vf-select--tri">
<span>Tri</span>
<select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)">
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ LIBELLES_TRI[t] }}</option>
</select>
</label>
<label v-if="vf.cfg.groupes.length" class="vf-select vf-select--groupe">
<span>Regrouper</span>
<select :value="vf.etat.groupe ?? ''" @change="choisirGroupe">
<option value="" :selected="!vf.etat.groupe">aucun</option>
<option v-for="g in vf.cfg.groupes" :key="g.cle" :value="g.cle" :selected="g.cle === vf.etat.groupe">{{ g.libelle.toLowerCase() }}</option>
</select>
</label>
<label v-if="vf.cfg.sousVues" class="vf-select vf-select--carte">
<span class="vf-sr">Carte affichée</span>
<select :value="vf.etat.sousVue" @change="vf.setSousVue(($event.target as HTMLSelectElement).value)">
<option v-for="s in vf.cfg.sousVues.valeurs" :key="s.valeur" :value="s.valeur" :selected="s.valeur === vf.etat.sousVue">{{ s.libelle }}</option>
</select>
</label>
</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">
<Chip
v-for="p in pastilles"
:key="p.cle"
as="span"
removable
active
:variant="p.variante"
:label="p.libelle"
@remove="p.retirer()"
/>
</div>
<!-- ═══════════ Corps : liste | carte -->
<div class="vf-corps">
<section
class="vf-liste"
:aria-label="`Liste des fiches`"
:aria-hidden="vf.vueAffichee === 'carte' ? 'true' : undefined"
>
<p v-if="intention" class="vf-intention">{{ intention }}</p>
<VueFichesFiltres :vf="vf" id-base="vf-panneau" class="vf-filtres" />
<p v-if="pending" class="vf-etat-vide">Chargement des fiches…</p>
<div v-else-if="!vf.resultat.length" class="vf-etat-vide">
<slot name="vide">
<p>Aucune fiche ne correspond<template v-if="vf.q"> à « {{ vf.q }} »</template>.</p>
<button v-if="vf.nbActifs || vf.q" type="button" class="vf-lien" @click="vf.effacerTout()">Tout effacer</button>
</slot>
</div>
<template v-else>
<section
v-for="g in vf.groupes"
:key="g.cle"
class="vf-groupe"
:aria-labelledby="g.libelle ? `vf-g-${idGroupe(g.cle)}` : undefined"
>
<h3 v-if="g.libelle" :id="`vf-g-${idGroupe(g.cle)}`" class="vf-groupe__titre">
<button
type="button"
class="vf-groupe__bascule"
:aria-expanded="!replies.has(g.cle)"
@click="basculerGroupe(g.cle)"
>
<span aria-hidden="true" class="vf-groupe__chevron">{{ replies.has(g.cle) ? '›' : '⌄' }}</span>
{{ g.libelle }} · {{ g.fiches.length }}
</button>
</h3>
<div v-show="!replies.has(g.cle)" class="vf-grille">
<FicheMiniCard
v-for="f in g.fiches"
:key="`${f.kind}-${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)"
@etiquette="(l) => ajouterDepuisCarte(f, l)"
@mouseenter="survoler(f)"
@mouseleave="survoler(null)"
/>
</div>
</section>
</template>
</section>
<div class="vf-carte">
<button
type="button"
class="vf-poignee"
:aria-label="vf.vueAffichee === 'carte' ? 'Afficher la liste des fiches' : 'Replier la liste des fiches'"
:aria-expanded="vf.vueAffichee !== 'carte'"
@click="vf.setVue(vf.vueAffichee === 'carte' ? 'mixte' : 'carte')"
><span aria-hidden="true">{{ vf.vueAffichee === 'carte' ? '›' : '‹' }}</span></button>
<slot name="carte" />
<!-- Téléphone, état Carte : pied « filtres actifs ✕ » + « Voir les N fiches › » -->
<div class="vf-pied">
<button v-if="vf.nbActifs || vf.q" type="button" class="vf-pied__filtres" @click="vf.effacerTout()">
<span class="vf-pied__resume">{{ resumeFiltres }}</span>
<span aria-hidden="true"> ✕</span><span class="vf-sr"> (tout effacer)</span>
</button>
<button type="button" class="vf-pied__voir" @click="vf.setVue('fiches')">
Voir {{ vf.resultat.length > 1 ? `les ${vf.resultat.length} fiches` : `${vf.resultat.length} fiche` }} ›
</button>
</div>
</div>
</div>
<!-- ═══════════ Feuille « Filtrer » (téléphone) -->
<Teleport to="body">
<div v-if="feuilleOuverte" class="vf-voile" @click="fermerFeuille" />
<div
v-if="feuilleOuverte"
ref="feuille"
class="vf-feuille"
role="dialog"
aria-modal="true"
aria-labelledby="vf-feuille-titre"
@keydown.esc="fermerFeuille"
@keydown.tab="pieger"
>
<div class="vf-feuille__tete">
<h2 id="vf-feuille-titre">Filtrer</h2>
<button ref="boutonFermer" type="button" class="vf-feuille__fermer" aria-label="Fermer les filtres" @click="fermerFeuille">✕</button>
</div>
<div class="vf-feuille__corps">
<VueFichesFiltres :vf="vf" id-base="vf-feuille" />
<label v-if="vf.cfg.groupes.length" class="vf-select vf-select--feuille">
<span>Regrouper</span>
<select :value="vf.etat.groupe ?? ''" @change="choisirGroupe">
<option value="" :selected="!vf.etat.groupe">aucun</option>
<option v-for="g in vf.cfg.groupes" :key="g.cle" :value="g.cle" :selected="g.cle === vf.etat.groupe">{{ g.libelle.toLowerCase() }}</option>
</select>
</label>
</div>
<div class="vf-feuille__pied">
<button type="button" class="vf-feuille__voir" @click="fermerFeuille">
Voir {{ vf.resultat.length > 1 ? `${vf.resultat.length} fiches` : `${vf.resultat.length} fiche` }}
</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
import type { VueFichesEtat } from '~/composables/useVueFiches'
import { LIBELLES_TRI, type EtatVue, type TriFiches } from '~/utils/vueFiches'
const props = defineProps<{
vf: VueFichesEtat
/** Une ligne, « intention d'abord » (DESIGN-SYSTEM §Intention d'abord). */
intention?: string
libelleRecherche: string
pending?: boolean
selectedId?: string | number | null
}>()
const emit = defineEmits<{
select: [fiche: MiniFiche]
survol: [fiche: MiniFiche | null]
}>()
const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' }
const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e)))
// ── Pastilles actives (état Carte, et téléphone en Fiches) ──
const pastilles = computed(() => {
const out: { cle: string; libelle: string; variante: 'fonction' | 'echelle' | 'posture'; retirer: () => void }[] = []
for (const d of props.vf.cfg.dimensions) {
for (const v of props.vf.etat.filtres[d.cle] ?? []) {
const lib = d.valeurs.find((x) => x.valeur === v)?.libelle ?? v
out.push({ cle: `${d.cle}:${v}`, libelle: lib, variante: d.variante, retirer: () => props.vf.toggle(d.cle, v) })
}
}
if (props.vf.etat.sansAdresse) {
out.push({ cle: 'adresse', libelle: 'Sans adresse', variante: 'echelle', retirer: () => props.vf.setSansAdresse(false) })
}
return out
})
const libellesActifs = computed(() => {
const out: string[] = []
for (const d of props.vf.cfg.dimensions) {
for (const v of props.vf.etat.filtres[d.cle] ?? []) {
out.push(v, d.valeurs.find((x) => x.valeur === v)?.libelle ?? v)
}
}
return out
})
const resumeFiltres = computed(() => {
const parts = pastilles.value.map((p) => p.libelle)
if (props.vf.q) parts.unshift(`« ${props.vf.q} »`)
return parts.join(', ')
})
/** Chip cliquée dans une carte-fiche : ajoute le filtre, garde l'état de vue. */
function ajouterDepuisCarte(f: MiniFiche, libelle: string) {
const dim = props.vf.cfg.dimensions.find((d) => d.cle === f.chipsDimension)
if (!dim) return
const val = dim.valeurs.find((v) => v.libelle === libelle || v.valeur === libelle || v.valeur === libelle.replace(/^#/, ''))
if (val) props.vf.toggle(dim.cle, val.valeur)
}
function survoler(f: MiniFiche | null) {
props.vf.survolId = f ? f.id : null
emit('survol', f)
}
function choisirGroupe(e: Event) {
const v = (e.target as HTMLSelectElement).value
props.vf.setGroupe(v || null)
}
// ── Groupes repliables ──
const replies = ref(new Set<string>())
function basculerGroupe(cle: string) {
const s = new Set(replies.value)
s.has(cle) ? s.delete(cle) : s.add(cle)
replies.value = s
}
watch(() => props.vf.etat.groupe, () => { replies.value = new Set() })
const idGroupe = (cle: string) => (cle || 'sans').normalize('NFD').replace(/[^\w-]/g, '')
// ── Feuille « Filtrer » ──
const feuilleOuverte = ref(false)
const feuille = ref<HTMLElement | null>(null)
const boutonFiltrer = ref<HTMLElement | null>(null)
const boutonFermer = ref<HTMLElement | null>(null)
function ouvrirFeuille() {
feuilleOuverte.value = true
nextTick(() => boutonFermer.value?.focus())
}
function fermerFeuille() {
feuilleOuverte.value = false
nextTick(() => boutonFiltrer.value?.focus())
}
function pieger(e: KeyboardEvent) {
const el = feuille.value
if (!el) return
const f = [...el.querySelectorAll<HTMLElement>('button:not([disabled]), select, input')]
if (!f.length) return
const premier = f[0]
const dernier = f[f.length - 1]
if (e.shiftKey && document.activeElement === premier) { e.preventDefault(); dernier.focus() }
else if (!e.shiftKey && document.activeElement === dernier) { e.preventDefault(); premier.focus() }
}
// 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 })
</script>
<style scoped>
/* ═══════════ Socle */
.vf {
--vf-liste: 320px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--bg);
color: var(--text);
}
.vf-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ═══════════ Barre d'outils */
.vf-barre {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-sm);
padding: var(--space-xs) var(--space-md);
background: var(--surface);
border-bottom: 1px solid var(--border);
min-width: 0;
}
.vf-seg {
display: inline-flex;
padding: 2px;
border-radius: var(--radius-md);
background: var(--bg-alt);
flex-shrink: 0;
}
.vf-seg__btn {
min-height: 32px;
padding: 0 var(--space-sm);
border: none;
border-radius: calc(var(--radius-md) - 2px);
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
cursor: pointer;
}
.vf-seg__btn[aria-pressed='true'] {
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
.vf-seg__n { display: none; }
.vf-recherche {
flex: 1 1 200px;
max-width: 340px;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-xs);
min-height: 36px;
padding: 0 var(--space-sm);
border: 1.5px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vf-recherche:focus-within { border-color: var(--nav-primary-solid); }
.vf-recherche input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
}
.vf-recherche input::placeholder { color: color-mix(in srgb, var(--text) 70%, transparent); }
.vf-recherche input::-webkit-search-cancel-button { display: none; }
.vf-recherche__effacer {
display: flex;
padding: 4px;
border: none;
border-radius: var(--radius-full);
background: none;
color: inherit;
cursor: pointer;
}
.vf-filtrer {
display: none;
min-height: 36px;
padding: 0 var(--space-sm);
border: 1.5px solid var(--nav-primary-solid);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
cursor: pointer;
flex-shrink: 0;
}
.vf-pastilles {
display: none;
flex-wrap: wrap;
gap: var(--space-2xs);
min-width: 0;
}
.vf-espace { flex: 1 1 0; min-width: 0; }
.vf-compteur {
margin: 0;
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
white-space: nowrap;
}
.vf-compteur__sep { margin: 0 var(--space-2xs); }
.vf-lien {
padding: 0;
border: none;
background: none;
color: var(--text);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.vf-select {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
flex-shrink: 0;
}
.vf-select select {
min-height: 32px;
padding: 0 var(--space-xs);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
}
.vf-seg__btn:focus-visible,
.vf-filtrer:focus-visible,
.vf-lien:focus-visible,
.vf-recherche__effacer:focus-visible,
.vf-select select:focus-visible,
.vf-poignee:focus-visible,
.vf-groupe__bascule:focus-visible,
.vf-pied button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ═══════════ Corps */
.vf-corps {
position: relative;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.vf-liste {
grid-column: 1;
grid-row: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
background: var(--bg-alt);
}
.vf-intention {
margin: 0 0 var(--space-sm);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vf-filtres { margin-bottom: var(--space-md); }
.vf-etat-vide {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vf-groupe + .vf-groupe { margin-top: var(--space-md); }
.vf-groupe__titre { margin: 0 0 var(--space-xs); font-size: var(--fs-sm); }
.vf-groupe__bascule {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: 32px;
padding: 0;
border: none;
background: none;
color: var(--text);
font: inherit;
font-weight: var(--fw-semibold);
cursor: pointer;
}
.vf-groupe__chevron { display: inline-block; width: 1em; text-align: center; }
.vf-grille {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: var(--space-sm);
}
.vf-carte {
position: relative;
grid-column: 1;
grid-row: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.vf-poignee {
display: none;
position: absolute;
left: 0;
top: 50%;
z-index: 1001;
width: 22px;
height: 56px;
transform: translateY(-50%);
padding: 0;
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: var(--surface);
color: var(--text);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.vf-pied { display: none; }
/* ═══════════ État Fiches (et auto sous 1024 px) : la liste prend tout, la carte reste montée, invisible */
.vf--fiches .vf-carte,
.vf--auto .vf-carte {
grid-column: 1 / -1;
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}
.vf--fiches .vf-grille,
.vf--auto .vf-grille {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@media (min-width: 768px) {
.vf--fiches .vf-grille,
.vf--auto .vf-grille { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1280px) {
.vf--fiches .vf-grille { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.vf--fiches .vf-select--carte,
.vf--auto .vf-select--carte { display: none; }
/* ═══════════ État Carte : la carte prend tout, liste repliée (invisible au clavier) */
.vf--carte .vf-liste { visibility: hidden; }
.vf--carte .vf-select--tri,
.vf--carte .vf-select--groupe { display: none; }
.vf--carte .vf-pastilles--barre { display: flex; }
/* ═══════════ Ordinateur (≥ 1024) : Mixte, poignée, transitions */
@media (min-width: 1024px) {
.vf-corps {
grid-template-columns: var(--vf-liste) minmax(0, 1fr);
transition: grid-template-columns var(--dur-base, 200ms) var(--ease-out, ease-out);
}
.vf-carte { grid-column: 2; }
.vf-poignee { display: block; }
.vf--fiches .vf-corps { grid-template-columns: minmax(0, 1fr) 0; }
.vf--fiches .vf-poignee { display: none; }
.vf--carte .vf-corps { --vf-liste: 0px; }
.vf--carte .vf-liste { padding: 0; overflow: hidden; }
.vf--mixte .vf-liste,
.vf--auto .vf-liste { border-right: 1px solid var(--border); background: var(--surface); }
.vf--mixte .vf-grille,
.vf--auto .vf-grille { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: var(--space-xs); }
.vf--auto .vf-carte {
grid-column: 2;
position: relative;
inset: auto;
visibility: visible;
pointer-events: auto;
}
.vf--auto .vf-select--carte { display: inline-flex; }
}
@media (min-width: 1024px) and (prefers-reduced-motion: reduce) {
.vf-corps { transition: none; }
}
/* Mixte n'existe pas sous 1024 px */
@media (max-width: 1023.98px) {
.vf-seg__btn--mixte { display: none; }
.vf-carte, .vf-liste { grid-column: 1; }
}
/* ═══════════ Téléphone (< 768) */
@media (max-width: 767.98px) {
.vf-barre { padding: var(--space-xs) var(--space-sm); }
.vf-seg { flex: 1 1 100%; }
.vf-seg__btn { flex: 1; min-height: 40px; }
.vf-seg__n { display: inline; }
.vf-recherche { flex: 1 1 100px; }
.vf-espace { display: none; }
.vf-filtrer { display: inline-flex; align-items: center; }
.vf-filtres { display: none; }
.vf-compteur { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
.vf-select--groupe { display: none; }
.vf-select span:not(.vf-sr) { display: none; }
.vf-liste { padding: var(--space-sm); }
.vf--fiches .vf-pastilles--tel,
.vf--auto .vf-pastilles--tel {
display: flex;
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
/* Carte sur téléphone : segments + sous-vue en tête, pied en bas */
.vf--carte .vf-seg { flex: 1 1 0; }
.vf--carte .vf-recherche,
.vf--carte .vf-filtrer,
.vf--carte .vf-pastilles--barre,
.vf--carte .vf-espace { display: none; }
.vf--carte .vf-liste { display: none; }
.vf--carte .vf-pied {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
}
}
.vf-pied__filtres {
flex: 1 1 auto;
min-width: 0;
display: inline-flex;
min-height: 40px;
align-items: center;
padding: 0 var(--space-sm);
border: 1.5px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: var(--fs-sm);
cursor: pointer;
}
.vf-pied__resume { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vf-pied__voir {
margin-left: auto;
flex-shrink: 0;
min-height: 40px;
padding: 0 var(--space-sm);
border: none;
border-radius: var(--radius-md);
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
cursor: pointer;
}
/* ═══════════ Feuille « Filtrer » */
.vf-voile {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(17, 21, 32, 0.45);
}
.vf-feuille {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10001;
display: flex;
flex-direction: column;
max-height: 85vh;
border-radius: var(--radius-lg, 16px) var(--radius-lg, 16px) 0 0;
background: var(--surface);
color: var(--text);
box-shadow: 0 -8px 32px rgba(17, 21, 32, 0.25);
}
.vf-feuille__tete {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
}
.vf-feuille__tete h2 { margin: 0; font-size: var(--fs-md, 1rem); }
.vf-feuille__fermer {
width: 44px;
height: 44px;
border: none;
background: none;
color: var(--text);
font-size: 1.1rem;
cursor: pointer;
}
.vf-feuille__corps {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.vf-feuille__pied {
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border);
}
.vf-feuille__voir {
width: 100%;
min-height: 44px;
border: none;
border-radius: var(--radius-md);
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
font: inherit;
font-weight: var(--fw-semibold);
cursor: pointer;
}
.vf-feuille__fermer:focus-visible,
.vf-feuille__voir:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<!--
VueFichesFiltres — B11-M4. Les chips de filtre d'une vue fiches, par
dimension (Chip.vue, variante de la dimension). Monté à UN seul endroit selon
l'état (SPEC §3) : panneau de gauche en Mixte, tête de grille en Fiches,
feuille du bas sur téléphone. Compteur dans le résultat courant ; chip à 0
grisée et inactive ; au-delà de 14 valeurs, repli « + N » (actives toujours
visibles) ; « Tout effacer » dès qu'un filtre est actif.
-->
<template>
<div class="vff">
<ChipGroup v-if="vf.etat.sansAdresse" aria-label="Filtre de localisation">
<Chip as="span" removable active variant="echelle" label="Sans adresse" @remove="vf.setSansAdresse(false)" />
</ChipGroup>
<div v-for="d in vf.cfg.dimensions" :key="d.cle" class="vff__dim">
<p :id="`${idBase}-${d.cle}`" class="vff__titre">{{ d.libelle }}</p>
<ChipGroup :aria-label="`Filtrer par ${d.libelle.toLowerCase()}`" :aria-describedby="`${idBase}-${d.cle}`">
<Chip
v-for="v in visibles(d)"
:key="v.valeur"
:label="v.libelle"
:variant="d.variante"
:active="actif(d.cle, v.valeur)"
:count="vf.compteurs[d.cle]?.[v.valeur] ?? 0"
:disabled="!actif(d.cle, v.valeur) && !(vf.compteurs[d.cle]?.[v.valeur])"
class="vff__chip"
@toggle="vf.toggle(d.cle, v.valeur)"
/>
<button
v-if="d.valeurs.length > SEUIL"
type="button"
class="vff__plus"
:aria-expanded="!!deplies[d.cle]"
@click="deplies[d.cle] = !deplies[d.cle]"
>{{ deplies[d.cle] ? 'Réduire' : `+ ${d.valeurs.length - visibles(d).length}` }}</button>
</ChipGroup>
</div>
<button
v-if="vf.nbActifs || vf.q"
type="button"
class="vff__effacer"
@click="vf.effacerTout()"
>Tout effacer</button>
</div>
</template>
<script setup lang="ts">
import type { VueFichesEtat } from '~/composables/useVueFiches'
import type { DimensionFiltre } from '~/utils/vueFiches'
const props = defineProps<{ vf: VueFichesEtat; idBase: string }>()
const SEUIL = 14
const deplies = reactive<Record<string, boolean>>({})
function actif(dim: string, valeur: string) {
return (props.vf.etat.filtres[dim] ?? []).includes(valeur)
}
/** Les 14 premières dans l'ordre de la dimension (stable : les chips ne bougent pas au clic) + les actives hors de ce lot. */
function visibles(d: DimensionFiltre) {
if (d.valeurs.length <= SEUIL || deplies[d.cle]) return d.valeurs
return d.valeurs.filter((v, i) => i < SEUIL || actif(d.cle, v.valeur))
}
</script>
<style scoped>
.vff {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.vff__titre {
margin: 0 0 var(--space-2xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vff__chip:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.vff__plus {
min-height: 32px;
padding: var(--space-2xs) var(--space-sm);
border: 1.5px dashed color-mix(in srgb, var(--text) 40%, transparent);
border-radius: var(--radius-full);
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs);
cursor: pointer;
}
.vff__effacer {
align-self: flex-start;
padding: var(--space-2xs) 0;
border: none;
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs);
text-decoration: underline;
cursor: pointer;
}
.vff__plus:focus-visible,
.vff__effacer:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>