@zevra/ui 0.6.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/dist/catalogue.d.ts.map +1 -1
- package/dist/catalogue.js +24 -1
- package/dist/catalogue.js.map +1 -1
- package/dist/css-inline.d.ts.map +1 -1
- package/dist/css-inline.js +1 -1
- package/dist/css-inline.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/select-menu.d.ts +25 -0
- package/dist/select-menu.d.ts.map +1 -0
- package/dist/select-menu.js +157 -0
- package/dist/select-menu.js.map +1 -0
- package/package.json +1 -1
- package/react/catalogue.tsx +37 -1
- package/react/css-inline.ts +1 -1
- package/react/index.ts +1 -0
- package/react/select-menu.tsx +250 -0
- package/src/base.css +3 -1
- package/src/components.css +54 -3
- package/src/forms.css +137 -12
- package/src/navigation.css +9 -0
- package/src/tokens.css +21 -5
- package/tokens/index.js +10 -4
package/react/index.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/components.css
CHANGED
|
@@ -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);
|
|
122
|
+
}
|
|
123
|
+
|
|
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);
|
|
105
136
|
}
|
|
106
137
|
|
|
107
|
-
.zv-link-arrow:hover
|
|
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);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.zv-link-arrow:hover svg,
|
|
156
|
+
.zv-link-arrow:focus-visible svg {
|
|
157
|
+
transform: translateX(4px);
|
|
115
158
|
}
|
|
116
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
|
-
|
|
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:
|
|
569
|
+
border-radius: 0;
|
|
519
570
|
overflow: hidden;
|
|
520
571
|
}
|
|
521
572
|
|
package/src/forms.css
CHANGED
|
@@ -183,6 +183,15 @@
|
|
|
183
183
|
border: 1px solid var(--line);
|
|
184
184
|
background: var(--white);
|
|
185
185
|
padding-inline: 13px;
|
|
186
|
+
/* La prise de focus s'ANIME (25/08) : sans transition, le cadre sautait
|
|
187
|
+
* d'un état à l'autre et le retour se lisait comme un défaut de rendu. */
|
|
188
|
+
transition:
|
|
189
|
+
border-color 140ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
|
190
|
+
box-shadow 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.zv-search:hover {
|
|
194
|
+
border-color: var(--accent-line);
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
/* DÉRIVÉ : la bordure de focus relevée sur .zv-input, portée par le cadre.
|
|
@@ -196,16 +205,42 @@
|
|
|
196
205
|
* · supprimer ce filet priverait la souris de tout retour, alors que le
|
|
197
206
|
* relevé lui en donne un sur les champs bordés.
|
|
198
207
|
* La redondance coûte moins cher que l'une ou l'autre perte. */
|
|
208
|
+
/* ⚠️ REPRIS le 25/08/2026 — la DA jugeait la prise de focus « boff ». Elle
|
|
209
|
+
* l'était : le cadre passait à --accent-line, un filet si pâle qu'à côté de
|
|
210
|
+
* l'anneau du champ intérieur il ne se voyait pas. Trois corrections :
|
|
211
|
+
* · le filet passe à --accent PLEIN, pas à sa version diluée ;
|
|
212
|
+
* · un halo --accent-soft de 3px double le trait — c'est lui qui rend le
|
|
213
|
+
* retour visible du coin de l'œil, sans déplacer un pixel de la mise en
|
|
214
|
+
* page (box-shadow ne prend pas de place) ;
|
|
215
|
+
* · la loupe s'allume avec le cadre : elle cesse d'être une invite grise
|
|
216
|
+
* pour devenir la marque du champ actif.
|
|
217
|
+
*
|
|
218
|
+
* L'anneau :focus-visible du champ INTÉRIEUR est neutralisé ici, et
|
|
219
|
+
* seulement ici : le cadre le remplace, et il le remplace pour la souris
|
|
220
|
+
* comme pour le clavier. C'est ce qui lève la double marque assumée jusque-là
|
|
221
|
+
* — un seul objet à l'écran, un seul retour. Le champ nu reste parfaitement
|
|
222
|
+
* repérable au clavier, puisque c'est le cadre entier qui s'allume. */
|
|
199
223
|
.zv-search:focus-within {
|
|
200
|
-
border-color: var(--accent
|
|
224
|
+
border-color: var(--accent);
|
|
225
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.zv-search:focus-within .zv-search__icon {
|
|
229
|
+
stroke: var(--accent);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.zv-search__input:focus-visible {
|
|
233
|
+
outline: none;
|
|
201
234
|
}
|
|
202
235
|
|
|
203
236
|
.zv-search__icon {
|
|
204
237
|
flex: none;
|
|
205
238
|
width: 13px;
|
|
206
239
|
height: 13px;
|
|
207
|
-
/* Même gris que le placeholder : la loupe est une invite, pas une donnée.
|
|
240
|
+
/* Même gris que le placeholder : la loupe est une invite, pas une donnée.
|
|
241
|
+
* Elle s'allume avec le cadre — voir :focus-within plus bas. */
|
|
208
242
|
stroke: var(--label);
|
|
243
|
+
transition: stroke 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
209
244
|
}
|
|
210
245
|
|
|
211
246
|
.zv-search__input {
|
|
@@ -279,6 +314,63 @@
|
|
|
279
314
|
max-width: 100%;
|
|
280
315
|
}
|
|
281
316
|
|
|
317
|
+
/* Ancre pleine largeur : le cas d'un CHAMP de formulaire, où la liste doit
|
|
318
|
+
* tomber exactement sous le contrôle et faire la même largeur que lui. La
|
|
319
|
+
* variante en ligne ci-dessus reste celle des filtres, où le déclencheur se
|
|
320
|
+
* dimensionne sur son libellé. */
|
|
321
|
+
.zv-select-anchor--block {
|
|
322
|
+
display: block;
|
|
323
|
+
width: 100%;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.zv-select-anchor--block .zv-menu {
|
|
327
|
+
width: 100%;
|
|
328
|
+
min-width: 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ─── Déclencheur de sélection DESSINÉE ─────────────────────────────────
|
|
332
|
+
* ⚠️ AJOUTÉ le 25/08/2026 : « le select, faut le designer jusque dans
|
|
333
|
+
* l'affichage de la liste » (DA, sur pièces).
|
|
334
|
+
*
|
|
335
|
+
* Le <select> natif (.zv-input--select) reste dans le paquet et garde son
|
|
336
|
+
* usage — mais il ne peut PAS être habillé : la liste déroulée est peinte
|
|
337
|
+
* par le système, pas par la page. Sur un macOS en mode sombre, elle sort
|
|
338
|
+
* en menu noir au milieu d'une interface claire. Aucun CSS ne corrige cela,
|
|
339
|
+
* c'est une limite du contrôle.
|
|
340
|
+
*
|
|
341
|
+
* D'où ce déclencheur, qui a l'apparence exacte d'un champ et ouvre le
|
|
342
|
+
* .zv-menu du paquet. Ce qu'il COÛTE, et qu'il faut assumer en le
|
|
343
|
+
* choisissant : le clavier, l'annonce et la liste native sont à réimplémenter
|
|
344
|
+
* — c'est fait dans le composant React (SelectMenu), pas dans ce fichier.
|
|
345
|
+
* Un gabarit qui pose ces classes à la main sans ce travail livre un select
|
|
346
|
+
* inutilisable au clavier. Dans ce cas, prendre le natif. */
|
|
347
|
+
.zv-input--trigger {
|
|
348
|
+
display: flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
justify-content: space-between;
|
|
351
|
+
gap: 10px;
|
|
352
|
+
text-align: left;
|
|
353
|
+
line-height: 1.4;
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
transition: border-color 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.zv-input--trigger:hover {
|
|
359
|
+
border-color: var(--accent-line);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* Ouvert, le déclencheur reste marqué : sans cela, la liste flotte au-dessus
|
|
363
|
+
* d'un champ qui a l'air au repos, et on ne sait plus lequel elle sert. */
|
|
364
|
+
.zv-input--trigger[aria-expanded='true'] {
|
|
365
|
+
border-color: var(--accent);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* Le placeholder d'un déclencheur est du texte, pas un ::placeholder : il
|
|
369
|
+
* lui faut donc sa propre teinte, celle du champ vide. */
|
|
370
|
+
.zv-input--trigger[data-vide='oui'] {
|
|
371
|
+
color: var(--label);
|
|
372
|
+
}
|
|
373
|
+
|
|
282
374
|
.zv-select {
|
|
283
375
|
display: inline-flex;
|
|
284
376
|
align-items: center;
|
|
@@ -384,37 +476,70 @@
|
|
|
384
476
|
* elle est portée par l'aplat plein et par le libellé accolé, pas par cette
|
|
385
477
|
* nuance-là. */
|
|
386
478
|
|
|
479
|
+
/* ⚠️ REDESSINÉ le 25/08/2026 — la DA a tranché « switch horrible » sur
|
|
480
|
+
* pièces, et elle avait raison sur trois points :
|
|
481
|
+
* · 28×16 avec un bouton rond de 12, c'est un jouet posé au milieu de
|
|
482
|
+
* contrôles carrés de 16px de haut ; la ligne de réglage n'avait aucune
|
|
483
|
+
* unité ;
|
|
484
|
+
* · la piste éteinte était --accent-line, un indigo très pâle : éteint et
|
|
485
|
+
* allumé se distinguaient par la SATURATION d'une même teinte, ce qui
|
|
486
|
+
* tombe au premier écran mal calibré et disparaît en daltonisme ;
|
|
487
|
+
* · le bouton rond contredisait l'arbitrage « carré » du même jour.
|
|
488
|
+
* La piste passe donc à 36×20, à angle vif, avec un filet qui la pose comme
|
|
489
|
+
* les champs voisins ; éteint elle est BLANCHE au filet gris, allumée elle
|
|
490
|
+
* est un aplat indigo plein. L'écart n'est plus une nuance, c'est un
|
|
491
|
+
* contraste. */
|
|
387
492
|
.zv-switch {
|
|
388
493
|
position: relative;
|
|
389
494
|
display: inline-block;
|
|
390
|
-
width:
|
|
391
|
-
height:
|
|
495
|
+
width: 36px;
|
|
496
|
+
height: 20px;
|
|
392
497
|
flex: none;
|
|
393
|
-
|
|
498
|
+
border: 1px solid var(--line);
|
|
499
|
+
background: var(--white);
|
|
394
500
|
/* La courbe est celle du paquet (motion.css, `--zv-easing`), recopiée en
|
|
395
501
|
* clair : forms.css ne dépend pas de motion.css et n'a pas à en dépendre
|
|
396
502
|
* pour une transition de couleur. Ce n'est PAS `ease` — le défaut d'un
|
|
397
503
|
* framework tiers n'a rien à faire dans le système. */
|
|
398
|
-
transition:
|
|
504
|
+
transition:
|
|
505
|
+
background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
|
506
|
+
border-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.zv-switch-hit:hover .zv-switch {
|
|
510
|
+
border-color: var(--accent-line);
|
|
399
511
|
}
|
|
400
512
|
|
|
401
513
|
.zv-switch--on {
|
|
514
|
+
border-color: var(--accent);
|
|
402
515
|
background: var(--accent);
|
|
403
516
|
}
|
|
404
517
|
|
|
518
|
+
.zv-switch-hit:hover .zv-switch--on {
|
|
519
|
+
border-color: var(--accent-deep);
|
|
520
|
+
background: var(--accent-deep);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* Bouton CARRÉ : le rond ici n'est pas une forme porteuse de sens (ce que
|
|
524
|
+
* sont la pastille d'état ou le radio), c'était un style — il tombe avec les
|
|
525
|
+
* autres rayons. Éteint il est gris comme un contrôle inactif, allumé il
|
|
526
|
+
* passe en blanc sur l'aplat indigo. */
|
|
405
527
|
.zv-switch__knob {
|
|
406
528
|
position: absolute;
|
|
407
529
|
inset-block-start: 2px;
|
|
408
530
|
inset-inline-start: 2px;
|
|
409
|
-
width:
|
|
410
|
-
height:
|
|
411
|
-
border-radius:
|
|
412
|
-
background: var(--
|
|
413
|
-
transition:
|
|
531
|
+
width: 14px;
|
|
532
|
+
height: 14px;
|
|
533
|
+
border-radius: 0;
|
|
534
|
+
background: var(--label);
|
|
535
|
+
transition:
|
|
536
|
+
inset-inline-start 150ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
|
537
|
+
background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
|
414
538
|
}
|
|
415
539
|
|
|
416
540
|
.zv-switch--on .zv-switch__knob {
|
|
417
|
-
inset-inline-start:
|
|
541
|
+
inset-inline-start: 18px;
|
|
542
|
+
background: var(--white);
|
|
418
543
|
}
|
|
419
544
|
|
|
420
545
|
/* Cible tactile portée par une enveloppe : agrandir la piste elle-même
|
package/src/navigation.css
CHANGED
|
@@ -429,6 +429,15 @@
|
|
|
429
429
|
background: var(--hairline);
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
/* ⚠️ Option SURVOLÉE AU CLAVIER — ajoutée le 25/08/2026 avec la sélection
|
|
433
|
+
* dessinée. Ce n'est ni :hover (la souris n'est pas dessus) ni :focus (le
|
|
434
|
+
* focus reste sur le déclencheur, c'est aria-activedescendant qui porte
|
|
435
|
+
* l'annonce). Sans cette classe, parcourir la liste aux flèches ne se voit
|
|
436
|
+
* pas : le lecteur d'écran suit, l'œil non. */
|
|
437
|
+
.zv-menu__item--survol {
|
|
438
|
+
background: var(--hairline);
|
|
439
|
+
}
|
|
440
|
+
|
|
432
441
|
.zv-menu__item:focus-visible {
|
|
433
442
|
outline-offset: -2px;
|
|
434
443
|
}
|
package/src/tokens.css
CHANGED
|
@@ -74,11 +74,27 @@
|
|
|
74
74
|
--lead: 18px;
|
|
75
75
|
--body: 15px;
|
|
76
76
|
|
|
77
|
-
/* Rayons
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
/* Rayons — ARBITRÉS À ZÉRO le 25/08/2026.
|
|
78
|
+
*
|
|
79
|
+
* La DA a tranché sur pièces : « très carré, ça fait pro et inspire
|
|
80
|
+
* confiance » ; le rond disait « flexible, jovial », qui n'est pas ce que
|
|
81
|
+
* vend Zevra. Le relevé donnait 5/3/10/16px, hérités d'un état antérieur
|
|
82
|
+
* de la charte — et déjà contredits sur place, puisque les champs de
|
|
83
|
+
* saisie, les étiquettes et les pastilles d'initiales étaient à angle vif
|
|
84
|
+
* depuis le premier jour. Les quatre tokens s'alignent sur eux.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ Les tokens RESTENT, à zéro : ils nomment quatre intentions
|
|
87
|
+
* distinctes (action, pastille, panneau flottant, carte). Le jour où la DA
|
|
88
|
+
* veut rendre de la rondeur à l'une d'elles, c'est une ligne ici — pas une
|
|
89
|
+
* chasse aux `border-radius` dans douze fichiers.
|
|
90
|
+
*
|
|
91
|
+
* ⚠️ Ce qui n'est PAS concerné : le rond de forme, qui porte du sens et
|
|
92
|
+
* non du style — pastille d'état, bouton d'interrupteur, radio, halo. Ils
|
|
93
|
+
* écrivent leur `border-radius` en clair, sans passer par un token. */
|
|
94
|
+
--r-btn: 0;
|
|
95
|
+
--r-pill: 0;
|
|
96
|
+
--r-panel: 0;
|
|
97
|
+
--r-card: 0;
|
|
82
98
|
|
|
83
99
|
/* Ombres */
|
|
84
100
|
--shadow-card: 0 2px 10px rgba(11, 16, 32, 0.05);
|
package/tokens/index.js
CHANGED
|
@@ -99,11 +99,17 @@ export const mesures = {
|
|
|
99
99
|
specFilet: '56px',
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Rayons — À ZÉRO depuis l'arbitrage DA du 25/08/2026 (« très carré, ça
|
|
103
|
+
* fait pro et inspire confiance »). Les quatre clés restent : elles nomment
|
|
104
|
+
* quatre intentions distinctes, et rendre de la rondeur à l'une d'elles
|
|
105
|
+
* doit rester une ligne à changer, pas une chasse dans douze fichiers.
|
|
106
|
+
* Le rond de FORME — pastille d'état, bouton d'interrupteur, radio, halo —
|
|
107
|
+
* ne passe pas par ici : il porte du sens, pas du style. */
|
|
102
108
|
export const rayons = {
|
|
103
|
-
btn: '
|
|
104
|
-
pill: '
|
|
105
|
-
panel: '
|
|
106
|
-
card: '
|
|
109
|
+
btn: '0',
|
|
110
|
+
pill: '0',
|
|
111
|
+
panel: '0',
|
|
112
|
+
card: '0',
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
export const ombres = {
|