@benjosivo/table-query 1.0.4 → 1.2.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.
package/README.md CHANGED
@@ -83,6 +83,24 @@ app.post('/api/commandes', wrapRouteHandler(async (req, res) => {
83
83
  | `'HIDE'` | Colonne sans filtre |
84
84
  | `null` | Colonne sans filtre particulier |
85
85
 
86
+ ### Règles de mise en forme (`formattingRules`)
87
+
88
+ `reqTableQuery` peut transporter des règles de couleur jusqu'au client. Elles sont renvoyées **telles quelles** dans `payload.formattingRules` : le serveur ne les évalue jamais et elles ne touchent **jamais** au SQL.
89
+
90
+ ```js
91
+ const { data } = await reqTableQuery({
92
+ query: `SELECT ...`,
93
+ req,
94
+ paramFilter: [null, 'MULTISELECT', 'SLIDER'],
95
+ formattingRules: [
96
+ { column: 'statut', operator: '=', value: 'en retard', style: { backgroundColor: '#ffd7d7' } },
97
+ { column: 'montant', operator: '>', value: 10000, target: 'cell', style: { fontWeight: 'bold' } },
98
+ ],
99
+ });
100
+ ```
101
+
102
+ Les règles sont indexées **par nom de colonne** (contrairement à `paramFilter`, qui est positionnel). Une règle dont la `column` ne correspond à aucune colonne de la requête est ignorée, avec un `console.warn` — jamais une erreur.
103
+
86
104
  ### Le cache (`useCache`)
87
105
 
88
106
  Le cache est activé **par appel**, pas globalement :
@@ -140,6 +158,14 @@ function CommandesTable() {
140
158
  | `selectionColumnPosition` | `number \| 'start' \| 'end'` (défaut `'start'`) | Position de la colonne de checkbox parmi les colonnes visibles |
141
159
  | `selectedIds` | `any[]` | Sélection contrôlée par le parent (voir plus bas) |
142
160
  | `onSelectionChange` | `(ids, rows) => void` | Appelée à chaque changement de sélection, avec les ids et les lignes complètes |
161
+ | `formattingRules` | `FormattingRule[]` | Règles de mise en forme conditionnelle (voir « Mise en forme conditionnelle ») |
162
+ | `getRowFormatting` | `(row, i) => {style?, className?}` | Échappatoire : style de ligne calculé en JS, appliqué après toutes les règles |
163
+ | `getCellFormatting` | `(col, value, row, i) => {style?, className?}` | Idem, par cellule |
164
+ | `formattingEditor` | `boolean` (défaut `false`) | Affiche le bouton « Mise en forme » pour l'utilisateur final |
165
+ | `formattingStorageKey` | `string` | Sauvegarde les règles de l'utilisateur dans `localStorage` sous cette clé |
166
+ | `onFormattingRulesChange` | `(rules) => void` | Appelée à chaque modification des règles de l'utilisateur |
167
+ | `initialUserFormattingRules` | `FormattingRule[]` | Réhydrate les règles utilisateur depuis ton backend (prioritaire sur `localStorage`) |
168
+ | `formattingButtonLabel` | `string` (défaut `'Mise en forme'`) | Libellé du bouton de la barre d'outils |
143
169
 
144
170
  ### Sélection de lignes (checkbox)
145
171
 
@@ -185,6 +211,122 @@ const supprimer = async () => {
185
211
  />
186
212
  ```
187
213
 
214
+ ### Mise en forme conditionnelle (couleurs)
215
+
216
+ Colorer des lignes ou des cellules selon leurs valeurs, façon Excel. Les règles sont des objets **sérialisables** : elles peuvent être écrites en dur, stockées en base, ou renvoyées par l'API.
217
+
218
+ ```tsx
219
+ <DataTable
220
+ fetchData={fetchCommandes}
221
+ formattingRules={[
222
+ // Toute la ligne en rouge pâle quand le statut vaut "en retard"
223
+ { column: 'statut', operator: '=', value: 'en retard', style: { backgroundColor: '#ffd7d7' } },
224
+ // Seulement la cellule "montant" en gras vert au-dessus de 10 000
225
+ { column: 'montant', operator: '>', value: 10000, target: 'cell', style: { color: '#0a7d32', fontWeight: 'bold' } },
226
+ // Deux colonnes précises, via une classe CSS de ton app
227
+ { column: 'livraison', operator: 'isNull', target: ['livraison', 'transporteur'], className: 'a-completer' },
228
+ ]}
229
+ />
230
+ ```
231
+
232
+ La librairie ne livre **aucun CSS** : `style` (inline) fonctionne sans configuration, `className` suppose que ton app définit la classe.
233
+
234
+ #### Écrire une règle (`FormattingRule`)
235
+
236
+ | Champ | Type | Description |
237
+ |---|---|---|
238
+ | `column` | `string` | **Obligatoire.** Nom de la colonne testée (une clé des objets de `items`) |
239
+ | `operator` | voir table ci-dessous | **Obligatoire.** |
240
+ | `value` | `any` | L'opérande ; sa forme dépend de l'opérateur |
241
+ | `target` | `'row' \| 'cell' \| string[]` | Ce qui est coloré. Défaut `'row'` |
242
+ | `style` | `CSSProperties` | Style inline appliqué au `<tr>` ou au `<td>` |
243
+ | `className` | `string` | Classe CSS ajoutée au `<tr>` ou au `<td>` |
244
+ | `valueType` | `'auto' \| 'string' \| 'number' \| 'date' \| 'boolean'` | Force le mode de comparaison. Défaut `'auto'` |
245
+ | `stopIfTrue` | `boolean` | Arrête les règles suivantes sur ce que cette règle a coloré |
246
+ | `enabled` | `boolean` | `false` conserve la règle sans l'appliquer. Défaut `true` |
247
+ | `label` | `string` | Libellé affiché dans l'éditeur |
248
+ | `id` | `string` | Généré automatiquement si absent |
249
+
250
+ | Opérateur | Opérande |
251
+ |---|---|
252
+ | `=` `!=` `<` `<=` `>` `>=` | une valeur |
253
+ | `between` | `[min, max]` ou `{min, max}` — **bornes incluses**, inversées tolérées |
254
+ | `in` | un tableau, ou une chaîne `"a, b, c"` |
255
+ | `contains` `notContains` `startsWith` `endsWith` | une valeur (toujours comparée en texte) |
256
+ | `isNull` `isNotNull` | aucune |
257
+
258
+ #### Cible d'une règle (`target`)
259
+
260
+ - `'row'` (défaut) → le `<tr>` entier.
261
+ - `'cell'` → seulement la cellule de la colonne testée.
262
+ - `['col_a', 'col_b']` → ces colonnes-là.
263
+
264
+ Le style de ligne est **aussi** posé sur chaque `<td>`. Sans cela, le moindre CSS de ton app sur `td` (zébrage, `tbody td { background: #fff }`) recouvrirait le fond du `<tr>` et la couleur semblerait ne pas marcher. Une règle `'cell'` est étalée **par-dessus**, elle l'emporte donc propriété par propriété.
265
+
266
+ Les `className`, eux, ne descendent **pas** sur les `<td>` : une classe de ligne est un point d'accroche pour ton propre CSS, écris `tr.ma-classe td { ... }`.
267
+
268
+ #### Ordre, fusion et `stopIfTrue`
269
+
270
+ Les règles sont évaluées **dans l'ordre**, et cet ordre **est** la priorité :
271
+
272
+ 1. `formattingRules` (props de ton app)
273
+ 2. les règles renvoyées par l'API
274
+ 3. les règles créées par l'utilisateur dans l'éditeur
275
+
276
+ Les styles se **cumulent** ; sur une même propriété CSS, **la dernière règle gagne**. Deux règles peuvent donc apporter l'une le fond, l'autre le gras.
277
+
278
+ `stopIfTrue` gèle exactement ce que la règle a coloré : posé sur une règle `'row'`, il bloque les règles `'row'` suivantes mais **pas** les règles `'cell'` ; posé sur une règle `'cell'`, il ne gèle que cette cellule.
279
+
280
+ #### Comparaison des valeurs
281
+
282
+ Les valeurs viennent de MySQL : un `DECIMAL` arrive en chaîne, une `DATE` en objet `Date`. En mode `'auto'`, la comparaison essaie dans cet ordre : **date** (si un `Date` est en jeu) → **nombre** (si les deux côtés sont numériques) → **date ISO** → **texte**.
283
+
284
+ - Le texte est comparé **sans tenir compte de la casse**.
285
+ - `'007'` et `'7'` sont **égaux** en mode auto (comparaison numérique). Pour une référence ou un code postal, mets `valueType: 'string'`.
286
+ - `'2024'` est traité comme un **nombre**, pas comme une année.
287
+ - Une valeur `'YYYY-MM-DD'` désigne le **jour entier** : `= '2024-01-05'` matche un `DATETIME` du 5 à 14h32, et `between` inclut toute la journée de fin.
288
+
289
+ **Valeurs nulles** : `isNull` matche `null`, `undefined` et la chaîne vide ; `!=` et `notContains` matchent sur une valeur nulle ; **tous les autres opérateurs ne matchent jamais** sur `null`. Une règle visant une colonne **inexistante** ne matche rien du tout (y compris `isNull`).
290
+
291
+ #### Échappatoire : `getRowFormatting` / `getCellFormatting`
292
+
293
+ Pour une logique qui croise plusieurs colonnes, hors de portée d'une règle déclarative :
294
+
295
+ ```tsx
296
+ const getRowFormatting = useCallback(
297
+ (row) => (row.livree > row.commandee ? { style: { backgroundColor: '#ffe6e6' } } : null),
298
+ [],
299
+ );
300
+
301
+ <DataTable fetchData={fetchCommandes} getRowFormatting={getRowFormatting} />
302
+ ```
303
+
304
+ Ces callbacks sont appliqués **en dernier** et ignorent `stopIfTrue` — ils l'emportent toujours. **Enveloppe-les dans `useCallback`**, sinon le calcul des couleurs est refait à chaque rendu.
305
+
306
+ #### L'éditeur pour l'utilisateur final (`formattingEditor`)
307
+
308
+ Désactivé par défaut. Avec `formattingEditor`, un bouton « Mise en forme » apparaît au-dessus du tableau et ouvre une modale où l'utilisateur ajoute, réordonne, désactive et supprime ses propres règles.
309
+
310
+ ```tsx
311
+ <DataTable
312
+ fetchData={fetchCommandes}
313
+ formattingEditor
314
+ formattingStorageKey='commandes' // persistance locale, gérée par la lib
315
+ onFormattingRulesChange={(rules) => save(rules)} // ou ta propre persistance serveur
316
+ initialUserFormattingRules={reglesDeMonBackend} // prioritaire sur localStorage
317
+ />
318
+ ```
319
+
320
+ - `formattingStorageKey` écrit sous la clé réelle `tableQuery:formatting:<clé>`, au format `{"v":1,"rules":[...],"disabled":[...]}`. Tout accès au stockage est protégé (SSR, navigation privée, quota dépassé) et une version inconnue est ignorée.
321
+ - L'utilisateur n'édite que **sa** couche. Les règles venues des props et de l'API s'affichent en lecture seule, avec une case pour les désactiver.
322
+ - Sans `formattingEditor`, aucun nœud supplémentaire n'est ajouté au DOM.
323
+
324
+ #### Limites connues
325
+
326
+ - Sur une colonne `JSON`, la coloration du texte peut sembler sans effet : le rendu JSON pose ses propres `<span class="key|string|number">`, et le CSS de ton app sur ces classes l'emporte sur la couleur héritée du `<td>`. Le fond, lui, fonctionne.
327
+ - Sur une colonne `FILE`/`BLOB`, le contenu est un `<img>` ou un `<button>` : le fond s'affiche autour, mais la couleur du texte est écrasée par le style de tes boutons.
328
+ - Une règle visant une colonne masquée (`HIDE`) est bien évaluée, mais une cible `'cell'` sur cette colonne n'a aucun effet visible.
329
+
188
330
  ### Utilisation avec ta propre UI de filtre
189
331
 
190
332
  Si le système de filtre par défaut ne convient pas, utilise le hook et les composants séparément :
@@ -6,4 +6,4 @@ import type { DataTableProps } from './types.js';
6
6
  * If you want your own filter UI, don't use this component: compose `useDataTable` +
7
7
  * `Table` + `Pagination` directly instead (see README "Using the pieces separately").
8
8
  */
9
- export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable, selectionColumnPosition, selectedIds, onSelectionChange, }: DataTableProps<T>): import("react").JSX.Element;
9
+ export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable, selectionColumnPosition, selectedIds, onSelectionChange, formattingRules, getRowFormatting, getCellFormatting, formattingEditor, formattingStorageKey, onFormattingRulesChange, initialUserFormattingRules, formattingButtonLabel, }: DataTableProps<T>): import("react").JSX.Element;
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useMemo, useState } from 'react';
3
3
  import { useDataTable } from './useDataTable.js';
4
+ import { useFormattingRules } from './useFormattingRules.js';
5
+ import { FormattingToolbar } from './FormattingToolbar.js';
6
+ import { columnNamesOf } from './utils.js';
4
7
  import { FilterModal } from './FilterModal.js';
5
8
  import { FilterPanel } from './FilterPanel.js';
6
9
  import { Table } from './Table.js';
@@ -12,7 +15,7 @@ import { Pagination } from './Pagination.js';
12
15
  * If you want your own filter UI, don't use this component: compose `useDataTable` +
13
16
  * `Table` + `Pagination` directly instead (see README "Using the pieces separately").
14
17
  */
15
- export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable = false, selectionColumnPosition = 'start', selectedIds, onSelectionChange, }) {
18
+ export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable = false, selectionColumnPosition = 'start', selectedIds, onSelectionChange, formattingRules, getRowFormatting, getCellFormatting, formattingEditor = false, formattingStorageKey, onFormattingRulesChange, initialUserFormattingRules, formattingButtonLabel, }) {
16
19
  const table = useDataTable({
17
20
  fetchData,
18
21
  advancedFilters,
@@ -24,11 +27,20 @@ export function DataTable({ fetchData, onRowClick, filterEnabled = true, sorting
24
27
  });
25
28
  const [openFilterCol, setOpenFilterCol] = useState(null);
26
29
  const [filterAnchor, setFilterAnchor] = useState(null);
30
+ const formatting = useFormattingRules({
31
+ propsRules: formattingRules,
32
+ serverRules: table.serverFormattingRules,
33
+ storageKey: formattingStorageKey,
34
+ initialUserRules: initialUserFormattingRules,
35
+ onChange: onFormattingRulesChange,
36
+ });
37
+ // Same derivation as Table's, via the shared helper, so the editor's column list can't drift.
38
+ const columnsForEditor = useMemo(() => columnNamesOf(table.items, table.paramFilter), [table.items, table.paramFilter]);
27
39
  const uniqueValuesFor = (column) => {
28
40
  const values = table.items.map((row) => String(row[column] ?? ''));
29
41
  return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
30
42
  };
31
- return (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'flex-start', maxHeight: height }, children: [advancedFilters && table.filterConfig && (_jsx(FilterPanel, { filterConfig: table.filterConfig, activeFilters: table.filters, onChange: table.setColumnFilter, onClearAll: table.clearAllFilters })), _jsxs("div", { className: 'frame', style: { maxHeight: '-webkit-fill-available', minWidth: '10vw' }, children: [_jsx("div", { style: { maxHeight: '-webkit-fill-available', overflowY: 'auto', overflowX: 'auto' }, children: _jsx(Table, { items: table.items, fieldsType: table.fieldsType, paramFilter: table.paramFilter, sortColumn: table.sortColumn, sortDirection: table.sortDirection, sortingEnabled: sortingEnabled, onSort: (col) => table.toggleSort(col), onRowClick: onRowClick, onImagePreview: onImagePreview, selectable: selectable, selectionColumnPosition: selectionColumnPosition, selectedIds: table.selectedIds, onToggleRow: table.setRowSelected, onToggleAllRows: table.setAllRowsSelected, renderHeaderExtra: filterEnabled && !advancedFilters
43
+ return (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'flex-start', maxHeight: height }, children: [advancedFilters && table.filterConfig && (_jsx(FilterPanel, { filterConfig: table.filterConfig, activeFilters: table.filters, onChange: table.setColumnFilter, onClearAll: table.clearAllFilters })), _jsxs("div", { className: 'frame', style: { maxHeight: '-webkit-fill-available', minWidth: '10vw' }, children: [formattingEditor && (_jsx("div", { className: 'flex-row', style: { justifyContent: 'flex-end' }, children: _jsx(FormattingToolbar, { columns: columnsForEditor, fieldsType: table.fieldsType, paramFilter: table.paramFilter, label: formattingButtonLabel, ...formatting }) })), _jsx("div", { style: { maxHeight: '-webkit-fill-available', overflowY: 'auto', overflowX: 'auto' }, children: _jsx(Table, { items: table.items, fieldsType: table.fieldsType, paramFilter: table.paramFilter, sortColumn: table.sortColumn, sortDirection: table.sortDirection, sortingEnabled: sortingEnabled, onSort: (col) => table.toggleSort(col), onRowClick: onRowClick, onImagePreview: onImagePreview, selectable: selectable, selectionColumnPosition: selectionColumnPosition, selectedIds: table.selectedIds, onToggleRow: table.setRowSelected, onToggleAllRows: table.setAllRowsSelected, formattingRules: formatting.rules, getRowFormatting: getRowFormatting, getCellFormatting: getCellFormatting, renderHeaderExtra: filterEnabled && !advancedFilters
32
44
  ? (col) => (_jsx("button", { style: { padding: 5, marginTop: 0 }, className: 'btnDataFilterTable', onClick: (e) => {
33
45
  setOpenFilterCol(col);
34
46
  setFilterAnchor(e.currentTarget);
@@ -0,0 +1,16 @@
1
+ import type { FieldTypeInfo, ParamFilter } from './types.js';
2
+ import type { UseFormattingRulesResult } from './useFormattingRules.js';
3
+ export interface FormattingModalProps extends UseFormattingRulesResult {
4
+ /** Every column of the table, hidden ones included. */
5
+ columns: string[];
6
+ fieldsType?: FieldTypeInfo[];
7
+ paramFilter?: ParamFilter[];
8
+ anchorEl: HTMLElement | null;
9
+ onClose: () => void;
10
+ }
11
+ /**
12
+ * The end-user editor for conditional formatting rules: list on top, one rule form below.
13
+ * Shares FilterModal's popover mechanics (anchored positioning, outside-click dismissal)
14
+ * and the same unstyled class hooks, so a host stylesheet themes both at once.
15
+ */
16
+ export declare function FormattingModal({ columns, fieldsType, paramFilter, anchorEl, onClose, rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules, }: FormattingModalProps): import("react").JSX.Element;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { FORMATTING_OPERATORS, describeRule, editorStateToStyle, newRuleId, operatorArity, styleToEditorState, } from './formatting.js';
4
+ /**
5
+ * The end-user editor for conditional formatting rules: list on top, one rule form below.
6
+ * Shares FilterModal's popover mechanics (anchored positioning, outside-click dismissal)
7
+ * and the same unstyled class hooks, so a host stylesheet themes both at once.
8
+ */
9
+ export function FormattingModal({ columns, fieldsType, paramFilter, anchorEl, onClose, rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules, }) {
10
+ const modalRef = useRef(null);
11
+ const [style, setStyle] = useState({ visibility: 'hidden' });
12
+ const [editingId, setEditingId] = useState(null);
13
+ const hiddenColumns = useMemo(() => {
14
+ const set = new Set();
15
+ columns.forEach((col, i) => {
16
+ if (paramFilter?.[i]?.type?.trim() === 'HIDE')
17
+ set.add(col);
18
+ });
19
+ return set;
20
+ }, [columns, paramFilter]);
21
+ // Same anchoring maths as FilterModal.
22
+ useEffect(() => {
23
+ if (!anchorEl || !modalRef.current)
24
+ return;
25
+ const modalRect = modalRef.current.getBoundingClientRect();
26
+ const triggerRect = anchorEl.getBoundingClientRect();
27
+ const left = Math.max(0, triggerRect.left - modalRect.width + triggerRect.width);
28
+ const top = Math.min(window.scrollY + window.innerHeight - modalRect.height, triggerRect.bottom + 5 + window.scrollY);
29
+ setStyle({ position: 'absolute', top, left, zIndex: 1000 });
30
+ }, [anchorEl, editingId, userRules.length, inherited.length]);
31
+ useEffect(() => {
32
+ const handler = (e) => {
33
+ if (modalRef.current && !modalRef.current.contains(e.target) && e.target !== anchorEl)
34
+ onClose();
35
+ };
36
+ window.addEventListener('mousedown', handler);
37
+ return () => window.removeEventListener('mousedown', handler);
38
+ }, [anchorEl, onClose]);
39
+ const editing = userRules.find((r) => r.id === editingId) ?? null;
40
+ const noColumns = columns.length === 0;
41
+ const startNewRule = () => {
42
+ const rule = {
43
+ id: newRuleId(),
44
+ column: columns[0] ?? '',
45
+ operator: '=',
46
+ value: '',
47
+ valueType: 'auto',
48
+ target: 'row',
49
+ style: { backgroundColor: '#ffe08a' },
50
+ enabled: true,
51
+ };
52
+ addRule(rule);
53
+ setEditingId(rule.id);
54
+ };
55
+ return (_jsxs("div", { ref: modalRef, className: 'modal flex-column', style: { minWidth: '22em', maxWidth: '34em', ...style }, children: [_jsxs("div", { className: 'frame', children: [_jsx("strong", { children: "Mise en forme conditionnelle" }), _jsxs("span", { style: { opacity: 0.7, fontSize: '0.85em' }, children: [rules.length, " r\u00E8gle", rules.length > 1 ? 's' : '', " active", rules.length > 1 ? 's' : ''] })] }), inherited.length > 0 && (_jsxs("div", { className: 'frame flex-column', style: { maxHeight: '18vh', overflowY: 'auto' }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "R\u00E8gles de l'application" }), inherited.map((rule) => (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6, opacity: 0.85 }, children: [_jsx("input", { type: 'checkbox', checked: !disabledIds.includes(rule.id), onChange: (e) => setRuleDisabled(rule.id, !e.target.checked), title: 'Activer / d\u00E9sactiver' }), _jsx(StyleSwatch, { rule: rule }), _jsx("span", { style: { width: '100%' }, children: rule.label || describeRule(rule) })] }, rule.id))), _jsx("span", { style: { opacity: 0.6, fontSize: '0.8em' }, children: "Ces r\u00E8gles ne sont pas modifiables ici." })] })), _jsxs("div", { className: 'frame flex-column', style: { maxHeight: '24vh', overflowY: 'auto' }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "Mes r\u00E8gles" }), userRules.length === 0 && _jsx("span", { style: { opacity: 0.6 }, children: "Aucune r\u00E8gle pour l'instant." }), userRules.map((rule, i) => (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: rule.enabled !== false, onChange: (e) => updateRule(rule.id, { enabled: e.target.checked }), title: 'Activer / d\u00E9sactiver' }), _jsx("button", { onClick: () => moveRule(rule.id, -1), disabled: i === 0, title: 'Monter (priorit\u00E9 plus faible)', children: "\u2191" }), _jsx("button", { onClick: () => moveRule(rule.id, 1), disabled: i === userRules.length - 1, title: 'Descendre (priorit\u00E9 plus forte)', children: "\u2193" }), _jsx(StyleSwatch, { rule: rule }), _jsx("span", { style: { width: '100%', cursor: 'pointer' }, onClick: () => setEditingId(rule.id), children: rule.label || describeRule(rule) }), _jsx("button", { onClick: () => setEditingId(editingId === rule.id ? null : rule.id), children: "\u00C9diter" }), _jsx("button", { onClick: () => {
56
+ if (editingId === rule.id)
57
+ setEditingId(null);
58
+ removeRule(rule.id);
59
+ }, title: 'Supprimer', children: "\u2715" })] }, rule.id)))] }), editing && (_jsx(RuleEditor, { rule: editing, columns: columns, hiddenColumns: hiddenColumns, fieldsType: fieldsType, onChange: (patch) => updateRule(editing.id, patch), onDone: () => setEditingId(null) })), _jsxs("div", { className: 'flex-row', children: [_jsx("button", { className: 'btn-accent', onClick: startNewRule, disabled: noColumns, children: "Ajouter une r\u00E8gle" }), _jsx("button", { onClick: () => {
60
+ setEditingId(null);
61
+ resetUserRules();
62
+ }, disabled: userRules.length === 0 && disabledIds.length === 0, children: "R\u00E9initialiser" }), _jsx("button", { onClick: onClose, children: "Fermer" })] }), noColumns && _jsx("span", { style: { opacity: 0.6 }, children: "Aucune colonne charg\u00E9e." })] }));
63
+ }
64
+ function StyleSwatch({ rule }) {
65
+ const s = rule.style ?? {};
66
+ return (_jsx("span", { "aria-hidden": 'true', style: {
67
+ display: 'inline-block',
68
+ width: '1.1em',
69
+ height: '1.1em',
70
+ flex: '0 0 auto',
71
+ border: '1px solid rgba(0,0,0,0.3)',
72
+ backgroundColor: s.backgroundColor ?? 'transparent',
73
+ color: s.color ?? 'inherit',
74
+ fontWeight: s.fontWeight,
75
+ fontStyle: s.fontStyle,
76
+ textAlign: 'center',
77
+ lineHeight: '1.1em',
78
+ fontSize: '0.8em',
79
+ }, children: "A" }));
80
+ }
81
+ // ==================== RULE EDITOR ====================
82
+ /** The operand input type follows the column's SQL type, so date rules get a date picker. */
83
+ function inputTypeFor(fieldType) {
84
+ const t = (fieldType ?? '').trim().toUpperCase();
85
+ if (t === 'DATE')
86
+ return 'date';
87
+ if (t === 'DATETIME' || t === 'TIMESTAMP')
88
+ return 'datetime-local';
89
+ return 'text';
90
+ }
91
+ function RuleEditor({ rule, columns, hiddenColumns, fieldsType, onChange, onDone, }) {
92
+ const arity = operatorArity(rule.operator);
93
+ const styleState = styleToEditorState(rule.style);
94
+ const columnIndex = columns.indexOf(rule.column);
95
+ const inputType = inputTypeFor(fieldsType?.[columnIndex]?.fieldType);
96
+ const setStyle = (patch) => onChange({ style: editorStateToStyle({ ...styleState, ...patch }) });
97
+ const pair = Array.isArray(rule.value) ? rule.value : [undefined, undefined];
98
+ const targetMode = Array.isArray(rule.target) ? 'columns' : rule.target === 'cell' ? 'cell' : 'row';
99
+ return (_jsxs("div", { className: 'frame flex-column', style: { gap: 6 }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "Modifier la r\u00E8gle" }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Colonne" }), _jsxs("select", { value: rule.column, onChange: (e) => onChange({ column: e.target.value }), style: { width: '100%' }, children: [!columns.includes(rule.column) && _jsxs("option", { value: rule.column, children: [rule.column, " (inconnue)"] }), columns.map((col) => (_jsxs("option", { value: col, children: [col, hiddenColumns.has(col) ? ' (masquée)' : ''] }, col)))] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Op\u00E9rateur" }), _jsx("select", { value: rule.operator, onChange: (e) => onChange({ operator: e.target.value, value: '' }), style: { width: '100%' }, children: FORMATTING_OPERATORS.map((op) => (_jsx("option", { value: op.value, children: op.label }, op.value))) })] }), arity === 1 && (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Valeur" }), _jsx("input", { type: inputType, value: String(rule.value ?? ''), onChange: (e) => onChange({ value: e.target.value }), style: { width: '100%' } })] })), arity === 2 && (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Entre" }), _jsx("input", { type: inputType, value: String(pair[0] ?? ''), placeholder: 'Min', onChange: (e) => onChange({ value: [e.target.value, pair[1] ?? ''] }) }), _jsx("input", { type: inputType, value: String(pair[1] ?? ''), placeholder: 'Max', onChange: (e) => onChange({ value: [pair[0] ?? '', e.target.value] }) })] })), arity === 'n' && (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Valeurs" }), _jsx("input", { type: 'text', value: Array.isArray(rule.value) ? rule.value.join(', ') : String(rule.value ?? ''), placeholder: 's\u00E9par\u00E9es par des virgules', onChange: (e) => onChange({ value: e.target.value }), style: { width: '100%' } })] })), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Comparer comme" }), _jsxs("select", { value: rule.valueType ?? 'auto', onChange: (e) => onChange({ valueType: e.target.value }), style: { width: '100%' }, children: [_jsx("option", { value: 'auto', children: "Automatique" }), _jsx("option", { value: 'string', children: "Texte" }), _jsx("option", { value: 'number', children: "Nombre" }), _jsx("option", { value: 'date', children: "Date" }), _jsx("option", { value: 'boolean', children: "Bool\u00E9en" })] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Appliquer \u00E0" }), _jsxs("select", { value: targetMode, onChange: (e) => {
100
+ const mode = e.target.value;
101
+ onChange({ target: (mode === 'columns' ? [rule.column] : mode) });
102
+ }, style: { width: '100%' }, children: [_jsx("option", { value: 'row', children: "Toute la ligne" }), _jsx("option", { value: 'cell', children: "Cette cellule" }), _jsx("option", { value: 'columns', children: "Colonnes choisies\u2026" })] })] }), targetMode === 'columns' && (_jsx("div", { className: 'frame flex-column', style: { maxHeight: '12vh', overflowY: 'auto' }, children: columns.map((col) => {
103
+ const list = rule.target ?? [];
104
+ return (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("input", { type: 'checkbox', checked: list.includes(col), onChange: (e) => onChange({ target: e.target.checked ? [...list, col] : list.filter((c) => c !== col) }) }), _jsxs("span", { children: [col, hiddenColumns.has(col) ? ' (masquée)' : ''] })] }, col));
105
+ }) })), _jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Fond" }), _jsx("input", { type: 'checkbox', checked: !!styleState.background, onChange: (e) => setStyle({ background: e.target.checked ? '#ffe08a' : undefined }) }), _jsx("input", { type: 'color', value: styleState.background ?? '#ffe08a', disabled: !styleState.background, onChange: (e) => setStyle({ background: e.target.value }) }), _jsx("span", { style: { minWidth: '4em' }, children: "Texte" }), _jsx("input", { type: 'checkbox', checked: !!styleState.color, onChange: (e) => setStyle({ color: e.target.checked ? '#000000' : undefined }) }), _jsx("input", { type: 'color', value: styleState.color ?? '#000000', disabled: !styleState.color, onChange: (e) => setStyle({ color: e.target.value }) })] }), _jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 10 }, children: [_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: styleState.bold, onChange: (e) => setStyle({ bold: e.target.checked }) }), _jsx("span", { children: "Gras" })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: styleState.italic, onChange: (e) => setStyle({ italic: e.target.checked }) }), _jsx("span", { children: "Italique" })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: !!rule.stopIfTrue, onChange: (e) => onChange({ stopIfTrue: e.target.checked }) }), _jsx("span", { title: "Les r\u00E8gles suivantes ne s'appliqueront plus \u00E0 ce que celle-ci a color\u00E9", children: "Arr\u00EAter si vrai" })] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Classe CSS" }), _jsx("input", { type: 'text', value: rule.className ?? '', placeholder: 'optionnel', onChange: (e) => onChange({ className: e.target.value || undefined }), style: { width: '100%' } })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Libell\u00E9" }), _jsx("input", { type: 'text', value: rule.label ?? '', placeholder: describeRule(rule), onChange: (e) => onChange({ label: e.target.value || undefined }), style: { width: '100%' } })] }), _jsx("button", { className: 'btn-accent', onClick: onDone, children: "Termin\u00E9" })] }));
106
+ }
@@ -0,0 +1,13 @@
1
+ import type { FieldTypeInfo, ParamFilter } from './types.js';
2
+ import type { UseFormattingRulesResult } from './useFormattingRules.js';
3
+ export interface FormattingToolbarProps extends UseFormattingRulesResult {
4
+ columns: string[];
5
+ fieldsType?: FieldTypeInfo[];
6
+ paramFilter?: ParamFilter[];
7
+ label?: string;
8
+ }
9
+ /**
10
+ * The button that opens the conditional formatting editor. Exported separately so a host
11
+ * composing `useDataTable` + `Table` by hand can drop it wherever it likes.
12
+ */
13
+ export declare function FormattingToolbar({ columns, fieldsType, paramFilter, label, ...formatting }: FormattingToolbarProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { FormattingModal } from './FormattingModal.js';
4
+ /**
5
+ * The button that opens the conditional formatting editor. Exported separately so a host
6
+ * composing `useDataTable` + `Table` by hand can drop it wherever it likes.
7
+ */
8
+ export function FormattingToolbar({ columns, fieldsType, paramFilter, label = 'Mise en forme', ...formatting }) {
9
+ const [anchorEl, setAnchorEl] = useState(null);
10
+ const activeCount = formatting.rules.length;
11
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { className: 'btnDataFilterTable', onClick: (e) => setAnchorEl(anchorEl ? null : e.currentTarget), title: 'Colorer des lignes ou des cellules selon leurs valeurs', children: [label, activeCount > 0 ? ` (${activeCount})` : ''] }), anchorEl && (_jsx(FormattingModal, { columns: columns, fieldsType: fieldsType, paramFilter: paramFilter, anchorEl: anchorEl, onClose: () => setAnchorEl(null), ...formatting }))] }));
12
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { FieldTypeInfo, ParamFilter, SelectionColumnPosition, SortDirection } from './types.js';
2
+ import type { FieldTypeInfo, FormattingRule, GetCellFormatting, GetRowFormatting, ParamFilter, SelectionColumnPosition, SortDirection } from './types.js';
3
3
  export interface TableProps<T extends Record<string, any>> {
4
4
  items: T[];
5
5
  fieldsType?: FieldTypeInfo[];
@@ -26,6 +26,12 @@ export interface TableProps<T extends Record<string, any>> {
26
26
  /** Labels for the checkboxes (accessibility). */
27
27
  selectAllLabel?: string;
28
28
  selectRowLabel?: string;
29
+ /** Conditional formatting rules, already merged and ordered (see useFormattingRules). */
30
+ formattingRules?: FormattingRule[];
31
+ /** Escape hatch for logic spanning several columns. Applied after every rule.
32
+ * Wrap these in useCallback, or the formatting memo recomputes on each render. */
33
+ getRowFormatting?: GetRowFormatting<T>;
34
+ getCellFormatting?: GetCellFormatting<T>;
29
35
  }
30
36
  /**
31
37
  * Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
@@ -33,4 +39,4 @@ export interface TableProps<T extends Record<string, any>> {
33
39
  * No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
34
40
  * (e.g. from `useDataTable`, or from any other data source).
35
41
  */
36
- export declare function Table<T extends Record<string, any>>({ items, fieldsType, paramFilter, sortColumn, sortDirection, onSort, sortingEnabled, onRowClick, onImagePreview, renderHeaderExtra, selectable, selectionColumnPosition, selectedIds, onToggleRow, onToggleAllRows, selectAllLabel, selectRowLabel, }: TableProps<T>): import("react").JSX.Element;
42
+ export declare function Table<T extends Record<string, any>>({ items, fieldsType, paramFilter, sortColumn, sortDirection, onSort, sortingEnabled, onRowClick, onImagePreview, renderHeaderExtra, selectable, selectionColumnPosition, selectedIds, onToggleRow, onToggleAllRows, selectAllLabel, selectRowLabel, formattingRules, getRowFormatting, getCellFormatting, }: TableProps<T>): import("react").JSX.Element;
@@ -1,17 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef } from 'react';
3
3
  import { Cell } from './Cell.js';
4
- import { getRowId, selectionKey } from './utils.js';
4
+ import { computeTableFormatting } from './formatting.js';
5
+ import { columnNamesOf, getRowId, selectionKey } from './utils.js';
6
+ /** Stable identity so the formatting memo isn't invalidated on every render. */
7
+ const NO_RULES = [];
5
8
  /**
6
9
  * Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
7
10
  * optional checkbox column.
8
11
  * No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
9
12
  * (e.g. from `useDataTable`, or from any other data source).
10
13
  */
11
- export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, selectable = false, selectionColumnPosition = 'start', selectedIds, onToggleRow, onToggleAllRows, selectAllLabel = 'Tout sélectionner', selectRowLabel = 'Sélectionner la ligne', }) {
12
- const columns = useMemo(() => (items[0] ? Object.keys(items[0]) : paramFilter && paramFilter.length > 0 ? paramFilter.map((el) => el.nom) : []), [items, paramFilter]);
14
+ export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, selectable = false, selectionColumnPosition = 'start', selectedIds, onToggleRow, onToggleAllRows, selectAllLabel = 'Tout sélectionner', selectRowLabel = 'Sélectionner la ligne', formattingRules, getRowFormatting, getCellFormatting, }) {
15
+ const columns = useMemo(() => columnNamesOf(items, paramFilter), [items, paramFilter]);
13
16
  /** Columns actually rendered, keeping their original index (fieldsType/paramFilter are indexed on it). */
14
17
  const visibleColumns = useMemo(() => columns.map((col, index) => ({ col, index })).filter(({ index }) => paramFilter?.[index]?.type.trim() !== 'HIDE'), [columns, paramFilter]);
18
+ /**
19
+ * Computed for the whole page at once rather than inside the render loop: Table re-renders
20
+ * on every checkbox toggle and every filter popover open, and re-evaluating N rules over up
21
+ * to 1000 rows on each of those is wasteful. `items` gets a fresh identity on every load(),
22
+ * so invalidation is automatic. Returns null when there is nothing to format.
23
+ */
24
+ const formatting = useMemo(() => computeTableFormatting(items, formattingRules ?? NO_RULES, columns, { getRowFormatting, getCellFormatting }), [items, formattingRules, columns, getRowFormatting, getCellFormatting]);
15
25
  const selectedKeys = useMemo(() => new Set((selectedIds ?? []).map(selectionKey)), [selectedIds]);
16
26
  const displayedCount = items.length;
17
27
  const selectedOnPage = useMemo(() => items.filter((row) => selectedKeys.has(selectionKey(getRowId(row)))).length, [items, selectedKeys]);
@@ -38,9 +48,19 @@ export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDir
38
48
  const headerSelectionCell = (_jsx("th", { className: 'selectionColumn', style: { width: '1%' }, children: _jsx(SelectionCheckbox, { checked: allDisplayedSelected, indeterminate: someDisplayedSelected, disabled: displayedCount === 0, label: selectAllLabel, onChange: (checked) => onToggleAllRows?.(checked) }) }, '__selection__'));
39
49
  return (_jsxs("table", { children: [_jsx("thead", { className: 'tableHeader', children: _jsx("tr", { children: withSelectionCell(headerCells, headerSelectionCell) }) }), _jsxs("tbody", { style: { maxHeight: 'stretch', maxWidth: 'stretch', overflow: 'auto' }, children: [items.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: 100, children: "No data found" }) })), items.map((row, i) => {
40
50
  const id = getRowId(row);
41
- const cells = visibleColumns.map(({ col, index }) => (_jsx("td", { children: _jsx(Cell, { value: row[col], fieldType: fieldsType[index]?.fieldType, onImagePreview: onImagePreview }) }, col)));
42
- const selectionCell = (_jsx("td", { className: 'selectionColumn', onClick: (e) => e.stopPropagation(), children: _jsx(SelectionCheckbox, { checked: selectedKeys.has(selectionKey(id)), label: selectRowLabel, onChange: (checked) => onToggleRow?.(row, checked) }) }, '__selection__'));
43
- return (_jsx("tr", { style: { cursor: onRowClick ? 'pointer' : 'default' }, onClick: () => id !== undefined && onRowClick?.(id, row), children: withSelectionCell(cells, selectionCell) }, id !== undefined ? String(id) : i));
51
+ const fmt = formatting?.[i];
52
+ const cells = visibleColumns.map(({ col, index }) => {
53
+ // Keyed by column NAME. `index` stays positional for fieldsType/paramFilter
54
+ // using it here would mis-colour every table that has a HIDE column.
55
+ const cf = fmt?.cellStyles[col];
56
+ // The row style is re-applied as a base layer on each <td>: host CSS that sets a
57
+ // background on td (zebra striping) paints over the <tr>'s own background otherwise.
58
+ // Spreading the cell style second makes it win per property, with no JS arbitration.
59
+ const style = fmt?.rowStyle || cf?.style ? { ...fmt?.rowStyle, ...cf?.style } : undefined;
60
+ return (_jsx("td", { className: cf?.className, style: style, children: _jsx(Cell, { value: row[col], fieldType: fieldsType[index]?.fieldType, onImagePreview: onImagePreview }) }, col));
61
+ });
62
+ const selectionCell = (_jsx("td", { className: 'selectionColumn', style: fmt?.rowStyle, onClick: (e) => e.stopPropagation(), children: _jsx(SelectionCheckbox, { checked: selectedKeys.has(selectionKey(id)), label: selectRowLabel, onChange: (checked) => onToggleRow?.(row, checked) }) }, '__selection__'));
63
+ return (_jsx("tr", { className: fmt?.rowClassName, style: { cursor: onRowClick ? 'pointer' : 'default', ...fmt?.rowStyle }, onClick: () => id !== undefined && onRowClick?.(id, row), children: withSelectionCell(cells, selectionCell) }, id !== undefined ? String(id) : i));
44
64
  })] })] }));
45
65
  }
46
66
  function SelectionCheckbox({ checked, indeterminate = false, disabled = false, label, onChange, }) {
@@ -0,0 +1,47 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { FormattingOperator, FormattingRule, FormattingValueType, GetCellFormatting, GetRowFormatting, RowFormatting } from './types.js';
3
+ /** Operator metadata: the single source of truth for the editor's <select> and its operand inputs. */
4
+ export declare const FORMATTING_OPERATORS: {
5
+ value: FormattingOperator;
6
+ label: string;
7
+ arity: 0 | 1 | 2 | 'n';
8
+ }[];
9
+ export declare function operatorArity(operator: FormattingOperator): 0 | 1 | 2 | 'n';
10
+ /**
11
+ * Three-way compare. Returns null when the two operands cannot be compared at all,
12
+ * which callers treat as "does not match" (except '!=', where it means "different").
13
+ */
14
+ export declare function compareValues(cell: unknown, operand: unknown, valueType?: FormattingValueType): -1 | 0 | 1 | null;
15
+ export declare function evaluateRule(rule: FormattingRule, row: Record<string, any>): boolean;
16
+ export declare function computeRowFormatting<T extends Record<string, any>>(row: T, rules: FormattingRule[], columns: string[], callbacks?: {
17
+ getRowFormatting?: GetRowFormatting<T>;
18
+ getCellFormatting?: GetCellFormatting<T>;
19
+ }, rowIndex?: number): RowFormatting;
20
+ /**
21
+ * Formatting for a whole page of rows. Returns `null` when there is nothing to do, so the
22
+ * feature costs one boolean check for everyone who does not use it.
23
+ */
24
+ export declare function computeTableFormatting<T extends Record<string, any>>(items: T[], rules: FormattingRule[], columns: string[], callbacks?: {
25
+ getRowFormatting?: GetRowFormatting<T>;
26
+ getCellFormatting?: GetCellFormatting<T>;
27
+ }): RowFormatting[] | null;
28
+ /** Stable-enough id for a rule that arrived without one. randomUUID needs a secure context. */
29
+ export declare function newRuleId(): string;
30
+ /**
31
+ * Coerce an untrusted rule list (an API payload or localStorage) into valid rules.
32
+ * Anything malformed is dropped, never thrown on — a bad stored rule must not take the
33
+ * table down.
34
+ */
35
+ export declare function sanitizeFormattingRules(input: unknown): FormattingRule[];
36
+ export interface RuleStyleState {
37
+ background?: string;
38
+ color?: string;
39
+ bold: boolean;
40
+ italic: boolean;
41
+ /** Every property the editor has no widget for, preserved across an edit. */
42
+ rest: CSSProperties;
43
+ }
44
+ export declare function styleToEditorState(style?: CSSProperties): RuleStyleState;
45
+ export declare function editorStateToStyle(state: RuleStyleState): CSSProperties | undefined;
46
+ /** Human-readable one-liner for a rule, used when it carries no explicit label. */
47
+ export declare function describeRule(rule: FormattingRule): string;