@zevra/ui 0.5.0 → 0.7.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/index.ts CHANGED
@@ -38,5 +38,6 @@ export * from './icon.js'
38
38
  export * from './layout.js'
39
39
  export * from './navigation.js'
40
40
  export * from './overlays.js'
41
+ export * from './select-menu.js'
41
42
  export * from './surfaces.js'
42
43
  export * from './typography.js'
package/react/layout.tsx CHANGED
@@ -52,11 +52,12 @@ export function Grid12({ className, ...reste }: Div & { className?: string }) {
52
52
  return <div className={cx('zv-grid-12', className)} {...reste} />
53
53
  }
54
54
 
55
- export type Span12 = 3 | 4 | 6 | 8 | 9
55
+ export type Span12 = 3 | 4 | 5 | 6 | 7 | 8 | 9
56
56
 
57
- /** ⚠️ Seules 3, 4, 6, 8 et 9 existent : ce sont les découpes employées par la
58
- * vitrine. En ajouter une revient à ajouter une mise en page, pas une
59
- * utilité — c'est une décision de DA. */
57
+ /** ⚠️ Seules ces sept portées existent : ce sont les découpes employées par
58
+ * la vitrine (5+7 vient du prototype experience.html — le glyphe en 5, le
59
+ * discours en 7). En ajouter une revient à ajouter une mise en page, pas
60
+ * une utilité — c'est une décision de DA. */
60
61
  export function Col({
61
62
  span,
62
63
  className,
@@ -0,0 +1,250 @@
1
+ 'use client'
2
+
3
+ // Sélection DESSINÉE — le déclencheur a l'apparence d'un champ, et la liste
4
+ // est peinte par le paquet.
5
+ //
6
+ // ⚠️ POURQUOI CE COMPOSANT EXISTE, ALORS QUE `Select` (natif) EST LÀ.
7
+ // Un <select> natif ne peut pas être habillé : sa liste déroulée est peinte
8
+ // par le SYSTÈME, pas par la page. Sur un macOS en mode sombre elle sort en
9
+ // menu noir au milieu d'une interface claire — aucun CSS ne corrige cela.
10
+ //
11
+ // ⚠️ CE QUE ÇA COÛTE, ET QUI EST PAYÉ ICI. En remplaçant le contrôle natif,
12
+ // on perd le clavier, l'annonce au lecteur d'écran et la liste système. Tout
13
+ // est réimplémenté dans ce fichier : rôles ARIA de combobox/listbox,
14
+ // navigation aux flèches, Origine/Fin, Échap, recherche au clavier, fermeture
15
+ // au clic extérieur et restitution du focus. C'est la raison pour laquelle il
16
+ // n'existe PAS de version « CSS seule » de ce composant : les classes posées
17
+ // à la main sans ce travail livrent un select inutilisable au clavier.
18
+ //
19
+ // Quand préférer `Select` (natif) : un formulaire long sur mobile, où la
20
+ // liste système est plus rapide à manipuler que n'importe quelle liste
21
+ // maison, et où le clavier logiciel s'adapte tout seul.
22
+
23
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
24
+ import type { ReactNode } from 'react'
25
+ import { cx } from './cx.js'
26
+
27
+ export interface OptionSelect<T extends string = string> {
28
+ valeur: T
29
+ label: ReactNode
30
+ /** Texte employé pour la recherche au clavier et l'affichage du
31
+ * déclencheur quand `label` n'est pas une chaîne. */
32
+ texte?: string
33
+ desactive?: boolean
34
+ }
35
+
36
+ export interface SelectMenuProps<T extends string = string> {
37
+ options: readonly OptionSelect<T>[]
38
+ valeur: T | null
39
+ onChange: (valeur: T) => void
40
+ /** Affiché quand rien n'est choisi. */
41
+ placeholder?: string
42
+ /** Identifiant du déclencheur — à faire correspondre au `htmlFor` du champ. */
43
+ id?: string
44
+ /** Nom du contrôle, quand aucun intitulé visible ne le désigne. */
45
+ label?: string
46
+ desactive?: boolean
47
+ erreur?: boolean
48
+ className?: string
49
+ }
50
+
51
+ function texteDe(o: OptionSelect | undefined): string {
52
+ if (!o) return ''
53
+ return o.texte ?? (typeof o.label === 'string' ? o.label : String(o.valeur))
54
+ }
55
+
56
+ export function SelectMenu<T extends string = string>({
57
+ options,
58
+ valeur,
59
+ onChange,
60
+ placeholder = 'Choisir…',
61
+ id,
62
+ label,
63
+ desactive,
64
+ erreur,
65
+ className,
66
+ }: SelectMenuProps<T>) {
67
+ const auto = useId()
68
+ const idListe = `${id ?? auto}-liste`
69
+ const [ouvert, setOuvert] = useState(false)
70
+ // L'option SURVOLÉE au clavier, distincte de l'option CHOISIE : c'est elle
71
+ // que `aria-activedescendant` désigne, et elle ne devient un choix qu'à
72
+ // Entrée. Les confondre ferait valider au premier coup de flèche.
73
+ const [actif, setActif] = useState(0)
74
+ const ancre = useRef<HTMLDivElement>(null)
75
+ const declencheur = useRef<HTMLButtonElement>(null)
76
+ const liste = useRef<HTMLDivElement>(null)
77
+ // La saisie au clavier (« ré » → Rédacteur) s'accumule et retombe après
78
+ // une seconde : c'est le comportement d'un <select> natif, et le perdre
79
+ // se remarque tout de suite sur une liste longue.
80
+ const frappe = useRef<{ texte: string; quand: number }>({ texte: '', quand: 0 })
81
+
82
+ const indexChoisi = options.findIndex((o) => o.valeur === valeur)
83
+ const choisi = indexChoisi >= 0 ? options[indexChoisi] : undefined
84
+
85
+ const ouvrir = useCallback(() => {
86
+ if (desactive) return
87
+ // On s'ouvre SUR l'option courante, pas sur la première : dans une liste
88
+ // de vingt entrées, repartir du haut oblige à retrouver sa place.
89
+ setActif(indexChoisi >= 0 ? indexChoisi : 0)
90
+ setOuvert(true)
91
+ }, [desactive, indexChoisi])
92
+
93
+ const fermer = useCallback((rendreLeFocus = true) => {
94
+ setOuvert(false)
95
+ if (rendreLeFocus) declencheur.current?.focus()
96
+ }, [])
97
+
98
+ const choisir = useCallback(
99
+ (i: number) => {
100
+ const o = options[i]
101
+ if (!o || o.desactive) return
102
+ onChange(o.valeur)
103
+ fermer()
104
+ },
105
+ [options, onChange, fermer],
106
+ )
107
+
108
+ // Clic extérieur. `pointerdown` et non `click` : un clic qui commence
109
+ // dehors et finit dedans ne doit pas rouvrir ce qu'il vient de fermer.
110
+ useEffect(() => {
111
+ if (!ouvert) return
112
+ const doc = ancre.current?.ownerDocument ?? document
113
+ function dehors(e: PointerEvent) {
114
+ if (!ancre.current?.contains(e.target as Node)) setOuvert(false)
115
+ }
116
+ doc.addEventListener('pointerdown', dehors)
117
+ return () => doc.removeEventListener('pointerdown', dehors)
118
+ }, [ouvert])
119
+
120
+ // L'option active reste visible quand on parcourt une liste plus haute que
121
+ // son cadre.
122
+ useEffect(() => {
123
+ if (!ouvert) return
124
+ const el = liste.current?.children[actif] as HTMLElement | undefined
125
+ el?.scrollIntoView({ block: 'nearest' })
126
+ }, [ouvert, actif])
127
+
128
+ function suivant(depuis: number, pas: number): number {
129
+ const n = options.length
130
+ for (let k = 1; k <= n; k++) {
131
+ const i = (depuis + pas * k + n * n) % n
132
+ if (!options[i]?.desactive) return i
133
+ }
134
+ return depuis
135
+ }
136
+
137
+ function onKeyDown(e: React.KeyboardEvent) {
138
+ if (desactive) return
139
+ if (!ouvert) {
140
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
141
+ e.preventDefault()
142
+ ouvrir()
143
+ }
144
+ return
145
+ }
146
+ switch (e.key) {
147
+ case 'Escape':
148
+ e.preventDefault()
149
+ e.stopPropagation()
150
+ fermer()
151
+ return
152
+ case 'Enter':
153
+ case ' ':
154
+ e.preventDefault()
155
+ choisir(actif)
156
+ return
157
+ case 'ArrowDown':
158
+ e.preventDefault()
159
+ setActif((i) => suivant(i, 1))
160
+ return
161
+ case 'ArrowUp':
162
+ e.preventDefault()
163
+ setActif((i) => suivant(i, -1))
164
+ return
165
+ case 'Home':
166
+ e.preventDefault()
167
+ setActif(suivant(-1, 1))
168
+ return
169
+ case 'End':
170
+ e.preventDefault()
171
+ setActif(suivant(options.length, -1))
172
+ return
173
+ case 'Tab':
174
+ // Tab valide et sort : c'est ce que fait un select natif, et sortir
175
+ // sans rien retenir surprend.
176
+ choisir(actif)
177
+ return
178
+ default:
179
+ break
180
+ }
181
+ if (e.key.length === 1) {
182
+ const maintenant = Date.now()
183
+ const texte =
184
+ (maintenant - frappe.current.quand < 1000 ? frappe.current.texte : '') +
185
+ e.key.toLowerCase()
186
+ frappe.current = { texte, quand: maintenant }
187
+ const i = options.findIndex(
188
+ (o) => !o.desactive && texteDe(o).toLowerCase().startsWith(texte),
189
+ )
190
+ if (i >= 0) setActif(i)
191
+ }
192
+ }
193
+
194
+ return (
195
+ <div
196
+ ref={ancre}
197
+ className={cx('zv-select-anchor', 'zv-select-anchor--block', className)}
198
+ >
199
+ <button
200
+ ref={declencheur}
201
+ id={id}
202
+ type="button"
203
+ role="combobox"
204
+ aria-expanded={ouvert}
205
+ aria-controls={idListe}
206
+ aria-haspopup="listbox"
207
+ aria-label={label}
208
+ aria-invalid={erreur || undefined}
209
+ aria-activedescendant={ouvert ? `${idListe}-${actif}` : undefined}
210
+ disabled={desactive}
211
+ data-vide={choisi ? undefined : 'oui'}
212
+ className="zv-input zv-input--trigger"
213
+ onClick={() => (ouvert ? fermer() : ouvrir())}
214
+ onKeyDown={onKeyDown}
215
+ >
216
+ <span className="zv-trunc">{choisi ? choisi.label : placeholder}</span>
217
+ <span className="zv-select__chevron" aria-hidden="true">
218
+ ▾
219
+ </span>
220
+ </button>
221
+
222
+ {ouvert && (
223
+ <div ref={liste} id={idListe} role="listbox" className="zv-menu">
224
+ {options.map((o, i) => (
225
+ <button
226
+ key={o.valeur}
227
+ id={`${idListe}-${i}`}
228
+ type="button"
229
+ role="option"
230
+ aria-selected={o.valeur === valeur}
231
+ aria-disabled={o.desactive || undefined}
232
+ // ⚠️ tabIndex -1 : le focus ne quitte JAMAIS le déclencheur.
233
+ // C'est ce qui permet à aria-activedescendant de porter
234
+ // l'annonce, et à la frappe de continuer d'arriver au clavier.
235
+ tabIndex={-1}
236
+ className={cx(
237
+ 'zv-menu__item',
238
+ i === actif && !o.desactive && 'zv-menu__item--survol',
239
+ )}
240
+ onPointerEnter={() => !o.desactive && setActif(i)}
241
+ onClick={() => choisir(i)}
242
+ >
243
+ {o.label}
244
+ </button>
245
+ ))}
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ }
package/src/base.css CHANGED
@@ -273,7 +273,9 @@ a:not([class]):hover {
273
273
  width: var(--spec-filet);
274
274
  height: 2px;
275
275
  background: var(--spectre);
276
- border-radius: 1px;
276
+ /* Angle vif comme tout le reste depuis le 25/08 — sur 2px de haut, le
277
+ * rayon d'1px du relevé ne se voyait de toute façon pas. */
278
+ border-radius: 0;
277
279
  }
278
280
 
279
281
  /* Bas de page : filet raccourci. */
@@ -94,7 +94,23 @@
94
94
  * à 16px dans DesignSystem.html. Tracés : M5 12h14 et M13 6l6 6-6 6, sur
95
95
  * un viewBox 0 0 24 24, stroke currentColor, épaisseur 2, bouts arrondis. */
96
96
 
97
+ /* ⚠️ REPRIS le 25/08/2026 — la DA le jugeait « pas ouf ». Le diagnostic :
98
+ * c'était un lien bleu avec un chevron collé, sans rien qui le distingue
99
+ * d'un lien de texte, et sans aucune récompense au survol. Le mouvement du
100
+ * paquet est réservé à la vitrine et piloté au défilement ; il n'y avait
101
+ * donc PAS de vocabulaire pour un lien d'action.
102
+ *
103
+ * Trois ajouts, tous dessinés dans les tokens existants :
104
+ * · un FILET qui se déploie sous le libellé au survol, de gauche à droite
105
+ * (scaleX sur une origine gauche) — pas un `text-decoration`, qui ne
106
+ * s'anime pas et coupe les jambages ;
107
+ * · la flèche avance de 4px, ce qui donne au lien sa direction ;
108
+ * · le libellé passe en --accent-deep.
109
+ * Le tout est repeint en une seule transition, donc coupé net par
110
+ * prefers-reduced-motion (base.css) — l'état final reste lisible sans. */
111
+
97
112
  .zv-link-arrow {
113
+ position: relative;
98
114
  display: inline-flex;
99
115
  align-items: center;
100
116
  gap: 8px;
@@ -102,22 +118,56 @@
102
118
  font-weight: 600;
103
119
  color: var(--accent);
104
120
  text-decoration: none;
121
+ transition: color 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
105
122
  }
106
123
 
107
- .zv-link-arrow:hover {
124
+ /* Le filet est un pseudo-élément et non une bordure : une bordure
125
+ * occuperait de la place et ferait sauter la ligne de base au survol. */
126
+ .zv-link-arrow::after {
127
+ content: '';
128
+ position: absolute;
129
+ inset-inline: 0;
130
+ bottom: -3px;
131
+ height: 1px;
132
+ background: currentColor;
133
+ transform: scaleX(0);
134
+ transform-origin: left center;
135
+ transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
136
+ }
137
+
138
+ .zv-link-arrow:hover,
139
+ .zv-link-arrow:focus-visible {
108
140
  color: var(--accent-deep);
109
141
  }
110
142
 
143
+ .zv-link-arrow:hover::after,
144
+ .zv-link-arrow:focus-visible::after {
145
+ transform: scaleX(1);
146
+ }
147
+
111
148
  .zv-link-arrow svg {
112
149
  width: 15px;
113
150
  height: 15px;
114
151
  flex: none;
152
+ transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
115
153
  }
116
154
 
155
+ .zv-link-arrow:hover svg,
156
+ .zv-link-arrow:focus-visible svg {
157
+ transform: translateX(4px);
158
+ }
159
+
160
+ /* Sur la bande manifeste : le filet suit `currentColor`, il n'y a donc rien
161
+ * à redéclarer — c'est tout l'intérêt de l'avoir posé en currentColor. */
117
162
  .zv-link-arrow--on-dark {
118
163
  color: var(--white);
119
164
  }
120
165
 
166
+ .zv-link-arrow--on-dark:hover,
167
+ .zv-link-arrow--on-dark:focus-visible {
168
+ color: var(--accent-on-dark);
169
+ }
170
+
121
171
  /* ─── Badges et puces ───────────────────────────────────────────────────*/
122
172
 
123
173
  /* Pastille pleine : numéro de pièce, catégorie de section. */
@@ -148,7 +198,8 @@
148
198
  color: var(--accent-deep);
149
199
  background: var(--white);
150
200
  border: 1px solid var(--accent-line);
151
- border-radius: 4px;
201
+ /* La puce de source est une pastille : elle suit le token, désormais à 0. */
202
+ border-radius: var(--r-pill);
152
203
  padding: 6px 10px;
153
204
  }
154
205
 
@@ -515,7 +566,7 @@
515
566
  .zv-progress {
516
567
  height: 3px;
517
568
  background: var(--hairline);
518
- border-radius: 2px;
569
+ border-radius: 0;
519
570
  overflow: hidden;
520
571
  }
521
572
 
@@ -524,8 +575,182 @@
524
575
  background: var(--spectre);
525
576
  }
526
577
 
578
+
579
+ /* ─── Blocs éditoriaux ──────────────────────────────────────────────────
580
+ * HORS RELEVÉ — ajoutés le 24/08/2026 à la demande de la DA (règle de
581
+ * variété : jamais deux blocs de même forme qui se suivent, 80 % de formes
582
+ * différentes par page). Quatre façons de poser du contenu SANS carte,
583
+ * pour que les grilles de cartes redeviennent un choix et non un réflexe.
584
+ * Dessinés dans les tokens existants, rien de nouveau dans la palette. */
585
+
586
+ /* Rangées à filets : un libellé fort, un contenu, un trait fin entre
587
+ * chaque rangée. Pour les listes où l'alignement fait la lecture
588
+ * (profils rencontrés, prestations, caractéristiques). */
589
+ .zv-rows {
590
+ display: flex;
591
+ flex-direction: column;
592
+ }
593
+
594
+ .zv-rows__row {
595
+ display: flex;
596
+ gap: 24px;
597
+ align-items: baseline;
598
+ flex-wrap: wrap;
599
+ padding: 18px 0;
600
+ border-top: 1px solid var(--line);
601
+ }
602
+
603
+ .zv-rows__row:first-child {
604
+ border-top: none;
605
+ }
606
+
607
+ .zv-rows__key {
608
+ font-weight: 600;
609
+ flex: 0 0 280px;
610
+ }
611
+
612
+ .zv-rows__val {
613
+ flex: 1;
614
+ min-width: 260px;
615
+ /* C'est le corps de texte du paquet — le token, pas sa valeur recopiée. */
616
+ font-size: var(--body);
617
+ line-height: 1.6;
618
+ color: var(--muted);
619
+ }
620
+
621
+ /* Sommaire numéroté : le numéro en display léger, le contenu à droite,
622
+ * un filet entre les entrées. Pour les suites longues qui étoufferaient
623
+ * en cartes (offres détaillées, programmes). */
624
+ .zv-index {
625
+ display: flex;
626
+ flex-direction: column;
627
+ }
628
+
629
+ .zv-index__item {
630
+ display: flex;
631
+ gap: 28px;
632
+ align-items: flex-start;
633
+ padding: 28px 0;
634
+ border-top: 1px solid var(--line);
635
+ }
636
+
637
+ .zv-index__item:first-child {
638
+ border-top: none;
639
+ padding-top: 0;
640
+ }
641
+
642
+ .zv-index__num {
643
+ font-family: var(--font-display);
644
+ font-weight: 300;
645
+ font-size: 40px;
646
+ line-height: 1;
647
+ letter-spacing: -0.02em;
648
+ color: var(--accent-deep);
649
+ flex: 0 0 64px;
650
+ }
651
+
652
+ .zv-index__body {
653
+ flex: 1;
654
+ min-width: 0;
655
+ }
656
+
657
+ /* Variante en colonnes : les mêmes entrées numérotées, posées en grille
658
+ * de trois, sans filets — l'espace fait la séparation. */
659
+ .zv-index--cols {
660
+ display: grid;
661
+ grid-template-columns: repeat(3, minmax(0, 1fr));
662
+ gap: 40px 32px;
663
+ }
664
+
665
+ .zv-index--cols .zv-index__item {
666
+ flex-direction: column;
667
+ gap: 10px;
668
+ padding: 0;
669
+ border-top: none;
670
+ }
671
+
672
+ .zv-index--cols .zv-index__num {
673
+ flex: none;
674
+ font-size: 32px;
675
+ color: var(--accent-line);
676
+ }
677
+
678
+ /* Déroulé vertical : le rail et ses points, à la verticale. La déclinaison
679
+ * verticale de zv-steps, pour les méthodes qui se lisent comme un récit.
680
+ * Les points reprennent les couleurs des points de méthode (l'un des
681
+ * trois emplois autorisés du spectre). */
682
+ .zv-flow {
683
+ position: relative;
684
+ display: flex;
685
+ flex-direction: column;
686
+ gap: 36px;
687
+ padding-left: 32px;
688
+ }
689
+
690
+ .zv-flow::before {
691
+ content: '';
692
+ position: absolute;
693
+ left: 5px;
694
+ top: 8px;
695
+ bottom: 8px;
696
+ width: 2px;
697
+ background: var(--line);
698
+ }
699
+
700
+ .zv-flow__step {
701
+ position: relative;
702
+ --zv-flow-dot: var(--iris);
703
+ }
704
+
705
+ .zv-flow__step::before {
706
+ content: '';
707
+ position: absolute;
708
+ left: -32px;
709
+ top: 5px;
710
+ width: 12px;
711
+ height: 12px;
712
+ border-radius: 50%;
713
+ background: var(--zv-flow-dot);
714
+ box-shadow:
715
+ 0 0 0 4px var(--paper),
716
+ 0 0 0 5px var(--accent-line);
717
+ }
718
+
719
+ .zv-flow__step--violet {
720
+ --zv-flow-dot: var(--violet);
721
+ }
722
+
723
+ .zv-flow__step--cyan {
724
+ --zv-flow-dot: var(--cyan);
725
+ }
726
+
727
+ /* Encart d'appel : une bande claire sur fond indigo dilué, un titre, une
728
+ * action. Pour l'offre transverse qui doit se voir sans crier
729
+ * (remise Campus, renvoi vers une autre page). */
730
+ .zv-callout {
731
+ display: flex;
732
+ align-items: center;
733
+ justify-content: space-between;
734
+ gap: 24px;
735
+ flex-wrap: wrap;
736
+ background: var(--accent-soft);
737
+ border: 1px solid var(--accent-line);
738
+ border-radius: var(--r-card);
739
+ padding: 32px 36px;
740
+ }
741
+
742
+ .zv-callout__title {
743
+ margin: 0;
744
+ font-family: var(--font-display);
745
+ font-weight: 300;
746
+ letter-spacing: -0.01em;
747
+ font-size: 26px;
748
+ line-height: 1.2;
749
+ }
750
+
527
751
  /* ─── Mobile ────────────────────────────────────────────────────────────*/
528
752
 
753
+
529
754
  @media (max-width: 767px) {
530
755
  .zv-btn--sm {
531
756
  height: 36px;
@@ -549,4 +774,31 @@
549
774
  .zv-inset {
550
775
  max-width: none;
551
776
  }
777
+
778
+ .zv-rows__key {
779
+ flex-basis: 100%;
780
+ }
781
+
782
+ .zv-index__item {
783
+ gap: 18px;
784
+ padding: 22px 0;
785
+ }
786
+
787
+ .zv-index__num {
788
+ flex-basis: 44px;
789
+ font-size: 30px;
790
+ }
791
+
792
+ .zv-index--cols {
793
+ grid-template-columns: minmax(0, 1fr);
794
+ gap: 28px;
795
+ }
796
+
797
+ .zv-callout {
798
+ padding: 24px 22px;
799
+ }
800
+
801
+ .zv-callout__title {
802
+ font-size: 21px;
803
+ }
552
804
  }