Files
nav-carte/components/FormLibre.vue
T
Jules NenyandClaude Opus 5.5 74337c0bcb fix(a11y): chip actif en fond plein sur tout le site, survol réservé aux pointeurs
Chip.vue : l'actif (échelle, fonction) passe de --primary, translucide
dans le thème AEP (3,96:1 composé, sous AA), à --nav-primary-solid /
--nav-text-on-primary (14,62:1 ; ≈12:1 en sombre), comme le formulaire
libre depuis AF4. Le survol d'un actif ne change plus de fond ; tous les
:hover passent sous @media (hover: hover) (plus de chip collé au tap).
Mesuré sur /, /agences, /proposer, mobile et desktop.

FormLibre.vue : surcharges locales de AF4 devenues inutiles retirées
(principal plein, bloc hover: none) ; le survol du secondaire passe
sous (hover: hover). Secondaire en contour vérifié au toucher.

Décision MOE du 29/09, veto Jules possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:26:05 +02:00

311 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<form @submit.prevent="submit" class="proposer-form proposer-form--libre" novalidate>
<div class="field-group" :class="{ 'field-error': errors.texte }">
<label id="libre-liens-label">Tes liens <span class="label-hint">(1 ou plusieurs — sites, PDF, articles...)</span></label>
<div class="liens-list" role="group" aria-labelledby="libre-liens-label">
<div v-for="(lien, i) in liens" :key="i" class="lien-row">
<input
:id="`libre-lien-${i}`"
v-model="liens[i]"
type="url"
inputmode="url"
placeholder="https://..."
autocomplete="off"
@blur="validateAll(false)"
/>
<button
v-if="liens.length > 1"
type="button"
class="lien-remove"
:aria-label="`Retirer ce lien`"
@click="removeLien(i)"
>×</button>
</div>
</div>
<button v-if="liens.length < 10" type="button" class="btn-add-lien" @click="addLien">+ un autre lien</button>
<span v-if="errors.texte" id="libre-texte-error" class="error-msg" role="alert">{{ errors.texte }}</span>
</div>
<div class="field-group">
<label for="libre-texte">Pourquoi c'est utile, pour toi ? <span class="label-hint">(pas de format imposé)</span></label>
<textarea
id="libre-texte"
v-model="texte"
rows="4"
placeholder="Dis-le avec tes mots : ce que ça t'a apporté, pourquoi tu le partages..."
:aria-describedby="errors.texte ? 'libre-texte-error' : undefined"
/>
</div>
<div class="field-group">
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — un ou deux si tu hésites)</span></label>
<ChipGroup aria-label="Type suggéré" aria-describedby="libre-type-hint">
<Chip
v-for="t in TYPES_SUGGERES"
:key="t"
:label="TYPES_SUGGERES_LABELS[t]"
variant="fonction"
:active="typeSuggere === t || typeSecondaire === t"
:class="{ 'chip--secondaire': typeSecondaire === t }"
@toggle="toggleType(t)"
/>
</ChipGroup>
<span id="libre-type-hint" class="label-hint">
Écosystème : {{ TYPES_SUGGERES_HINTS.ecosysteme }} · Réseau : {{ TYPES_SUGGERES_HINTS.reseau }} · Jobs : {{ TYPES_SUGGERES_HINTS.jobs }} · Outils : {{ TYPES_SUGGERES_HINTS.outils }} · Références : {{ TYPES_SUGGERES_HINTS.references }}
</span>
<!-- Distinction non visuelle premier choix / second choix pour lecteurs d'écran :
les deux chips actifs partagent le même aria-pressed, sans quoi rien ne dit
lequel est principal. -->
<span class="sr-only" aria-live="polite">
<template v-if="typeSuggere && typeSecondaire">
Principal : {{ TYPES_SUGGERES_LABELS[typeSuggere] }} · second choix : {{ TYPES_SUGGERES_LABELS[typeSecondaire] }}
</template>
</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="libre-email">Ton email <span class="label-hint">(optionnel — pour qu'on te tienne au courant)</span></label>
<input
id="libre-email"
v-model="email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateAll(false)"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<!-- Honeypot anti-spam : caché visuellement et aux lecteurs d'écran, un bot le remplit. -->
<div class="hp-field" aria-hidden="true">
<label for="libre-site-web">Site web</label>
<input id="libre-site-web" v-model="siteWeb" type="text" tabindex="-1" autocomplete="off" />
</div>
<div v-if="serverError" class="server-error" role="alert" aria-live="assertive">
<strong>Erreur :</strong> {{ serverError }}
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">
{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}
</button>
</div>
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, TYPES_SUGGERES_HINTS, type TypeSuggere } from '~/utils/submitLibre'
const props = defineProps<{ presetType?: TypeSuggere }>()
const emit = defineEmits<{ success: [] }>()
const liens = reactive<string[]>([''])
const texte = ref('')
const email = ref('')
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
// Deuxième chip optionnelle (B5-M2b) — pour les indécis entre deux types.
const typeSecondaire = ref<TypeSuggere | undefined>()
const siteWeb = ref('') // honeypot
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function addLien() {
if (liens.length < 10) liens.push('')
}
function removeLien(i: number) {
liens.splice(i, 1)
if (liens.length === 0) liens.push('')
}
/**
* Deux chips maximum. Premier clic = principal, deuxième = secondaire.
* Décocher le principal alors qu'un secondaire existe promeut ce dernier.
* Troisième clic sur un chip inactif quand deux sont déjà actifs : remplace
* le secondaire par le nouveau chip (choix le plus simple — pas de file d'attente).
*/
function toggleType(t: TypeSuggere) {
if (typeSuggere.value === t) {
typeSuggere.value = typeSecondaire.value
typeSecondaire.value = undefined
return
}
if (typeSecondaire.value === t) {
typeSecondaire.value = undefined
return
}
if (typeSuggere.value === undefined) {
typeSuggere.value = t
} else if (typeSecondaire.value === undefined) {
typeSecondaire.value = t
} else {
typeSecondaire.value = t
}
}
function currentPayload() {
return {
liens: liens.map((l) => l.trim()).filter(Boolean),
texte: texte.value,
submitted_by_email: email.value || undefined,
type_suggere: typeSuggere.value,
type_secondaire: typeSecondaire.value,
site_web: siteWeb.value,
}
}
function validateAll(scrollToError = true): boolean {
const result = LibreSubmitSchema.safeParse(currentPayload())
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.keys(errors).forEach((k) => delete errors[k])
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
if (scrollToError) {
nextTick(() => {
document.querySelector('.field-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
return false
}
Object.keys(errors).forEach((k) => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll(true)) return
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: { mode: 'libre', ...currentPayload() },
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
Object.assign(errors, Object.fromEntries(Object.entries(e.data as Record<string, string[]>).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])))
serverError.value = 'Vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
.liens-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.375rem;
}
.lien-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.lien-row input {
flex: 1;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
box-sizing: border-box;
}
.lien-row input:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.lien-remove {
flex-shrink: 0;
width: 32px;
height: 32px;
border: 1px solid rgba(26, 34, 56, 0.2);
background: transparent;
border-radius: 6px;
color: var(--nav-text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.lien-remove:hover {
border-color: #c0392b;
color: #c0392b;
}
.btn-add-lien {
align-self: flex-start;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px dashed rgba(26, 34, 56, 0.3);
border-radius: 6px;
font-size: 0.8rem;
color: var(--nav-text-muted);
cursor: pointer;
font-family: inherit;
}
.btn-add-lien:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.hp-field {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* ── B5-M2c : distinction principal / second choix ──
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
ont été retirées. */
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
--nav-text sur le fond de page : contraste très large (>10:1), stable
dans tous les états. */
.field-group :deep(.chip--secondaire.chip--active) {
background: transparent !important;
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
}
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
(sur tactile, :hover reste collé après un tap). */
@media (hover: hover) {
.field-group :deep(.chip--secondaire.chip--active:hover) {
background: rgba(var(--nav-primary-raw), 0.08) !important;
border-color: var(--nav-primary-solid) !important;
color: var(--nav-text) !important;
}
}
</style>