mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 07:50:58 +00:00
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:
1 parent
2dec945fa3
commit
f8d4b4113d
14 files changed
+233
-45
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user