@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.
- 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/data-table.d.ts +30 -0
- package/dist/data-table.d.ts.map +1 -0
- package/dist/data-table.js +35 -0
- package/dist/data-table.js.map +1 -0
- 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 +18 -3
- package/react/catalogue.tsx +37 -1
- package/react/css-inline.ts +1 -1
- package/react/data-table.tsx +153 -0
- 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/data.css +45 -0
- package/src/forms.css +137 -12
- package/src/navigation.css +9 -0
- package/src/tokens.css +21 -5
- package/tokens/index.js +10 -4
|
@@ -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
|
@@ -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/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. */
|