@zevra/ui 0.17.0 → 0.20.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'>
@@ -415,3 +415,274 @@ export function Callout({ titre, className, children, ...reste }: CalloutProps)
415
415
  </div>
416
416
  )
417
417
  }
418
+
419
+ export interface MediaProps extends Div {
420
+ /** Rapport du cadre. Quatre, et pas davantage : un cinquième se demande. */
421
+ ratio?: '16-9' | '4-3' | '1-1' | '3-2'
422
+ /**
423
+ * L'image est CONTENUE au lieu de remplir — un logo, un schéma, une pièce
424
+ * qu'on ne peut pas rogner. Le papier passe alors derrière : il restera du
425
+ * vide, autant qu'il soit choisi.
426
+ */
427
+ contient?: boolean
428
+ className?: string
429
+ children?: ReactNode
430
+ }
431
+
432
+ /**
433
+ * Cadre à rapport constant pour une image ou une vidéo.
434
+ *
435
+ * ⚠️ Le rapport vit sur le CADRE, jamais sur l'image : posé sur un enfant de
436
+ * grille aligné au centre, il ne se résout pas et la colonne s'effondre à
437
+ * zéro. Le cadre est donc un bloc à lui, que l'image remplit en absolu.
438
+ *
439
+ * Il ne charge rien et ne décode rien : un produit qui veut masquer le temps
440
+ * de décodage compose `Spinner` par-dessus.
441
+ */
442
+ export function Media({ ratio = '16-9', contient, className, children, ...reste }: MediaProps) {
443
+ return (
444
+ <div
445
+ className={cx('zv-media', `zv-media--${ratio}`, contient && 'zv-media--contient', className)}
446
+ {...reste}
447
+ >
448
+ {children}
449
+ </div>
450
+ )
451
+ }
452
+
453
+ export interface MediaImgProps
454
+ extends Omit<HTMLAttributes<HTMLImageElement>, 'className'> {
455
+ src: string
456
+ alt: string
457
+ className?: string
458
+ }
459
+
460
+ /**
461
+ * L'image d'un cadre. `alt` est OBLIGATOIRE — vide pour une image
462
+ * décorative, décrite sinon : c'est un choix, pas un oubli.
463
+ *
464
+ * Un produit qui optimise ses images (next/image) pose la classe
465
+ * `zv-media__img` sur son propre composant plutôt que d'employer celui-ci :
466
+ * le CSS est la source de vérité, la façade n'en est qu'une commodité.
467
+ */
468
+ export function MediaImg({ className, alt, ...reste }: MediaImgProps) {
469
+ return <img alt={alt} className={cx('zv-media__img', className)} {...reste} />
470
+ }
471
+
472
+ export interface MediaCoinProps extends Span {
473
+ /** Coin gauche plutôt que droit. */
474
+ gauche?: boolean
475
+ /** Coin haut plutôt que bas. */
476
+ haut?: boolean
477
+ className?: string
478
+ children?: ReactNode
479
+ }
480
+
481
+ /** Mention posée dans un coin du cadre : durée d'une vidéo, nombre de pages,
482
+ * réserve d'accès. Encre diluée, parce que le fond d'image est imprévisible. */
483
+ export function MediaCoin({ gauche, haut, className, children, ...reste }: MediaCoinProps) {
484
+ return (
485
+ <span
486
+ className={cx(
487
+ 'zv-media__coin',
488
+ gauche && 'zv-media__coin--gauche',
489
+ haut && 'zv-media__coin--haut',
490
+ className,
491
+ )}
492
+ {...reste}
493
+ >
494
+ {children}
495
+ </span>
496
+ )
497
+ }
498
+
499
+ /** Voile de lecture : un titre posé SUR une image n'est lisible qu'au-dessus
500
+ * d'un dégradé. Il ne capte pas le pointeur — le cadre reste cliquable. */
501
+ export function MediaVoile({ className, ...reste }: Span & { className?: string }) {
502
+ return <span aria-hidden="true" className={cx('zv-media__voile', className)} {...reste} />
503
+ }
504
+
505
+ /* ─── Page légale ──────────────────────────────────────────────────────
506
+ * La FORME d'un document contractuel. Le TEXTE vit dans @zevra/legal,
507
+ * versionné et archivé — ce composant n'en dépend pas : il décrit la forme
508
+ * qu'il sait rendre, et le typage structurel de TypeScript fait le reste.
509
+ * C'est délibéré : le design system ne doit pas dépendre du contenu, sinon
510
+ * une correction de clause deviendrait une livraison de charte. */
511
+
512
+ export interface BlocLegal {
513
+ type: 'paragraphe' | 'sous-titre' | 'liste' | 'encart' | 'definitions' | 'grille' | 'tableau'
514
+ texte?: string
515
+ ordonnee?: boolean
516
+ items?: string[]
517
+ ton?: 'clair' | 'important'
518
+ label?: string
519
+ entrees?: { cle: string; valeur: string }[]
520
+ cartes?: { titre: string; texte: string }[]
521
+ entetes?: string[]
522
+ lignes?: string[][]
523
+ }
524
+
525
+ export interface SectionLegale {
526
+ id: string
527
+ titre: string
528
+ icone?: string
529
+ blocs: BlocLegal[]
530
+ }
531
+
532
+ export interface DocumentLegalRendu {
533
+ titre: string
534
+ /** L'identifiant juridique (« cgvu-2026-02-22 »). AFFICHÉ, et ce n'est pas
535
+ * décoratif : c'est ce que l'utilisateur cite quand il conteste. */
536
+ version: string
537
+ applicableAu: string
538
+ essentiel?: { label: string; points: string[] }
539
+ preambule?: BlocLegal[]
540
+ sections: SectionLegale[]
541
+ contact?: string[]
542
+ }
543
+
544
+ /* ⚠️ POURQUOI dangerouslySetInnerHTML EST ACCEPTABLE ICI, ET SEULEMENT ICI.
545
+ * Le texte porte trois balises et trois seulement — <strong>, <em>,
546
+ * <a href> — qui ont un sens JURIDIQUE : une clause mise en avant par
547
+ * l'auteur, un renvoi vers un autre contrat. Les retirer appauvrirait le
548
+ * document ; les rendre en texte brut afficherait des chevrons.
549
+ * Ce contenu vient d'un dépôt versionné, JAMAIS d'une saisie utilisateur, et
550
+ * un test de @zevra/legal refuse toute autre balise. Ne pas reprendre ce
551
+ * motif pour du contenu dont on ne maîtrise pas l'origine. */
552
+ function Riche({ html, as: Balise = 'span', className }: { html: string; as?: ElementType; className?: string }) {
553
+ return <Balise className={className} dangerouslySetInnerHTML={{ __html: html }} />
554
+ }
555
+
556
+ function BlocRendu({ bloc }: { bloc: BlocLegal }) {
557
+ switch (bloc.type) {
558
+ case 'paragraphe':
559
+ return <Riche as="p" className="zv-body" html={bloc.texte ?? ''} />
560
+ case 'sous-titre':
561
+ return <h3 className="zv-card-title">{bloc.texte}</h3>
562
+ case 'liste': {
563
+ const Liste = bloc.ordonnee ? 'ol' : 'ul'
564
+ return (
565
+ <Liste className="zv-legal__liste">
566
+ {(bloc.items ?? []).map((item, i) => (
567
+ <Riche key={i} as="li" html={item} />
568
+ ))}
569
+ </Liste>
570
+ )
571
+ }
572
+ case 'encart':
573
+ // L'encart « En clair » COMMENTE la clause, il ne la remplace pas :
574
+ // d'où une surface d'accompagnement, jamais l'aplat d'une alerte.
575
+ return (
576
+ <aside
577
+ className={cx('zv-legal__encart', bloc.ton === 'important' && 'zv-legal__encart--important')}
578
+ >
579
+ {bloc.label && <span className="zv-legal__encart-label">{bloc.label}</span>}
580
+ <Riche as="p" className="zv-body zv-body--dense" html={bloc.texte ?? ''} />
581
+ </aside>
582
+ )
583
+ case 'definitions':
584
+ return (
585
+ <Rows>
586
+ {(bloc.entrees ?? []).map((e) => (
587
+ <RowsItem key={e.cle} cle={e.cle}>
588
+ <Riche html={e.valeur} />
589
+ </RowsItem>
590
+ ))}
591
+ </Rows>
592
+ )
593
+ case 'grille':
594
+ return (
595
+ <IndexList cols>
596
+ {(bloc.cartes ?? []).map((c, i) => (
597
+ <IndexItem key={c.titre} num={String(i + 1).padStart(2, '0')}>
598
+ <h4 className="zv-card-title">{c.titre}</h4>
599
+ <Riche as="p" className="zv-body zv-body--dense" html={c.texte} />
600
+ </IndexItem>
601
+ ))}
602
+ </IndexList>
603
+ )
604
+ case 'tableau':
605
+ return (
606
+ <table className="zv-legal__table">
607
+ {(bloc.entetes ?? []).length > 0 && (
608
+ <thead>
609
+ <tr>
610
+ {(bloc.entetes ?? []).map((e) => (
611
+ <th key={e}>{e}</th>
612
+ ))}
613
+ </tr>
614
+ </thead>
615
+ )}
616
+ <tbody>
617
+ {(bloc.lignes ?? []).map((ligne, i) => (
618
+ <tr key={i}>
619
+ {ligne.map((cellule, j) => (
620
+ <Riche key={j} as="td" html={cellule} />
621
+ ))}
622
+ </tr>
623
+ ))}
624
+ </tbody>
625
+ </table>
626
+ )
627
+ default:
628
+ return null
629
+ }
630
+ }
631
+
632
+ /** Le rendu d'un document contractuel. Le sommaire ne fait PAS partie du
633
+ * composant : il se pose à côté avec `Rail`, dont les ancres sont les `id`
634
+ * de section — c'est ce qui permet de le placer en marge sur la vitrine et
635
+ * de l'omettre dans un tunnel d'inscription.
636
+ *
637
+ * ⚠️ Ne jamais tronquer un document contractuel derrière un « voir plus » :
638
+ * un texte qu'on accepte doit être lisible en entier à l'endroit où on
639
+ * l'accepte. */
640
+ export function LegalPage({
641
+ document,
642
+ className,
643
+ ...reste
644
+ }: Div & { document: DocumentLegalRendu; className?: string }) {
645
+ return (
646
+ <article className={cx('zv-legal', className)} {...reste}>
647
+ <header className="zv-legal__tete">
648
+ <h1 className="zv-h2 zv-h2--sm">{document.titre}</h1>
649
+ <span className="zv-mono zv-mono--micro">
650
+ EN VIGUEUR AU {document.applicableAu} · VERSION {document.version}
651
+ </span>
652
+ </header>
653
+
654
+ {document.essentiel && document.essentiel.points.length > 0 && (
655
+ // Résumé, donc sans valeur contractuelle : il est présenté comme un
656
+ // encart, jamais comme le corps du document.
657
+ <Callout titre={document.essentiel.label}>
658
+ <ul className="zv-legal__liste">
659
+ {document.essentiel.points.map((p, i) => (
660
+ <Riche key={i} as="li" html={p} />
661
+ ))}
662
+ </ul>
663
+ </Callout>
664
+ )}
665
+
666
+ {(document.preambule ?? []).map((bloc, i) => (
667
+ <BlocRendu key={i} bloc={bloc} />
668
+ ))}
669
+
670
+ {document.sections.map((section) => (
671
+ <section key={section.id} id={section.id} className="zv-legal__section">
672
+ <h2 className="zv-h3">{section.titre}</h2>
673
+ {section.blocs.map((bloc, i) => (
674
+ <BlocRendu key={i} bloc={bloc} />
675
+ ))}
676
+ </section>
677
+ ))}
678
+
679
+ {(document.contact ?? []).length > 0 && (
680
+ <footer className="zv-legal__tete">
681
+ {(document.contact ?? []).map((ligne, i) => (
682
+ <Riche key={i} as="p" className="zv-body zv-body--dense" html={ligne} />
683
+ ))}
684
+ </footer>
685
+ )}
686
+ </article>
687
+ )
688
+ }