@zevra/ui 0.6.0 → 0.8.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.
@@ -0,0 +1,153 @@
1
+ // Tableau piloté par TanStack Table.
2
+ //
3
+ // ⚠️ CE FICHIER N'EST PAS DANS LE BARREL, et c'est délibéré :
4
+ // `@tanstack/react-table` est une dépendance OPTIONNELLE. L'importer depuis
5
+ // `@zevra/ui/react` casserait toutes les apps qui ne l'ont pas installée —
6
+ // une landing n'a aucun tableau. D'où le sous-chemin dédié :
7
+ //
8
+ // import { DataTable } from '@zevra/ui/react/table'
9
+ //
10
+ // POURQUOI TANSTACK plutôt que notre propre tri/pagination : tri multi-colonnes,
11
+ // filtres, groupement, virtualisation, sélection de lignes — c'est un travail
12
+ // de plusieurs mois que personne ici n'a envie de refaire, et mal. TanStack est
13
+ // HEADLESS : il ne rend rien, il calcule des modèles de lignes. Ce fichier lui
14
+ // donne l'apparence du paquet, exactement comme shadcn le fait avec ses
15
+ // variables. Aucune classe nouvelle n'entre par cette porte : ce sont les
16
+ // mêmes .zv-table / .zv-row / .zv-cell que le tableau écrit à la main.
17
+
18
+ import type { ReactNode } from 'react'
19
+ import {
20
+ flexRender,
21
+ type RowData,
22
+ type Table as TableTanstack,
23
+ } from '@tanstack/react-table'
24
+ import { cx } from './cx.js'
25
+
26
+ // Le pont entre les deux mondes : une colonne déclare ici son registre
27
+ // d'affichage, et le composant le traduit en classes du paquet. C'est ce qui
28
+ // évite d'avoir à écrire une cellule sur mesure pour aligner un nombre.
29
+ declare module '@tanstack/react-table' {
30
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
31
+ interface ColumnMeta<TData extends RowData, TValue> {
32
+ /** `nom` : colonne d'identité, la seule en encre pleine.
33
+ * `num` : chiffres alignés à droite, en chasse tabulaire. */
34
+ registre?: 'nom' | 'num'
35
+ /** Intitulé qui REMPLACE l'en-tête sous le point de rupture, où les
36
+ * colonnes s'empilent et où l'en-tête disparaît. */
37
+ label?: string
38
+ /** Unité accolée au nombre, en plus petit. */
39
+ unite?: ReactNode
40
+ /** Largeur de la colonne dans la grille (syntaxe grid-template-columns).
41
+ * Par défaut : `minmax(0, 1fr)`. */
42
+ largeur?: string
43
+ }
44
+ }
45
+
46
+ export interface DataTableProps<T> {
47
+ table: TableTanstack<T>
48
+ /** Rendu à la place des lignes quand le modèle est vide. ⚠️ C'est la LIGNE
49
+ * d'absence qu'on attend ici (filtre trop étroit), pas l'écran d'absence :
50
+ * l'en-tête doit rester visible pour qu'on puisse élargir le filtre. */
51
+ vide?: ReactNode
52
+ /** Appelé au clic sur une ligne. Sans lui, la ligne n'est pas cliquable —
53
+ * et elle ne doit pas en avoir l'air. */
54
+ onRowClick?: (ligne: T) => void
55
+ className?: string
56
+ }
57
+
58
+ export function DataTable<T>({ table, vide, onRowClick, className }: DataTableProps<T>) {
59
+ const colonnes = table.getVisibleLeafColumns()
60
+ const gabarit = colonnes
61
+ .map((c) => c.columnDef.meta?.largeur ?? 'minmax(0, 1fr)')
62
+ .join(' ')
63
+ const lignes = table.getRowModel().rows
64
+
65
+ return (
66
+ <div
67
+ className={cx('zv-table', className)}
68
+ style={{ ['--zv-cols']: gabarit } as React.CSSProperties}
69
+ >
70
+ {table.getHeaderGroups().map((groupe) => (
71
+ <div className="zv-table__head" key={groupe.id}>
72
+ {groupe.headers.map((entete) => {
73
+ const triable = entete.column.getCanSort()
74
+ const sens = entete.column.getIsSorted()
75
+ const registre = entete.column.columnDef.meta?.registre
76
+ const contenu = entete.isPlaceholder
77
+ ? null
78
+ : flexRender(entete.column.columnDef.header, entete.getContext())
79
+ return (
80
+ <div
81
+ key={entete.id}
82
+ className={cx('zv-cell', registre === 'num' && 'zv-cell--num')}
83
+ // La colonne triée est annoncée : sans aria-sort, le tri est
84
+ // une information purement visuelle.
85
+ aria-sort={
86
+ sens === 'asc' ? 'ascending' : sens === 'desc' ? 'descending' : undefined
87
+ }
88
+ >
89
+ {triable ? (
90
+ <button
91
+ type="button"
92
+ className="zv-sort"
93
+ onClick={entete.column.getToggleSortingHandler()}
94
+ >
95
+ {contenu}
96
+ <span className="zv-sort__mark" aria-hidden="true">
97
+ {sens === 'asc' ? '↑' : sens === 'desc' ? '↓' : '↕'}
98
+ </span>
99
+ </button>
100
+ ) : (
101
+ contenu
102
+ )}
103
+ </div>
104
+ )
105
+ })}
106
+ </div>
107
+ ))}
108
+
109
+ {lignes.length === 0
110
+ ? vide
111
+ : lignes.map((ligne) => (
112
+ <div
113
+ key={ligne.id}
114
+ className="zv-row"
115
+ // Le clavier suit le clic : une ligne cliquable qui n'est pas
116
+ // atteignable au clavier est une action inaccessible.
117
+ role={onRowClick ? 'button' : undefined}
118
+ tabIndex={onRowClick ? 0 : undefined}
119
+ style={onRowClick ? undefined : { cursor: 'default' }}
120
+ onClick={onRowClick ? () => onRowClick(ligne.original) : undefined}
121
+ onKeyDown={
122
+ onRowClick
123
+ ? (e) => {
124
+ if (e.key === 'Enter' || e.key === ' ') {
125
+ e.preventDefault()
126
+ onRowClick(ligne.original)
127
+ }
128
+ }
129
+ : undefined
130
+ }
131
+ >
132
+ {ligne.getVisibleCells().map((cellule) => {
133
+ const meta = cellule.column.columnDef.meta
134
+ return (
135
+ <div
136
+ key={cellule.id}
137
+ className={cx(
138
+ 'zv-cell',
139
+ meta?.registre === 'nom' && 'zv-cell--name',
140
+ meta?.registre === 'num' && 'zv-cell--num',
141
+ )}
142
+ >
143
+ {meta?.label && <span className="zv-cell__label">{meta.label}</span>}
144
+ {flexRender(cellule.column.columnDef.cell, cellule.getContext())}
145
+ {meta?.unite && <span className="zv-cell__unit">{meta.unite}</span>}
146
+ </div>
147
+ )
148
+ })}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )
153
+ }
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'
@@ -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);
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
- 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
 
package/src/data.css CHANGED
@@ -156,6 +156,51 @@
156
156
  * contient aucun, et les inventer ferait apparaître un fond de plus dans un
157
157
  * système qui n'en a que deux (blanc, survol). */
158
158
 
159
+ /* ─── En-tête triable ──────────────────────────────────────────────────
160
+ * ⚠️ HORS RELEVÉ — ajouté le 25/08/2026 avec l'adaptateur TanStack Table.
161
+ * Aucun tableau de la production n'est triable ; l'intitulé y est du texte
162
+ * inerte. Un en-tête qui trie DOIT être un <button> — c'est la seule façon
163
+ * qu'il soit atteignable au clavier et annoncé comme actionnable.
164
+ *
165
+ * `font: inherit` plutôt qu'une police redite : l'en-tête a déjà la sienne
166
+ * (mono, capitales, interlettrée), et un <button> retomberait sur celle du
167
+ * navigateur. Hériter ne fait entrer aucune valeur nouvelle. */
168
+
169
+ .zv-sort {
170
+ display: inline-flex;
171
+ align-items: center;
172
+ gap: 5px;
173
+ border: 0;
174
+ background: none;
175
+ padding: 0;
176
+ font: inherit;
177
+ letter-spacing: inherit;
178
+ text-transform: inherit;
179
+ color: inherit;
180
+ cursor: pointer;
181
+ }
182
+
183
+ .zv-sort:hover {
184
+ color: var(--accent);
185
+ }
186
+
187
+ .zv-sort:focus-visible {
188
+ /* L'en-tête colle au bord du cadre : l'anneau de base.css y serait rogné. */
189
+ outline-offset: -2px;
190
+ }
191
+
192
+ /* La marque de tri reste TOUJOURS posée, même sur une colonne non triée
193
+ * (elle vaut alors ↕) : si elle n'apparaissait qu'au tri, la largeur de la
194
+ * colonne changerait au clic et toute la ligne d'en-tête danserait. */
195
+ .zv-sort__mark {
196
+ font-size: var(--mono-sm);
197
+ color: var(--label);
198
+ }
199
+
200
+ .zv-sort:hover .zv-sort__mark {
201
+ color: var(--accent);
202
+ }
203
+
159
204
  /* ─── Cellules ─────────────────────────────────────────────────────────
160
205
  * Trois registres seulement : le texte courant, le nombre, et l'intitulé qui
161
206
  * remplace l'en-tête quand celui-ci disparaît. */