feat(b3): M1 — Chip.vue par dimension (échelle outline, fonction pleine, posture safran) + filtres / et /agences

- components/Chip.vue (nouveau) : label/variant(echelle|fonction|posture)/
  active/count/as(button|span)/removable, emits toggle+remove, aria-pressed
  natif sur as=button, croix "Retirer {label}" accessible. Styles 100% tokens
  DS (--primary/--on-primary/--bg-alt/--accent/--on-accent/--focus-ring/
  --dur-fast), hover translateY, focus-visible outline var(--focus-ring),
  transition coupée sous prefers-reduced-motion. Taille compacte 32px de haut
  mesurée (tap ok).
- components/ChipGroup.vue (nouveau) : role="group" + aria-label + flex-wrap
  gap — garantit le wrap propre exigé (jamais une ligne à virgules).
- EchelleFilter.vue, FonctionFilter.vue : rendu remplacé par Chip/ChipGroup,
  props modelValue/counts et événement update:modelValue strictement
  inchangés (NavSidebar.vue n'a rien à changer côté logique). FonctionFilter
  passe le rang de sélection (comme avant) via le prop count de Chip.
- HashtagFilter.vue : seule la liste HASHTAGS (togglable, grammaire fonction)
  passe en Chip/ChipGroup. Les chips FAMILLES gardent leur rendu et leurs
  couleurs propres par famille (hors grammaire échelle/fonction/posture,
  hors périmètre B3 — les changer aurait été une refonte, pas un branchement).
- pages/agences.vue : hashtags par structure (liste sidebar) en
  Chip as="span" variant="fonction" (non cliquables avant, le restent).
- pages/index.vue : la sheet mobile ne passe PAS par NavSidebar/EchelleFilter/
  FonctionFilter — elle a son propre bloc de chips dupliqué (découvert en
  testant le mobile, absent du périmètre écrit du brief). Direction du parti
  design ("chips codées par dimension", mobile-first explicite) : convertie
  en Chip/ChipGroup elle aussi, mêmes handlers toggleEchelle/toggleFonction
  inchangés. Effet de bord positif : le nombre par fonction (déjà calculé,
  jamais affiché sur mobile) apparaît désormais aussi en mobile, à parité
  avec le desktop.
- components/TagBadge.vue supprimé : son seul consommateur, OrgCard.vue,
  n'est importé nulle part dans l'app (grep vérifié) — code mort. OrgCard.vue
  supprimé avec lui plutôt que laissé avec une référence à un composant
  disparu.
- components/TypeBadge.vue : conservé tel quel — ce n'est pas un filtre, et
  sa grammaire couleur par type (7 couleurs Tailwind fixes) est orthogonale
  à échelle/fonction/posture ; le convertir en Chip aurait perdu l'info de
  type ou inventé une 4e variante non validée.

Vérifié : npm run build OK. Chips wrap avec gap en clair/sombre/mobile
(390px, hauteur mesurée 32px). Dark mode : contraste texte/fond conforme
(safran = texte sombre, jamais blanc).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-09-25 12:03:22 +02:00
co-authored by Claude Sonnet 5
parent 0903b8989b
commit 4b1340f160
9 changed files with 242 additions and 148 deletions
+170
View File
@@ -0,0 +1,170 @@
<template>
<component
:is="as"
:type="as === 'button' ? 'button' : undefined"
class="chip"
:class="[`chip--${variant}`, { 'chip--active': active }]"
:aria-pressed="as === 'button' ? active : undefined"
@click="as === 'button' && emit('toggle')"
>
<span class="chip__label">{{ label }}</span>
<span v-if="count !== undefined" class="chip__count">{{ count }}</span>
<button
v-if="removable"
type="button"
class="chip__remove"
:aria-label="`Retirer ${label}`"
@click.stop="emit('remove')"
>
<svg width="10" height="10" 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>
</component>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
variant?: 'echelle' | 'fonction' | 'posture'
active?: boolean
count?: number
as?: 'button' | 'span'
removable?: boolean
}>(),
{
variant: 'fonction',
active: false,
count: undefined,
as: 'button',
removable: false,
}
)
const emit = defineEmits<{
toggle: []
remove: []
}>()
</script>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-2xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--fs-xs);
line-height: var(--lh-tight);
font-weight: var(--fw-medium);
border: 1.5px solid transparent;
cursor: pointer;
user-select: none;
min-height: 32px;
box-sizing: border-box;
background: none;
transition: background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}
.chip:hover {
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Échelle : outline bleu, fond transparent, actif = plein ────────────── */
.chip--echelle {
border-color: var(--primary);
color: var(--text);
background: transparent;
}
.chip--echelle:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--on-primary);
}
.chip--echelle.chip--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.chip--echelle.chip--active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
.chip--fonction {
border-color: transparent;
background: var(--bg-alt);
color: var(--text);
}
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
.chip--fonction.chip--active {
background: var(--primary);
color: var(--on-primary);
}
.chip--fonction.chip--active:hover {
background: var(--primary-hover);
}
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
.chip--posture {
border-color: var(--accent);
color: var(--text);
background: transparent;
}
.chip--posture:hover {
background: var(--accent);
color: var(--on-accent);
}
.chip--posture.chip--active {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.chip__count {
font-size: 0.85em;
opacity: 0.75;
}
.chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
opacity: 0.7;
cursor: pointer;
border-radius: var(--radius-full);
}
.chip__remove:hover {
opacity: 1;
}
.chip__remove:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="chip-group" role="group" :aria-label="ariaLabel">
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
ariaLabel: string
}>()
</script>
<style scoped>
.chip-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-xs);
}
</style>
+8 -31
View File
@@ -1,39 +1,16 @@
<template> <template>
<div class="space-y-1.5"> <div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p> <p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<!-- Inline sur 1 ligne — même pattern que FonctionFilter --> <ChipGroup aria-label="Filtrer par échelle">
<div class="flex flex-wrap gap-x-4 gap-y-1.5"> <Chip
<label
v-for="option in ECHELLES" v-for="option in ECHELLES"
:key="option" :key="option"
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity" :label="option"
> variant="echelle"
<!-- Case carrée --> :active="isSelected(option)"
<span @toggle="toggle(option)"
class="flex items-center justify-center shrink-0 transition-all" />
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;" </ChipGroup>
:style="isSelected(option)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
>
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ option }}</span>
<!-- Input réel (masqué) -->
<input
type="checkbox"
class="sr-only"
:checked="isSelected(option)"
@change="toggle(option)"
/>
</label>
</div>
</div> </div>
</template> </template>
+9 -24
View File
@@ -1,32 +1,17 @@
<template> <template>
<div class="space-y-1.5"> <div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p> <p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<div class="space-y-1"> <ChipGroup aria-label="Filtrer par fonction">
<button <Chip
v-for="fn in FONCTIONS" v-for="fn in FONCTIONS"
:key="fn" :key="fn"
@click="toggle(fn)" :label="fn"
:aria-pressed="modelValue.includes(fn)" variant="fonction"
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80" :active="modelValue.includes(fn)"
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''" :count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
> @toggle="toggle(fn)"
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs --> />
<span </ChipGroup>
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
>
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ fn }}</span>
</button>
</div>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);"> <p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }} {{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button> <button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
+9 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);"> <div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille --> <!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
<div style="margin-bottom: 6px;"> <div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span> <span class="filter-label">FAMILLES</span>
<div class="chips-row"> <div class="chips-row">
@@ -25,17 +25,16 @@
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;" 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> >{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div> </div>
<div v-if="hashtagsVisible" class="chips-row"> <ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<span <Chip
v-for="tag in props.allHashtags" v-for="tag in props.allHashtags"
:key="tag" :key="tag"
class="chip chip-small" :label="tag"
:style="selectedHashtags.includes(tag) variant="fonction"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="selectedHashtags.includes(tag)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" @toggle="toggleHashtag(tag)"
@click="toggleHashtag(tag)" />
>{{ tag }}</span> </ChipGroup>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
transition: all 0.15s; transition: all 0.15s;
user-select: none; user-select: none;
} }
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style> </style>
-45
View File
@@ -1,45 +0,0 @@
<template>
<NuxtLink
:to="`/fiche/${org.Id}`"
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
>
<div class="flex items-start justify-between gap-3 mb-2">
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
</div>
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
{{ org.description }}
</p>
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
<TagBadge
v-for="tag in tags"
:key="tag"
:tag="tag"
@click="$emit('filter-tag', tag)"
/>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
org: {
Id: number
nom: string
type_org?: string
description?: string
tags?: string
lien?: string
}
}>()
defineEmits<{ 'filter-tag': [tag: string] }>()
const tags = computed(() =>
props.org.tags
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
: []
)
</script>
-11
View File
@@ -1,11 +0,0 @@
<template>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
@click.prevent="$emit('click', tag)"
>{{ tag }}</span>
</template>
<script setup lang="ts">
defineProps<{ tag: string }>()
defineEmits<{ click: [tag: string] }>()
</script>
+7 -6
View File
@@ -87,14 +87,15 @@
/> />
</div> </div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div> <div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1"> <ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<span <Chip
v-for="tag in structure.hashtags.slice(0, 2)" v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag" :key="tag"
class="text-xs" as="span"
style="color: var(--nav-text-muted);" variant="fonction"
>{{ tag }}</span> :label="tag"
</div> />
</ChipGroup>
</div> </div>
</div> </div>
+20 -20
View File
@@ -185,36 +185,36 @@
</button> </button>
</label> </label>
<!-- Filtres ÉCHELLE — chips style FONCTION --> <!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
<div class="mt-2"> <div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span> <span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1"> <ChipGroup aria-label="Filtrer par échelle">
<span <Chip
v-for="opt in ECHELLES" v-for="opt in ECHELLES"
:key="opt" :key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all" :label="opt"
:style="echelle.includes(opt) variant="echelle"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="echelle.includes(opt)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" @toggle="toggleEchelle(opt)"
@click="toggleEchelle(opt)" />
>{{ opt }}</span> </ChipGroup>
</div>
</div> </div>
<!-- Filtres FONCTION — chips flex-wrap --> <!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
<div class="mt-2"> <div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span> <span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1"> <ChipGroup aria-label="Filtrer par fonction">
<span <Chip
v-for="fn in FONCTIONS" v-for="fn in FONCTIONS"
:key="fn" :key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all" :label="fn"
:style="fonctions.includes(fn) variant="fonction"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="fonctions.includes(fn)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" :count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
@click="toggleFonction(fn)" @toggle="toggleFonction(fn)"
>{{ fn }}</span> />
</div> </ChipGroup>
</div> </div>
<button <button