@zevra/ui 0.7.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/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. */