@zevra/ui 0.42.0 → 0.43.1

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.
@@ -15,9 +15,11 @@
15
15
  // variables. Aucune classe nouvelle n'entre par cette porte : ce sont les
16
16
  // mêmes .zv-table / .zv-row / .zv-cell que le tableau écrit à la main.
17
17
 
18
- import type { ReactNode } from 'react'
18
+ import { Fragment, type CSSProperties, type ReactNode } from 'react'
19
19
  import {
20
20
  flexRender,
21
+ type Column,
22
+ type Header,
21
23
  type RowData,
22
24
  type Table as TableTanstack,
23
25
  } from '@tanstack/react-table'
@@ -40,11 +42,38 @@ declare module '@tanstack/react-table' {
40
42
  /** Largeur de la colonne dans la grille (syntaxe grid-template-columns).
41
43
  * Par défaut : `minmax(0, 1fr)`. */
42
44
  largeur?: string
45
+ /** Famille du filtre de colonne dans la grille (`DataGrid`). Déduite
46
+ * des valeurs si absente : nombre, date (Date ou ISO), choix (peu de
47
+ * valeurs qui se répètent), texte. `false` retire le filtre. */
48
+ filtre?: 'texte' | 'nombre' | 'date' | 'choix' | false
49
+ /** Classe de cellule supplémentaire, réservée au paquet (la case de
50
+ * sélection de la grille). Une app n'y écrit rien : le CSS du paquet
51
+ * est hors couche, une classe d'app y serait inerte. */
52
+ classe?: string
43
53
  }
44
54
  }
45
55
 
56
+ /** Ce que reçoit un rendu d'en-tête substitué (`celluleEntete`) : la cellule
57
+ * telle que le tableau l'aurait rendue, classes, style et contenu compris.
58
+ * Le substitut l'ENVELOPPE (une poignée, un comportement), il ne la refait
59
+ * pas : c'est ce qui garde l'en-tête identique d'un tableau à l'autre. */
60
+ export interface CelluleEnteteProps<T> {
61
+ entete: Header<T, unknown>
62
+ className: string
63
+ style?: CSSProperties
64
+ 'aria-sort'?: 'ascending' | 'descending'
65
+ children: ReactNode
66
+ }
67
+
46
68
  export interface DataTableProps<T> {
47
69
  table: TableTanstack<T>
70
+ /** Remplace le rendu de chaque cellule d'en-tête. Sert à la grille
71
+ * (poignées de déplacement et de largeur) ; un tableau simple n'en a
72
+ * pas besoin. */
73
+ celluleEntete?: (props: CelluleEnteteProps<T>) => ReactNode
74
+ /** Rangée posée SOUS les intitulés, avant les lignes : les filtres de
75
+ * colonne de la grille. */
76
+ sousEntete?: ReactNode
48
77
  /** Rendu à la place des lignes quand le modèle est vide. ⚠️ C'est la LIGNE
49
78
  * d'absence qu'on attend ici (filtre trop étroit), pas l'écran d'absence :
50
79
  * l'en-tête doit rester visible pour qu'on puisse élargir le filtre. */
@@ -52,20 +81,90 @@ export interface DataTableProps<T> {
52
81
  /** Appelé au clic sur une ligne. Sans lui, la ligne n'est pas cliquable :
53
82
  * et elle ne doit pas en avoir l'air. */
54
83
  onRowClick?: (ligne: T) => void
84
+ /** La ligne dont on regarde le détail (l'aperçu ouvert à côté de la
85
+ * liste). Elle porte un filet d'accent à gauche, distinct de la ligne
86
+ * COCHÉE, qui est une sélection pour agir. */
87
+ ligneActive?: (ligne: T) => boolean
55
88
  className?: string
56
89
  }
57
90
 
58
- export function DataTable<T>({ table, vide, onRowClick, className }: DataTableProps<T>) {
59
- const colonnes = table.getVisibleLeafColumns()
91
+ /** MODE GRILLE : activé par `enableColumnResizing`. Les largeurs viennent
92
+ * alors de TanStack en pixels (`size` = plancher, `columnSizing` = réglage
93
+ * de l'utilisateur), et les colonnes épinglées collent au bord du cadre.
94
+ * Sans ce réglage, le tableau rend exactement ce qu'il rendait avant. */
95
+ function piste<T>(c: Column<T, unknown>, regle: boolean): string {
96
+ const taille = c.getSize()
97
+ // Réglée ou épinglée : fixe. Sinon : un plancher, et une part de l'espace
98
+ // libre PROPORTIONNELLE au plancher, pour que les colonnes larges restent
99
+ // larges en s'étirant.
100
+ return regle || c.getIsPinned() ? `${taille}px` : `minmax(${taille}px, ${taille}fr)`
101
+ }
102
+
103
+ /** Classes et décalage d'une cellule épinglée (en-tête, filtres ou corps).
104
+ * Exporté pour la rangée de filtres de la grille, qui doit coller avec ses
105
+ * colonnes. */
106
+ export function epinglage<T>(
107
+ c: Column<T, unknown>,
108
+ table: TableTanstack<T>,
109
+ ): { classes: string | false; style?: CSSProperties } {
110
+ const cote = c.getIsPinned()
111
+ if (!cote) return { classes: false }
112
+ const voisines = cote === 'left' ? table.getLeftVisibleLeafColumns() : table.getRightVisibleLeafColumns()
113
+ const rang = voisines.findIndex((v) => v.id === c.id)
114
+ // Le bord : la dernière à gauche, la première à droite, celle qui touche
115
+ // ce qui défile.
116
+ const bord = cote === 'left' ? rang === voisines.length - 1 : rang === 0
117
+ const decalage =
118
+ cote === 'left'
119
+ ? { px: c.getStart('left'), gouttieres: rang }
120
+ : { px: c.getAfter('right'), gouttieres: voisines.length - 1 - rang }
121
+ const valeur = `calc(${decalage.px}px + ${decalage.gouttieres} * var(--zv-grille-gap))`
122
+ return {
123
+ classes: cx('zv-cell--pin', cote === 'right' && 'zv-cell--pin-droite', bord && 'zv-cell--pin-bord'),
124
+ style: cote === 'left' ? { left: valeur } : { right: valeur },
125
+ }
126
+ }
127
+
128
+ /** Les colonnes visibles DANS L'ORDRE DU RENDU : épinglées à gauche,
129
+ * centrales, épinglées à droite. C'est l'ordre des en-têtes et des cellules ;
130
+ * `getVisibleLeafColumns()`, lui, ignore l'épinglage, et un gabarit bâti
131
+ * dessus donne à la première colonne la largeur d'une autre. */
132
+ export function colonnesAffichees<T>(table: TableTanstack<T>): Column<T, unknown>[] {
133
+ return [
134
+ ...table.getLeftVisibleLeafColumns(),
135
+ ...table.getCenterVisibleLeafColumns(),
136
+ ...table.getRightVisibleLeafColumns(),
137
+ ]
138
+ }
139
+
140
+ export function DataTable<T>({
141
+ table,
142
+ vide,
143
+ onRowClick,
144
+ celluleEntete,
145
+ sousEntete,
146
+ ligneActive,
147
+ className,
148
+ }: DataTableProps<T>) {
149
+ const grille = table.options.enableColumnResizing === true
150
+ const reglages = table.getState().columnSizing
151
+ const colonnes = colonnesAffichees(table)
60
152
  const gabarit = colonnes
61
- .map((c) => c.columnDef.meta?.largeur ?? 'minmax(0, 1fr)')
153
+ .map((c) =>
154
+ grille ? piste(c, reglages[c.id] != null) : (c.columnDef.meta?.largeur ?? 'minmax(0, 1fr)'),
155
+ )
62
156
  .join(' ')
63
157
  const lignes = table.getRowModel().rows
158
+ const style: Record<string, string> = { '--zv-cols': gabarit }
159
+ if (grille) {
160
+ style['--zv-cols-somme'] = `${colonnes.reduce((s, c) => s + c.getSize(), 0)}px`
161
+ style['--zv-cols-n'] = String(colonnes.length)
162
+ }
64
163
 
65
164
  return (
66
165
  <div
67
166
  className={cx('zv-table', className)}
68
- style={{ ['--zv-cols']: gabarit } as React.CSSProperties}
167
+ style={style as CSSProperties}
69
168
  >
70
169
  {table.getHeaderGroups().map((groupe) => (
71
170
  <div className="zv-table__head" key={groupe.id}>
@@ -76,42 +175,61 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
76
175
  const contenu = entete.isPlaceholder
77
176
  ? null
78
177
  : flexRender(entete.column.columnDef.header, entete.getContext())
178
+ const pin = grille ? epinglage(entete.column, table) : { classes: false as const }
179
+ const ariaSort =
180
+ sens === 'asc' ? ('ascending' as const) : sens === 'desc' ? ('descending' as const) : undefined
181
+ const props: CelluleEnteteProps<T> = {
182
+ entete,
183
+ className: cx('zv-cell', registre === 'num' && 'zv-cell--num', pin.classes),
184
+ style: pin.style,
185
+ // La colonne triée est annoncée : sans aria-sort, le tri est
186
+ // une information purement visuelle.
187
+ 'aria-sort': ariaSort,
188
+ children: triable ? (
189
+ <button
190
+ type="button"
191
+ className="zv-sort"
192
+ onClick={entete.column.getToggleSortingHandler()}
193
+ >
194
+ {celluleEntete ? <span className="zv-colhead__label">{contenu}</span> : contenu}
195
+ <span className="zv-sort__mark" aria-hidden="true">
196
+ {sens === 'asc' ? '↑' : sens === 'desc' ? '↓' : '↕'}
197
+ </span>
198
+ </button>
199
+ ) : celluleEntete ? (
200
+ <span className="zv-colhead__label">{contenu}</span>
201
+ ) : (
202
+ contenu
203
+ ),
204
+ }
205
+ if (celluleEntete) return <Fragment key={entete.id}>{celluleEntete(props)}</Fragment>
79
206
  return (
80
207
  <div
81
208
  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
- }
209
+ className={props.className}
210
+ style={props.style}
211
+ aria-sort={props['aria-sort']}
88
212
  >
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
- )}
213
+ {props.children}
103
214
  </div>
104
215
  )
105
216
  })}
106
217
  </div>
107
218
  ))}
108
219
 
220
+ {sousEntete}
221
+
109
222
  {lignes.length === 0
110
223
  ? vide
111
224
  : lignes.map((ligne) => (
112
225
  <div
113
226
  key={ligne.id}
114
- className="zv-row"
227
+ className={cx(
228
+ 'zv-row',
229
+ ligne.getIsSelected() && 'zv-row--selected',
230
+ ligneActive?.(ligne.original) && 'zv-row--on',
231
+ )}
232
+ aria-current={ligneActive?.(ligne.original) || undefined}
115
233
  // Le clavier suit le clic : une ligne cliquable qui n'est pas
116
234
  // atteignable au clavier est une action inaccessible.
117
235
  role={onRowClick ? 'button' : undefined}
@@ -131,6 +249,7 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
131
249
  >
132
250
  {ligne.getVisibleCells().map((cellule) => {
133
251
  const meta = cellule.column.columnDef.meta
252
+ const pin = grille ? epinglage(cellule.column, table) : { classes: false as const }
134
253
  return (
135
254
  <div
136
255
  key={cellule.id}
@@ -138,7 +257,10 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
138
257
  'zv-cell',
139
258
  meta?.registre === 'nom' && 'zv-cell--name',
140
259
  meta?.registre === 'num' && 'zv-cell--num',
260
+ meta?.classe,
261
+ pin.classes,
141
262
  )}
263
+ style={pin.style}
142
264
  >
143
265
  {meta?.label && <span className="zv-cell__label">{meta.label}</span>}
144
266
  {flexRender(cellule.column.columnDef.cell, cellule.getContext())}