@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.
- 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/package.json +18 -3
- package/react/css-inline.ts +1 -1
- package/react/data-table.tsx +153 -0
- package/src/data.css +45 -0
|
@@ -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. */
|