Le second choix du double type se lit en contour (texte --nav-text), le principal passe en fond plein --nav-primary-solid : 4,16:1 → 14,6:1 mesuré (le fond actif translucide de Chip.vue était sous AA). Survol collant neutralisé sur tactile, annonce sr-only « Principal · second choix ». Scopé à FormLibre ; Chip.vue non modifié (défaut global signalé). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzGo8bxhHp6M9RxuAFbyLU
338 lines
11 KiB
Vue
338 lines
11 KiB
Vue
<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 + contraste chip actif ──
|
||
Mesuré avant modif (script de capture, af4-avant-*) : le chip actif de
|
||
Chip.vue (variant fonction, fond rgba(26,34,56,.6) sur fond de page
|
||
#f8f6f1) rend ≈ 4.16:1 au repos — sous le seuil AA 4.5:1. C'est un défaut
|
||
général de Chip.vue (touche aussi les filtres de / et /agences), signalé
|
||
hors mandat — voir rapport. On neutralise ce point pour CE formulaire
|
||
seulement, sans toucher Chip.vue : fond plein (--nav-primary-solid) au
|
||
lieu de la version translucide, pairing déjà utilisé ailleurs sur le site
|
||
(pages/trouver-du-taf.vue .taff-tab--active). Mesuré après : ≈14.6:1. */
|
||
.field-group :deep(.chip--fonction.chip--active) {
|
||
background: var(--nav-primary-solid);
|
||
color: var(--nav-text-on-primary);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
/* Survol collant sur tactile (le :hover de Chip.vue n'est pas gardé par
|
||
@media (hover: hover)) : neutralisé ici pour les deux chips actifs de ce
|
||
formulaire, sans toucher Chip.vue. Mesuré : le survol n'aggravait pas le
|
||
contraste du principal (6.68:1 contre 4.16:1 au repos, avant modif) mais
|
||
figeait un fond et un transform incohérents après un tap tant que rien ne
|
||
les levait. */
|
||
@media (hover: none) {
|
||
.field-group :deep(.chip--fonction.chip--active) {
|
||
transform: none !important;
|
||
}
|
||
.field-group :deep(.chip--fonction.chip--active:hover) {
|
||
background: var(--nav-primary-solid) !important;
|
||
color: var(--nav-text-on-primary) !important;
|
||
transform: none !important;
|
||
}
|
||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||
background: transparent !important;
|
||
border-color: var(--nav-primary-solid) !important;
|
||
color: var(--nav-text) !important;
|
||
transform: none !important;
|
||
}
|
||
}
|
||
</style>
|