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:
co-authored by
Claude Opus 5.5
parent
38df1d5eb5
commit
5a4c7af79c
@@ -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>
|
||||
@@ -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>
|
||||
<button
|
||||
type="button"
|
||||
class="fiche-mini-card"
|
||||
: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>
|
||||
<article class="fiche-mini-card" :class="{ 'fiche-mini-card--actif': actif }">
|
||||
<button
|
||||
type="button"
|
||||
class="fiche-mini-card__ouvrir"
|
||||
: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>
|
||||
</button>
|
||||
|
||||
<ChipGroup
|
||||
v-if="fiche.chips.length || fiche.echelle"
|
||||
@@ -14,11 +24,24 @@
|
||||
class="fiche-mini-card__chips"
|
||||
>
|
||||
<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>
|
||||
|
||||
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,30 +49,37 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
defineProps<{
|
||||
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<{
|
||||
open: []
|
||||
etiquette: [label: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiche-mini-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
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) {
|
||||
@@ -63,11 +93,42 @@ const emit = defineEmits<{
|
||||
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-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 {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
@@ -92,7 +153,13 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.fiche-mini-card__chips {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: var(--space-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
.fiche-mini-card__chips :deep(button) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fiche-mini-card__no-geo {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user