@zevra/ui 0.17.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/react/content.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  // Contenu — surfaces, badges et blocs éditoriaux. Sans état, donc sans
2
2
  // « use client ».
3
3
 
4
- import type { HTMLAttributes, ReactNode } from 'react'
4
+ import type { ElementType, HTMLAttributes, ReactNode } from 'react'
5
5
  import { cx } from './cx.js'
6
6
 
7
7
  type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
@@ -247,21 +247,61 @@ export function Faq({ className, ...reste }: Div & { className?: string }) {
247
247
  return <div className={cx('zv-faq', className)} {...reste} />
248
248
  }
249
249
 
250
- export interface FaqItemProps extends Div {
250
+ export interface FaqItemProps
251
+ extends Omit<HTMLAttributes<HTMLDetailsElement>, 'className' | 'title'> {
251
252
  question: ReactNode
253
+ /** Dépliée au chargement. Une par page au plus : si toutes le sont, le
254
+ * repli ne sert plus à rien et la page redevient un mur. */
255
+ ouvert?: boolean
252
256
  className?: string
253
257
  children?: ReactNode
254
258
  }
255
259
 
256
- /** Rendu ouvert, sans repli : la réponse est le contenu, la cacher derrière
257
- * un clic n'ajoute rien à cinq questions. Pour un vrai accordéon, c'est un
258
- * `<details>` — et il n'existe pas de style pour lui dans le paquet. */
259
- export function FaqItem({ question, className, children, ...reste }: FaqItemProps) {
260
+ /** Une question qui se replie. `<details>`, donc AUCUN JavaScript : elle
261
+ * s'ouvre au clic, à Entrée et à Espace, elle est annoncée comme repliable,
262
+ * et la recherche du navigateur (⌘F) l'ouvre toute seule pour montrer ce
263
+ * qu'elle a trouvé.
264
+ *
265
+ * ⚠️ La réponse reste DANS LE DOM quand la question est fermée — `details`
266
+ * la masque, il ne la retire pas. C'est ce qui permet à une page de porter
267
+ * un balisage `FAQPage` honnête : ce que le moteur lit est bien ce que le
268
+ * visiteur peut ouvrir. Un accordéon qui démonte son contenu annoncerait
269
+ * des réponses absentes.
270
+ *
271
+ * Le chevron fait partie du composant, comme celui de `Fold` et la flèche
272
+ * de `LinkArrow` : recopié à la main, il finit par diverger.
273
+ *
274
+ * `question` accepte un TITRE (`<h3>Ma question</h3>`) : une FAQ de vitrine
275
+ * veut ses questions dans le plan du document — c'est ce que lisent les
276
+ * moteurs, et ce que parcourt un lecteur d'écran qui saute de titre en
277
+ * titre. Le CSS lui retire ce que le navigateur lui met : le niveau reste
278
+ * au balisage, l'apparence au système. */
279
+ export function FaqItem({
280
+ question,
281
+ ouvert,
282
+ className,
283
+ children,
284
+ ...reste
285
+ }: FaqItemProps) {
260
286
  return (
261
- <div className={className} {...reste}>
262
- <p className="zv-faq__q">{question}</p>
287
+ <details className={cx('zv-faq__item', className)} open={ouvert} {...reste}>
288
+ <summary className="zv-faq__q">
289
+ {question}
290
+ <svg
291
+ className="zv-faq__chevron"
292
+ viewBox="0 0 24 24"
293
+ fill="none"
294
+ stroke="currentColor"
295
+ strokeWidth="2"
296
+ strokeLinecap="square"
297
+ strokeLinejoin="miter"
298
+ aria-hidden="true"
299
+ >
300
+ <path d="M6.67 9.33 L12 14.67 L17.33 9.33" />
301
+ </svg>
302
+ </summary>
263
303
  <p className="zv-faq__a">{children}</p>
264
- </div>
304
+ </details>
265
305
  )
266
306
  }
267
307
 
@@ -415,3 +455,274 @@ export function Callout({ titre, className, children, ...reste }: CalloutProps)
415
455
  </div>
416
456
  )
417
457
  }
458
+
459
+ export interface MediaProps extends Div {
460
+ /** Rapport du cadre. Quatre, et pas davantage : un cinquième se demande. */
461
+ ratio?: '16-9' | '4-3' | '1-1' | '3-2'
462
+ /**
463
+ * L'image est CONTENUE au lieu de remplir — un logo, un schéma, une pièce
464
+ * qu'on ne peut pas rogner. Le papier passe alors derrière : il restera du
465
+ * vide, autant qu'il soit choisi.
466
+ */
467
+ contient?: boolean
468
+ className?: string
469
+ children?: ReactNode
470
+ }
471
+
472
+ /**
473
+ * Cadre à rapport constant pour une image ou une vidéo.
474
+ *
475
+ * ⚠️ Le rapport vit sur le CADRE, jamais sur l'image : posé sur un enfant de
476
+ * grille aligné au centre, il ne se résout pas et la colonne s'effondre à
477
+ * zéro. Le cadre est donc un bloc à lui, que l'image remplit en absolu.
478
+ *
479
+ * Il ne charge rien et ne décode rien : un produit qui veut masquer le temps
480
+ * de décodage compose `Spinner` par-dessus.
481
+ */
482
+ export function Media({ ratio = '16-9', contient, className, children, ...reste }: MediaProps) {
483
+ return (
484
+ <div
485
+ className={cx('zv-media', `zv-media--${ratio}`, contient && 'zv-media--contient', className)}
486
+ {...reste}
487
+ >
488
+ {children}
489
+ </div>
490
+ )
491
+ }
492
+
493
+ export interface MediaImgProps
494
+ extends Omit<HTMLAttributes<HTMLImageElement>, 'className'> {
495
+ src: string
496
+ alt: string
497
+ className?: string
498
+ }
499
+
500
+ /**
501
+ * L'image d'un cadre. `alt` est OBLIGATOIRE — vide pour une image
502
+ * décorative, décrite sinon : c'est un choix, pas un oubli.
503
+ *
504
+ * Un produit qui optimise ses images (next/image) pose la classe
505
+ * `zv-media__img` sur son propre composant plutôt que d'employer celui-ci :
506
+ * le CSS est la source de vérité, la façade n'en est qu'une commodité.
507
+ */
508
+ export function MediaImg({ className, alt, ...reste }: MediaImgProps) {
509
+ return <img alt={alt} className={cx('zv-media__img', className)} {...reste} />
510
+ }
511
+
512
+ export interface MediaCoinProps extends Span {
513
+ /** Coin gauche plutôt que droit. */
514
+ gauche?: boolean
515
+ /** Coin haut plutôt que bas. */
516
+ haut?: boolean
517
+ className?: string
518
+ children?: ReactNode
519
+ }
520
+
521
+ /** Mention posée dans un coin du cadre : durée d'une vidéo, nombre de pages,
522
+ * réserve d'accès. Encre diluée, parce que le fond d'image est imprévisible. */
523
+ export function MediaCoin({ gauche, haut, className, children, ...reste }: MediaCoinProps) {
524
+ return (
525
+ <span
526
+ className={cx(
527
+ 'zv-media__coin',
528
+ gauche && 'zv-media__coin--gauche',
529
+ haut && 'zv-media__coin--haut',
530
+ className,
531
+ )}
532
+ {...reste}
533
+ >
534
+ {children}
535
+ </span>
536
+ )
537
+ }
538
+
539
+ /** Voile de lecture : un titre posé SUR une image n'est lisible qu'au-dessus
540
+ * d'un dégradé. Il ne capte pas le pointeur — le cadre reste cliquable. */
541
+ export function MediaVoile({ className, ...reste }: Span & { className?: string }) {
542
+ return <span aria-hidden="true" className={cx('zv-media__voile', className)} {...reste} />
543
+ }
544
+
545
+ /* ─── Page légale ──────────────────────────────────────────────────────
546
+ * La FORME d'un document contractuel. Le TEXTE vit dans @zevra/legal,
547
+ * versionné et archivé — ce composant n'en dépend pas : il décrit la forme
548
+ * qu'il sait rendre, et le typage structurel de TypeScript fait le reste.
549
+ * C'est délibéré : le design system ne doit pas dépendre du contenu, sinon
550
+ * une correction de clause deviendrait une livraison de charte. */
551
+
552
+ export interface BlocLegal {
553
+ type: 'paragraphe' | 'sous-titre' | 'liste' | 'encart' | 'definitions' | 'grille' | 'tableau'
554
+ texte?: string
555
+ ordonnee?: boolean
556
+ items?: string[]
557
+ ton?: 'clair' | 'important'
558
+ label?: string
559
+ entrees?: { cle: string; valeur: string }[]
560
+ cartes?: { titre: string; texte: string }[]
561
+ entetes?: string[]
562
+ lignes?: string[][]
563
+ }
564
+
565
+ export interface SectionLegale {
566
+ id: string
567
+ titre: string
568
+ icone?: string
569
+ blocs: BlocLegal[]
570
+ }
571
+
572
+ export interface DocumentLegalRendu {
573
+ titre: string
574
+ /** L'identifiant juridique (« cgvu-2026-02-22 »). AFFICHÉ, et ce n'est pas
575
+ * décoratif : c'est ce que l'utilisateur cite quand il conteste. */
576
+ version: string
577
+ applicableAu: string
578
+ essentiel?: { label: string; points: string[] }
579
+ preambule?: BlocLegal[]
580
+ sections: SectionLegale[]
581
+ contact?: string[]
582
+ }
583
+
584
+ /* ⚠️ POURQUOI dangerouslySetInnerHTML EST ACCEPTABLE ICI, ET SEULEMENT ICI.
585
+ * Le texte porte trois balises et trois seulement — <strong>, <em>,
586
+ * <a href> — qui ont un sens JURIDIQUE : une clause mise en avant par
587
+ * l'auteur, un renvoi vers un autre contrat. Les retirer appauvrirait le
588
+ * document ; les rendre en texte brut afficherait des chevrons.
589
+ * Ce contenu vient d'un dépôt versionné, JAMAIS d'une saisie utilisateur, et
590
+ * un test de @zevra/legal refuse toute autre balise. Ne pas reprendre ce
591
+ * motif pour du contenu dont on ne maîtrise pas l'origine. */
592
+ function Riche({ html, as: Balise = 'span', className }: { html: string; as?: ElementType; className?: string }) {
593
+ return <Balise className={className} dangerouslySetInnerHTML={{ __html: html }} />
594
+ }
595
+
596
+ function BlocRendu({ bloc }: { bloc: BlocLegal }) {
597
+ switch (bloc.type) {
598
+ case 'paragraphe':
599
+ return <Riche as="p" className="zv-body" html={bloc.texte ?? ''} />
600
+ case 'sous-titre':
601
+ return <h3 className="zv-card-title">{bloc.texte}</h3>
602
+ case 'liste': {
603
+ const Liste = bloc.ordonnee ? 'ol' : 'ul'
604
+ return (
605
+ <Liste className="zv-legal__liste">
606
+ {(bloc.items ?? []).map((item, i) => (
607
+ <Riche key={i} as="li" html={item} />
608
+ ))}
609
+ </Liste>
610
+ )
611
+ }
612
+ case 'encart':
613
+ // L'encart « En clair » COMMENTE la clause, il ne la remplace pas :
614
+ // d'où une surface d'accompagnement, jamais l'aplat d'une alerte.
615
+ return (
616
+ <aside
617
+ className={cx('zv-legal__encart', bloc.ton === 'important' && 'zv-legal__encart--important')}
618
+ >
619
+ {bloc.label && <span className="zv-legal__encart-label">{bloc.label}</span>}
620
+ <Riche as="p" className="zv-body zv-body--dense" html={bloc.texte ?? ''} />
621
+ </aside>
622
+ )
623
+ case 'definitions':
624
+ return (
625
+ <Rows>
626
+ {(bloc.entrees ?? []).map((e) => (
627
+ <RowsItem key={e.cle} cle={e.cle}>
628
+ <Riche html={e.valeur} />
629
+ </RowsItem>
630
+ ))}
631
+ </Rows>
632
+ )
633
+ case 'grille':
634
+ return (
635
+ <IndexList cols>
636
+ {(bloc.cartes ?? []).map((c, i) => (
637
+ <IndexItem key={c.titre} num={String(i + 1).padStart(2, '0')}>
638
+ <h4 className="zv-card-title">{c.titre}</h4>
639
+ <Riche as="p" className="zv-body zv-body--dense" html={c.texte} />
640
+ </IndexItem>
641
+ ))}
642
+ </IndexList>
643
+ )
644
+ case 'tableau':
645
+ return (
646
+ <table className="zv-legal__table">
647
+ {(bloc.entetes ?? []).length > 0 && (
648
+ <thead>
649
+ <tr>
650
+ {(bloc.entetes ?? []).map((e) => (
651
+ <th key={e}>{e}</th>
652
+ ))}
653
+ </tr>
654
+ </thead>
655
+ )}
656
+ <tbody>
657
+ {(bloc.lignes ?? []).map((ligne, i) => (
658
+ <tr key={i}>
659
+ {ligne.map((cellule, j) => (
660
+ <Riche key={j} as="td" html={cellule} />
661
+ ))}
662
+ </tr>
663
+ ))}
664
+ </tbody>
665
+ </table>
666
+ )
667
+ default:
668
+ return null
669
+ }
670
+ }
671
+
672
+ /** Le rendu d'un document contractuel. Le sommaire ne fait PAS partie du
673
+ * composant : il se pose à côté avec `Rail`, dont les ancres sont les `id`
674
+ * de section — c'est ce qui permet de le placer en marge sur la vitrine et
675
+ * de l'omettre dans un tunnel d'inscription.
676
+ *
677
+ * ⚠️ Ne jamais tronquer un document contractuel derrière un « voir plus » :
678
+ * un texte qu'on accepte doit être lisible en entier à l'endroit où on
679
+ * l'accepte. */
680
+ export function LegalPage({
681
+ document,
682
+ className,
683
+ ...reste
684
+ }: Div & { document: DocumentLegalRendu; className?: string }) {
685
+ return (
686
+ <article className={cx('zv-legal', className)} {...reste}>
687
+ <header className="zv-legal__tete">
688
+ <h1 className="zv-h2 zv-h2--sm">{document.titre}</h1>
689
+ <span className="zv-mono zv-mono--micro">
690
+ EN VIGUEUR AU {document.applicableAu} · VERSION {document.version}
691
+ </span>
692
+ </header>
693
+
694
+ {document.essentiel && document.essentiel.points.length > 0 && (
695
+ // Résumé, donc sans valeur contractuelle : il est présenté comme un
696
+ // encart, jamais comme le corps du document.
697
+ <Callout titre={document.essentiel.label}>
698
+ <ul className="zv-legal__liste">
699
+ {document.essentiel.points.map((p, i) => (
700
+ <Riche key={i} as="li" html={p} />
701
+ ))}
702
+ </ul>
703
+ </Callout>
704
+ )}
705
+
706
+ {(document.preambule ?? []).map((bloc, i) => (
707
+ <BlocRendu key={i} bloc={bloc} />
708
+ ))}
709
+
710
+ {document.sections.map((section) => (
711
+ <section key={section.id} id={section.id} className="zv-legal__section">
712
+ <h2 className="zv-h3">{section.titre}</h2>
713
+ {section.blocs.map((bloc, i) => (
714
+ <BlocRendu key={i} bloc={bloc} />
715
+ ))}
716
+ </section>
717
+ ))}
718
+
719
+ {(document.contact ?? []).length > 0 && (
720
+ <footer className="zv-legal__tete">
721
+ {(document.contact ?? []).map((ligne, i) => (
722
+ <Riche key={i} as="p" className="zv-body zv-body--dense" html={ligne} />
723
+ ))}
724
+ </footer>
725
+ )}
726
+ </article>
727
+ )
728
+ }