@zevra/ui 0.42.0 → 0.43.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.
@@ -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. */
@@ -55,17 +84,82 @@ export interface DataTableProps<T> {
55
84
  className?: string
56
85
  }
57
86
 
58
- export function DataTable<T>({ table, vide, onRowClick, className }: DataTableProps<T>) {
59
- const colonnes = table.getVisibleLeafColumns()
87
+ /** MODE GRILLE : activé par `enableColumnResizing`. Les largeurs viennent
88
+ * alors de TanStack en pixels (`size` = plancher, `columnSizing` = réglage
89
+ * de l'utilisateur), et les colonnes épinglées collent au bord du cadre.
90
+ * Sans ce réglage, le tableau rend exactement ce qu'il rendait avant. */
91
+ function piste<T>(c: Column<T, unknown>, regle: boolean): string {
92
+ const taille = c.getSize()
93
+ // Réglée ou épinglée : fixe. Sinon : un plancher, et une part de l'espace
94
+ // libre PROPORTIONNELLE au plancher, pour que les colonnes larges restent
95
+ // larges en s'étirant.
96
+ return regle || c.getIsPinned() ? `${taille}px` : `minmax(${taille}px, ${taille}fr)`
97
+ }
98
+
99
+ /** Classes et décalage d'une cellule épinglée (en-tête, filtres ou corps).
100
+ * Exporté pour la rangée de filtres de la grille, qui doit coller avec ses
101
+ * colonnes. */
102
+ export function epinglage<T>(
103
+ c: Column<T, unknown>,
104
+ table: TableTanstack<T>,
105
+ ): { classes: string | false; style?: CSSProperties } {
106
+ const cote = c.getIsPinned()
107
+ if (!cote) return { classes: false }
108
+ const voisines = cote === 'left' ? table.getLeftVisibleLeafColumns() : table.getRightVisibleLeafColumns()
109
+ const rang = voisines.findIndex((v) => v.id === c.id)
110
+ // Le bord : la dernière à gauche, la première à droite, celle qui touche
111
+ // ce qui défile.
112
+ const bord = cote === 'left' ? rang === voisines.length - 1 : rang === 0
113
+ const decalage =
114
+ cote === 'left'
115
+ ? { px: c.getStart('left'), gouttieres: rang }
116
+ : { px: c.getAfter('right'), gouttieres: voisines.length - 1 - rang }
117
+ const valeur = `calc(${decalage.px}px + ${decalage.gouttieres} * var(--zv-grille-gap))`
118
+ return {
119
+ classes: cx('zv-cell--pin', cote === 'right' && 'zv-cell--pin-droite', bord && 'zv-cell--pin-bord'),
120
+ style: cote === 'left' ? { left: valeur } : { right: valeur },
121
+ }
122
+ }
123
+
124
+ /** Les colonnes visibles DANS L'ORDRE DU RENDU : épinglées à gauche,
125
+ * centrales, épinglées à droite. C'est l'ordre des en-têtes et des cellules ;
126
+ * `getVisibleLeafColumns()`, lui, ignore l'épinglage, et un gabarit bâti
127
+ * dessus donne à la première colonne la largeur d'une autre. */
128
+ export function colonnesAffichees<T>(table: TableTanstack<T>): Column<T, unknown>[] {
129
+ return [
130
+ ...table.getLeftVisibleLeafColumns(),
131
+ ...table.getCenterVisibleLeafColumns(),
132
+ ...table.getRightVisibleLeafColumns(),
133
+ ]
134
+ }
135
+
136
+ export function DataTable<T>({
137
+ table,
138
+ vide,
139
+ onRowClick,
140
+ celluleEntete,
141
+ sousEntete,
142
+ className,
143
+ }: DataTableProps<T>) {
144
+ const grille = table.options.enableColumnResizing === true
145
+ const reglages = table.getState().columnSizing
146
+ const colonnes = colonnesAffichees(table)
60
147
  const gabarit = colonnes
61
- .map((c) => c.columnDef.meta?.largeur ?? 'minmax(0, 1fr)')
148
+ .map((c) =>
149
+ grille ? piste(c, reglages[c.id] != null) : (c.columnDef.meta?.largeur ?? 'minmax(0, 1fr)'),
150
+ )
62
151
  .join(' ')
63
152
  const lignes = table.getRowModel().rows
153
+ const style: Record<string, string> = { '--zv-cols': gabarit }
154
+ if (grille) {
155
+ style['--zv-cols-somme'] = `${colonnes.reduce((s, c) => s + c.getSize(), 0)}px`
156
+ style['--zv-cols-n'] = String(colonnes.length)
157
+ }
64
158
 
65
159
  return (
66
160
  <div
67
161
  className={cx('zv-table', className)}
68
- style={{ ['--zv-cols']: gabarit } as React.CSSProperties}
162
+ style={style as CSSProperties}
69
163
  >
70
164
  {table.getHeaderGroups().map((groupe) => (
71
165
  <div className="zv-table__head" key={groupe.id}>
@@ -76,42 +170,56 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
76
170
  const contenu = entete.isPlaceholder
77
171
  ? null
78
172
  : flexRender(entete.column.columnDef.header, entete.getContext())
173
+ const pin = grille ? epinglage(entete.column, table) : { classes: false as const }
174
+ const ariaSort =
175
+ sens === 'asc' ? ('ascending' as const) : sens === 'desc' ? ('descending' as const) : undefined
176
+ const props: CelluleEnteteProps<T> = {
177
+ entete,
178
+ className: cx('zv-cell', registre === 'num' && 'zv-cell--num', pin.classes),
179
+ style: pin.style,
180
+ // La colonne triée est annoncée : sans aria-sort, le tri est
181
+ // une information purement visuelle.
182
+ 'aria-sort': ariaSort,
183
+ children: triable ? (
184
+ <button
185
+ type="button"
186
+ className="zv-sort"
187
+ onClick={entete.column.getToggleSortingHandler()}
188
+ >
189
+ {celluleEntete ? <span className="zv-colhead__label">{contenu}</span> : contenu}
190
+ <span className="zv-sort__mark" aria-hidden="true">
191
+ {sens === 'asc' ? '↑' : sens === 'desc' ? '↓' : '↕'}
192
+ </span>
193
+ </button>
194
+ ) : celluleEntete ? (
195
+ <span className="zv-colhead__label">{contenu}</span>
196
+ ) : (
197
+ contenu
198
+ ),
199
+ }
200
+ if (celluleEntete) return <Fragment key={entete.id}>{celluleEntete(props)}</Fragment>
79
201
  return (
80
202
  <div
81
203
  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
- }
204
+ className={props.className}
205
+ style={props.style}
206
+ aria-sort={props['aria-sort']}
88
207
  >
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
- )}
208
+ {props.children}
103
209
  </div>
104
210
  )
105
211
  })}
106
212
  </div>
107
213
  ))}
108
214
 
215
+ {sousEntete}
216
+
109
217
  {lignes.length === 0
110
218
  ? vide
111
219
  : lignes.map((ligne) => (
112
220
  <div
113
221
  key={ligne.id}
114
- className="zv-row"
222
+ className={cx('zv-row', ligne.getIsSelected() && 'zv-row--selected')}
115
223
  // Le clavier suit le clic : une ligne cliquable qui n'est pas
116
224
  // atteignable au clavier est une action inaccessible.
117
225
  role={onRowClick ? 'button' : undefined}
@@ -131,6 +239,7 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
131
239
  >
132
240
  {ligne.getVisibleCells().map((cellule) => {
133
241
  const meta = cellule.column.columnDef.meta
242
+ const pin = grille ? epinglage(cellule.column, table) : { classes: false as const }
134
243
  return (
135
244
  <div
136
245
  key={cellule.id}
@@ -138,7 +247,10 @@ export function DataTable<T>({ table, vide, onRowClick, className }: DataTablePr
138
247
  'zv-cell',
139
248
  meta?.registre === 'nom' && 'zv-cell--name',
140
249
  meta?.registre === 'num' && 'zv-cell--num',
250
+ meta?.classe,
251
+ pin.classes,
141
252
  )}
253
+ style={pin.style}
142
254
  >
143
255
  {meta?.label && <span className="zv-cell__label">{meta.label}</span>}
144
256
  {flexRender(cellule.column.columnDef.cell, cellule.getContext())}