Mobile : parents séparés en pointillés, barre d'édition allégée, dates au clavier numérique (#82)

- Deux parents d'un même enfant sans couple enregistré sont reliés en pointillés au lieu
  d'avoir chacun un « Inconnu » (couple propre au dessin, rien en base) ; le tri de la
  fratrie le reconnaît.
- Barre d'édition mobile : X, « Ajouter » et « Plus » (dessin, texte, sticker, photo).
  Les outils s'ouvrent depuis « Plus » avec un retour, la corbeille n'apparaît que s'il y a
  quelque chose à effacer, l'outil actif en aplat. La barre ne passe plus sous le zoom.
- Bandeau d'édition : sur téléphone, seul le bouton reste, le texte se coupait.
- Dates : au-delà de 4 chiffres les barres se posent seules (12031950 → 12/03/1950), le
  message n'apparaît qu'en quittant le champ.
- Ajouter une personne : plus d'erreur « prénom » en quittant le champ vide, le bouton
  reste actif et le dit à l'appui.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-10-18 14:08:07 +02:00
1 parent 2dec945fa3
commit f8d4b4113d
14 files changed
+233 -45

No files matched your search

+3 -4
View File
@@ -146,8 +146,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
})
}
const isValid = firstName.trim().length > 0
const relationTypeLabels = {
parent: t('Parent de'),
child: t('Enfant de'),
@@ -191,7 +189,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
className={touched.firstName && !firstName.trim() ? 'apn-input--error' : ''}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, firstName: true }))}
placeholder={t('Marie')}
autoComplete="off"
aria-invalid={touched.firstName && !firstName.trim()}
@@ -325,7 +322,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>
{t('Annuler')}
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={!isValid || loading}>
{/* Toujours actif : sans prénom, l'appui le dit et ramène au champ (le dire dès qu'on quittait le prénom
affichait une erreur avant même d'avoir rempli la fiche) */}
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading}>
<UserPlus size={16} strokeWidth={2} />
<PzBusy busy={loading} busyLabel={t('Ajout en cours')}>{t('Ajouter cette personne')}</PzBusy>
</button>
+18 -6
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { t } from '../i18n/index.js'
import { dateToInputText, parseDateInput } from '../utils/dateInput.js'
import { dateToInputText, formatDigitsAsDate, parseDateInput } from '../utils/dateInput.js'
// Champ de date de la fiche : une année seule ou une date complète (issue #77). Le texte tapé reste tel
// quel ; la valeur ne remonte qu'une fois comprise, sinon le champ le signale sans rien enregistrer.
// Le clavier numérique du téléphone n'a pas de barre oblique : au-delà de 4 chiffres, les barres se posent
// toutes seules (12031950 → 12/03/1950). Le message n'apparaît qu'en quittant le champ.
export default function DateField({ id, value, onChange, placeholder, className = '' }) {
const [text, setText] = useState(() => dateToInputText(value))
const [touched, setTouched] = useState(false)
const invalid = parseDateInput(text) === undefined
return (
@@ -17,14 +20,23 @@ export default function DateField({ id, value, onChange, placeholder, className
className={className}
value={text}
placeholder={placeholder || t('Année ou date')}
aria-invalid={invalid || undefined}
aria-invalid={(touched && invalid) || undefined}
onChange={(e) => {
setText(e.target.value)
const parsed = parseDateInput(e.target.value)
if (parsed !== undefined) onChange(parsed)
const next = formatDigitsAsDate(e.target.value)
setText(next)
const parsed = parseDateInput(next)
if (parsed !== undefined) {
setTouched(false)
onChange(parsed)
}
}}
onBlur={() => {
const parsed = parseDateInput(text)
if (parsed === undefined) setTouched(true)
else setText(dateToInputText(parsed))
}}
/>
{invalid && <span className="pz-date-hint" role="status">{t('Une année (1950) ou une date (12/03/1950)')}</span>}
{touched && invalid && <span className="pz-date-hint" role="status">{t('Une année (1950) ou une date (12/03/1950)')}</span>}
</span>
)
}
@@ -53,6 +53,11 @@
.edit-mode-toast-text { font-size: 13px; }
}
/* Téléphone : le texte se coupait en « Vous modifi… » à côté du bouton, qui suffit à lui seul */
@media (max-width: 480px) {
.edit-mode-toast--with-submit .edit-mode-toast-text { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.edit-mode-toast { animation: pz-fade 160ms ease both; }
}
+1 -1
View File
@@ -22,7 +22,7 @@ function EditSessionBar({ active, hasDraft, role = 'contributor', onDeactivate,
<>
<div className={borderClass} aria-hidden="true" />
<div className={`edit-mode-toast ${isAdmin ? 'edit-mode-toast--admin' : ''}`} role="status" aria-live="polite">
<div className={`edit-mode-toast${isAdmin ? ' edit-mode-toast--admin' : ''}${showSubmit ? ' edit-mode-toast--with-submit' : ''}`} role="status" aria-live="polite">
<span className="edit-mode-toast-dot" aria-hidden="true" />
<span className="edit-mode-toast-text">{toastLabel}</span>
@@ -1,3 +1,6 @@
// Identifiant des couples « séparés » créés par le placement (deux parents sans union enregistrée)
export const SEPARATED_PAIR_PREFIX = 'pair:'
// --- Constantes visuelles ---
export const PERSON_W = 120
export const PERSON_H = 120
+36 -3
View File
@@ -3,7 +3,7 @@ import {
PERSON_W, PERSON_H, UNION_W, UNION_H,
UNKNOWN_W, UNKNOWN_H, COMPACT_W, COMPACT_JOIN_GAP,
JITTER_RADIUS, FLOAT_AMPLITUDE, FLOAT_SPEED,
ANGLE_VARIATION, MAX_ORBIT_MEDIAS, ORBIT_RADIUS,
ANGLE_VARIATION, MAX_ORBIT_MEDIAS, ORBIT_RADIUS, SEPARATED_PAIR_PREFIX,
} from './constants'
// ============================================================
@@ -40,6 +40,38 @@ function makePairKey(a, b) {
return left < right ? `${left}|${right}` : `${right}|${left}`
}
// Deux parents d'un même enfant sans couple enregistré (séparés, jamais mariés) : un couple « séparé »
// propre au dessin les relie en pointillés, au lieu d'un « Inconnu » à côté de chacun. Rien en base.
export function separatedPairUnions(filiations = [], unions = []) {
const coupled = new Set(unions.filter(u => u.partner1Id && u.partner2Id).map(u => makePairKey(u.partner1Id, u.partner2Id)))
const unionPartners = new Map(unions.map(u => [u.id, [u.partner1Id, u.partner2Id]]))
const byChild = new Map()
filiations.forEach((f) => {
if (!f.childId) return
if (!byChild.has(f.childId)) byChild.set(f.childId, { direct: new Set(), viaUnion: new Set() })
const entry = byChild.get(f.childId)
if (f.unionId) (unionPartners.get(f.unionId) || []).forEach(id => id != null && entry.viaUnion.add(String(id)))
else if (f.parentId) entry.direct.add(String(f.parentId))
})
const pairs = new Map()
byChild.forEach(({ direct, viaUnion }) => {
const parents = [...direct].filter(id => !viaUnion.has(id)).sort()
if (parents.length < 2) return
const key = makePairKey(parents[0], parents[1])
if (coupled.has(key) || pairs.has(key)) return
const original = (id) => filiations.find(f => String(f.parentId) === id)?.parentId ?? id
pairs.set(key, {
id: `${SEPARATED_PAIR_PREFIX}${parents[0]}:${parents[1]}`,
partner1Id: original(parents[0]),
partner2Id: original(parents[1]),
separated: true,
displayOrder: Number.MAX_SAFE_INTEGER,
})
})
return [...pairs.values()]
}
// ============================================================
// Normalisation des filiations
// ============================================================
@@ -170,8 +202,9 @@ const CLUSTER_SPREAD_FACTOR = 6
export function computeLayout(graphData = null) {
const persons = graphData?.persons || globalPersons
const unions = graphData?.unions || globalUnions
const filiations = graphData?.filiations || globalFiliations
const savedUnions = graphData?.unions || globalUnions
const unions = [...savedUnions, ...separatedPairUnions(filiations, savedUnions)]
const normalizedFiliations = normalizeFiliationsForLayout(filiations, unions)
@@ -330,7 +363,7 @@ export function computeLayout(graphData = null) {
// Entrées d'union unifiées (réelles + virtuelles)
const allUnionEntries = [
...unions.map(u => ({
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: false,
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated),
})),
...virtualUnions.map(vu => ({
elkId: vu.elkId, p1Key: `p-${vu.parentId}`, p2Key: vu.unknownElkId, virtual: true,
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { computeLayout, normalizeFiliationsForLayout } from './elkLayout'
import { computeLayout, normalizeFiliationsForLayout, separatedPairUnions, coupleBarMeta } from './elkLayout'
import { PERSON_W } from './constants'
describe('normalizeFiliationsForLayout', () => {
@@ -239,3 +239,31 @@ describe('computeLayout', () => {
expect(cX < bXs[0] || cX > bXs[1]).toBe(true)
})
})
describe('separated parents', () => {
// Philippe et Marie, parents d'Anaïs, sans couple enregistré (séparés)
const persons = [{ id: 'phil' }, { id: 'marie' }, { id: 'anais' }]
const filiations = [
{ id: 'f1', childId: 'anais', parentId: 'phil', unionId: null, parentageType: 'biologique', displayOrder: 1 },
{ id: 'f2', childId: 'anais', parentId: 'marie', unionId: null, parentageType: 'biologique', displayOrder: 2 },
]
it('links the two parents with a dashed couple instead of two unknowns', () => {
const result = computeLayout({ persons, unions: [], filiations })
const ids = result.children.map((node) => node.id)
expect(ids.some((id) => id.startsWith('unknown-'))).toBe(false)
const pair = coupleBarMeta.find((entry) => entry.elkId.startsWith('u-pair:'))
expect(pair).toMatchObject({ virtual: true })
expect([pair.p1Key, pair.p2Key].sort()).toEqual(['p-marie', 'p-phil'])
expect(result.edges.some((edge) => edge.sources[0] === pair.elkId && edge.targets[0] === 'p-anais')).toBe(true)
})
it('does not add a pair when the parents already have a union', () => {
const unions = [{ id: 'u1', partner1Id: 'phil', partner2Id: 'marie', displayOrder: 1 }]
expect(separatedPairUnions(filiations, unions)).toEqual([])
})
it('keeps a lone parent with an unknown partner', () => {
expect(separatedPairUnions([filiations[0]], [])).toEqual([])
})
})
@@ -1,5 +1,6 @@
// Réordonner une fratrie dans l'arbre : logique pure (sans canvas ni React), testée à part.
// Une fratrie = les enfants reliés à une même source (union `u-…` ou parent seul `vu-…`).
import { SEPARATED_PAIR_PREFIX } from './constants'
const centerOf = (node) => node.x + node.width / 2
@@ -68,7 +69,10 @@ function filiationsOf(filiations, unions, source, childId) {
if (source.startsWith('u-')) {
const unionId = source.slice(2)
const union = unions.find((u) => String(u.id) === unionId)
const partners = union ? [union.partner1Id, union.partner2Id].filter((id) => id != null).map(String) : []
// Couple « séparé » propre au dessin (elkLayout) : ses deux partenaires sont dans l'identifiant
const partners = union
? [union.partner1Id, union.partner2Id].filter((id) => id != null).map(String)
: unionId.startsWith(SEPARATED_PAIR_PREFIX) ? unionId.slice(SEPARATED_PAIR_PREFIX.length).split(':') : []
const viaUnion = filiations.filter((f) => sameChild(f) && String(f.unionId) === unionId)
// Union reconstituée par le placement : deux liens directs vers chacun des partenaires
return viaUnion.length ? viaUnion : filiations.filter((f) => sameChild(f) && !f.unionId && partners.includes(String(f.parentId)))
@@ -114,3 +114,18 @@ describe('ordre de la fratrie dans les données', () => {
expect(graph.filiations.every((f) => f.displayOrder === 1)).toBe(true)
})
})
describe('fratrie de parents séparés', () => {
it('retrouve les liens directs vers les deux parents du couple « séparé »', () => {
const filiations = [
{ id: 'x1', childId: 'k1', parentId: 'phil', unionId: null },
{ id: 'x2', childId: 'k1', parentId: 'marie', unionId: null },
{ id: 'x3', childId: 'k2', parentId: 'phil', unionId: null },
{ id: 'x4', childId: 'k2', parentId: 'marie', unionId: null },
]
expect(buildOrderPayload(filiations, [], 'u-pair:marie:phil', ['p-k2', 'p-k1'])).toEqual([
{ childId: 'k2', linkIds: ['x3', 'x4'] },
{ childId: 'k1', linkIds: ['x1', 'x2'] },
])
})
})
@@ -190,14 +190,12 @@
/* Active tool — gradient fill */
.nav-annot-btn--active {
background: linear-gradient(140deg, #93402a, #2a2622);
background: var(--pz-ink);
color: #ffffff;
box-shadow: 0 2px 10px rgba(147, 64, 42, 0.35);
}
.nav-annot-btn--active:hover:not(:disabled) {
background: linear-gradient(140deg, #93402a, #3d3731);
transform: translateY(-1px);
background: #3a3530;
}
/* Panel-open indicator — tiny dot below */
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import {
Edit3, Share2, User, UserPlus,
X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe,
X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe, ArrowLeft,
} from 'lucide-react'
import NavButton from './NavButton'
import AvatarMenu from './AvatarMenu'
@@ -21,6 +21,14 @@ const ANNOT_TOOLS = [
{ id: 'photo', label: t('Photo'), Icon: Image },
]
// Mobile : les outils de dessin attendent dans « Plus », la barre ne montre que l'essentiel
const ANNOT_MENU = [
{ id: 'drawing', label: t('Dessiner'), Icon: Pencil },
{ id: 'text', label: t('Écrire un texte'), Icon: Type },
{ id: 'sticker', label: t('Poser un sticker'), Icon: Star },
{ id: 'photo', label: t('Poser une photo'), Icon: Image },
]
function ContextualNavbar({
userRole,
pendingContributions = 0,
@@ -47,6 +55,7 @@ function ContextualNavbar({
}) {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false)
const [annotPanel, setAnnotPanel] = useState(null)
const [annotMode, setAnnotMode] = useState(false)
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef(null)
const [isMobile, setIsMobile] = useState(() =>
@@ -102,7 +111,10 @@ function ContextualNavbar({
}, [])
useEffect(() => {
if (!editModeActive) setAnnotPanel(null)
if (!editModeActive) {
setAnnotPanel(null)
setAnnotMode(false)
}
}, [editModeActive])
const { navbarType, authenticated } = userRole
@@ -131,6 +143,19 @@ function ContextualNavbar({
}
}
// Corbeille des annotations : visible seulement quand il y a quelque chose à effacer
const renderAnnotTrash = () => (
<button
className={`nav-annot-btn nav-annot-btn--trash${annotationTools.isDragging ? ' nav-annot-btn--drag-target' : ''}${annotationTools.isOverTrash ? ' nav-annot-btn--drag-over' : ''}`}
onClick={annotationTools.onDelete}
aria-label={t('Supprimer')}
title={t('Supprimer')}
data-annotation-trash=""
>
<Trash2 size={annotationTools.isOverTrash ? 22 : 18} strokeWidth={2} />
</button>
)
// Famille arrivée par le lien : les deux gestes utiles écrits en toutes lettres, le reste dans « Plus »
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const renderFamilyButtons = () => (
@@ -345,7 +370,7 @@ function ContextualNavbar({
)}
<div className={`navbar-content${showMobileAnnot ? ' navbar-content--annot' : ''}`}>
{showMobileAnnot ? (
{showMobileAnnot && !annotMode ? (
<>
<button
className="nav-annot-exit"
@@ -356,6 +381,61 @@ function ContextualNavbar({
<X size={18} strokeWidth={2.5} />
</button>
{/* Libellé court : la barre doit tenir à droite du bouton de zoom */}
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddPersonClick} aria-label={t('Ajouter une personne')}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Ajouter')}</span>
</button>
<div ref={moreRef} className="nav-more-wrapper">
<button
type="button"
className={`nav-family-btn nav-family-btn--more${moreOpen ? ' is-open' : ''}`}
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
aria-haspopup="menu"
>
<MoreHorizontal size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Plus')}</span>
</button>
{moreOpen && (
<div className="nav-more-menu" role="menu">
{ANNOT_MENU.map((tool) => (
<button
key={tool.id}
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false)
setAnnotMode(true)
handleAnnotToolClick(tool.id)
}}
>
<tool.Icon size={18} strokeWidth={2} aria-hidden="true" />
{tool.label}
</button>
))}
</div>
)}
</div>
{(annotationTools.selectedAnnotationId || annotationTools.isDragging) && renderAnnotTrash()}
</>
) : showMobileAnnot ? (
<>
<button
className="nav-annot-btn"
onClick={() => {
setAnnotPanel(null)
setAnnotMode(false)
annotationTools.onToolChange('pointer')
}}
aria-label={t('Terminé')}
title={t('Terminé')}
>
<ArrowLeft size={18} strokeWidth={2.5} />
</button>
<div className="nav-annot-sep" />
{ANNOT_TOOLS.map((tool) => (
@@ -370,27 +450,12 @@ function ContextualNavbar({
</button>
))}
<div className="nav-annot-sep" />
<button
className={`nav-annot-btn nav-annot-btn--trash${annotationTools.isDragging ? ' nav-annot-btn--drag-target' : ''}${annotationTools.isOverTrash ? ' nav-annot-btn--drag-over' : ''}`}
onClick={annotationTools.onDelete}
disabled={!annotationTools.selectedAnnotationId && !annotationTools.isDragging}
aria-label={t('Supprimer')}
title={t('Supprimer')}
data-annotation-trash=""
>
<Trash2 size={annotationTools.isOverTrash ? 22 : 18} strokeWidth={2} />
</button>
<button
className="nav-annot-btn nav-annot-btn--add"
onClick={onAddPersonClick}
aria-label={t('Ajouter une personne')}
title={t('Ajouter une personne')}
>
<UserPlus size={18} strokeWidth={2} />
</button>
{(annotationTools.selectedAnnotationId || annotationTools.isDragging) && (
<>
<div className="nav-annot-sep" />
{renderAnnotTrash()}
</>
)}
</>
) : (
<>
+5
View File
@@ -182,6 +182,11 @@ export default {
"Ajouter une personne": "Add a person",
"Plus": "More",
"Dessiner ou écrire sur l'arbre": "Draw or write on the tree",
"Dessiner": "Draw",
"Écrire un texte": "Write a text",
"Poser un sticker": "Add a sticker",
"Poser une photo": "Add a photo",
"Terminé": "Done",
"Le mode d'emploi": "User guide",
"Modifier l'arbre": "Edit the tree",
"Contributions": "Contributions",
+7
View File
@@ -13,6 +13,13 @@ export function dateToInputText(value) {
return year ? year[1] : raw
}
// Chiffres seuls au-delà d'une année (5 à 8 chiffres) : on pose les barres, « 12031950 » → « 12/03/1950 »
export function formatDigitsAsDate(text) {
const raw = String(text ?? '')
if (!/^\d{5,8}$/.test(raw)) return raw
return `${raw.slice(0, 2)}/${raw.slice(2, 4)}/${raw.slice(4)}`
}
// Texte du champ vers la valeur stockée : '' si vide, « AAAA-MM-JJ » si valable, undefined si on ne comprend pas
export function parseDateInput(text) {
const raw = String(text ?? '').trim()
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { dateToInputText, isYearOnly, parseDateInput } from './dateInput.js'
import { dateToInputText, formatDigitsAsDate, isYearOnly, parseDateInput } from './dateInput.js'
describe('saisie d’une date (issue #77)', () => {
it('une année seule ou une date complète', () => {
@@ -24,3 +24,17 @@ describe('saisie d’une date (issue #77)', () => {
expect(isYearOnly('1950-03-12')).toBe(false)
})
})
describe('formatDigitsAsDate', () => {
it('pose les barres au-delà de quatre chiffres, pour le clavier numérique du téléphone', () => {
expect(formatDigitsAsDate('1950')).toBe('1950')
expect(formatDigitsAsDate('12031')).toBe('12/03/1')
expect(formatDigitsAsDate('12031950')).toBe('12/03/1950')
expect(parseDateInput(formatDigitsAsDate('12031950'))).toBe('1950-03-12')
})
it('laisse tel quel ce qui contient déjà un séparateur', () => {
expect(formatDigitsAsDate('12/03/19')).toBe('12/03/19')
expect(formatDigitsAsDate('1/3/1950')).toBe('1/3/1950')
})
})