@navette-js/tables 1.0.0-beta.1 → 1.0.0-beta.2

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/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
6
6
  export * from "./query-builder.js";
7
- /** Emplacements de render hook d'une table — mêmes noms que `Filament\Tables\View\TablesRenderHook`. */
7
+ /** Render hook slots of a table. */
8
8
  export const TablesRenderHook = {
9
9
  CONTENT_AFTER: 'tables::content.after',
10
10
  CONTENT_BEFORE: 'tables::content.before',
@@ -26,17 +26,17 @@ export const TablesRenderHook = {
26
26
  TOOLBAR_START: 'tables::toolbar.start',
27
27
  FILTER_INDICATORS: 'tables::filter.indicators',
28
28
  HEADER_CELL: 'tables::header.cell',
29
- /** Anciens noms, conservés par Filament. */
29
+ /** Legacy aliases of the column manager trigger slots. */
30
30
  TOOLBAR_TOGGLE_COLUMN_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
31
31
  TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
32
32
  };
33
- /** Cibles de chargement de la table (`wire:target`), dans l'ordre émis par Filament. */
33
+ /** Methods and properties that trigger the table's loading state (`wire:target`). */
34
34
  export const LOADING_TARGETS = 'activeTab,applyTableFilters,gotoPage,nextPage,previousPage,removeTableFilter,removeTableFilters,reorderTable,resetTableColumnSearch,resetTableFiltersForm,resetTableSearch,setPage,sortTable,tableColumnSearches,tableFilters,tableGrouping,tableRecordsPerPage,tableSearch,tableSort,toggleTableReordering';
35
35
  const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
36
- // ------------------------------------------------------------------ colonnes
37
- /** Base commune des colonnes (port partiel de Filament\Tables\Columns\Column). */
36
+ // ------------------------------------------------------------------ columns
37
+ /** Base class of table columns. */
38
38
  export class Column {
39
- /** Réglage par défaut de toutes les instances créées ensuite par `make()` : `TextInput.configureUsing((input) => input.maxLength(255))`. */
39
+ /** Default setup for every instance created afterwards by `make()`, e.g. `TextColumn.configureUsing((column) => column.sortable())`. */
40
40
  static configureUsing(configure) { configureUsing(this, configure); }
41
41
  name;
42
42
  _label = null;
@@ -50,20 +50,20 @@ export class Column {
50
50
  _sortColumn = null;
51
51
  _searchColumn = null;
52
52
  _url = null;
53
- /** Libellé du groupe de colonnes auquel la colonne appartient (posé par ColumnGroup). */
53
+ /** Label of the column group this column belongs to (set by ColumnGroup). */
54
54
  _group = null;
55
55
  constructor(name) { this.name = name; }
56
56
  label(label) { this._label = label; return this; }
57
- /** `column` : colonne (ou alias de la requête) utilisée pour trier quand elle diffère du nom, par exemple pour une relation. */
57
+ /** `column`: column (or query alias) to sort by when it differs from the name, e.g. for a relationship. */
58
58
  sortable(condition = true) { this._sortable = condition !== false; this._sortColumn = typeof condition === 'object' ? condition.column : null; this._sortInverted = typeof condition === 'object' && condition.inverted === true; return this; }
59
59
  getSortColumn() { return this._sortColumn ?? this.name; }
60
60
  _sortInverted = false;
61
- /** Vrai quand la colonne de tri évolue en sens inverse de la valeur affichée (`inverted` : un âge trié par sa date). */
61
+ /** `inverted`: the sort column runs opposite to the displayed value (e.g. an age sorted by birth date). */
62
62
  isSortInverted() { return this._sortInverted; }
63
- /** Lien posé sur la cellule ; il prend le pas sur l'action de ligne. */
63
+ /** Link on the cell; takes precedence over the row action. */
64
64
  url(resolver) { this._url = resolver; return this; }
65
65
  getUrl(record) { return this._url ? this._url(record) : null; }
66
- /** `isGlobal: false` exclut la colonne de la recherche globale ; `isIndividual` ajoute un champ de recherche propre à la colonne. */
66
+ /** `isGlobal: false` excludes the column from the global search; `isIndividual` adds a search field for this column. */
67
67
  getSearchColumn() { return this._searchColumn ?? this.name; }
68
68
  searchable(options = true) {
69
69
  this._searchColumn = typeof options === 'object' ? (options.column ?? null) : null;
@@ -71,7 +71,7 @@ export class Column {
71
71
  this._individuallySearchable = typeof options === 'object' && options.isIndividual === true;
72
72
  return this;
73
73
  }
74
- /** Colonne que l'utilisateur peut masquer depuis le gestionnaire de colonnes. */
74
+ /** Lets the user hide the column from the column manager. */
75
75
  toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
76
76
  alignment(alignment) { this._alignment = alignment; return this; }
77
77
  alignEnd() { return this.alignment('end'); }
@@ -85,10 +85,10 @@ export class Column {
85
85
  isToggleable() { return this._toggleable; }
86
86
  isToggledHiddenByDefault() { return this._toggledHiddenByDefault; }
87
87
  getAlignment() { return this._alignment; }
88
- /** Suffixe des classes `fi-ta-header-cell-…` / `fi-ta-cell-…` : les tirets bas deviennent des tirets, les points sont conservés (comme Filament). */
88
+ /** Suffix of the `fi-ta-header-cell-…` / `fi-ta-cell-…` classes: underscores become dashes, dots are kept. */
89
89
  getCssName() { return this.name.replace(/_/g, '-'); }
90
90
  async getState(record) { return this._state ? this._state({ record }) : record[this.name]; }
91
- /** Vrai pour une cellule modifiable : elle n'est enveloppée ni d'un lien ni d'un bouton vers l'enregistrement. */
91
+ /** True for editable cells: they are not wrapped in a link or button to the record. */
92
92
  isClickDisabled() { return false; }
93
93
  }
94
94
  export class TextColumn extends Column {
@@ -106,40 +106,40 @@ export class TextColumn extends Column {
106
106
  _limit = null;
107
107
  _tooltip = null;
108
108
  static make(name) { return configured(new TextColumn(name)); }
109
- /** Affiche la valeur dans une pastille. */
109
+ /** Displays the value in a badge. */
110
110
  badge(condition = true) { this._badge = condition; return this; }
111
111
  color(color) { this._color = color; return this; }
112
112
  icon(resolver) { this._icon = resolver; return this; }
113
- /** Texte affiché à la place de la valeur brute (libellé d'une énumération, par exemple). */
113
+ /** Text displayed instead of the raw value (e.g. an enum label). */
114
114
  formatStateUsing(formatter) { this._format = formatter; return this; }
115
- /** Date courte, comme `->date()` de Filament (« Nov 9, 2026 »). */
115
+ /** Short date, e.g. "Nov 9, 2026". */
116
116
  date(condition = true) { this._date = condition; return this; }
117
117
  _dateTime = false;
118
- /** Date et heure, « M j, Y H:i:s » (format par défaut de Filament). */
118
+ /** Date and time, e.g. "Nov 9, 2026 14:30:00". */
119
119
  dateTime(condition = true) { this._dateTime = condition; this._date = condition; return this; }
120
- /** Nombre formaté avec séparateurs de milliers (`->numeric()`). */
120
+ /** Number formatted with thousands separators. */
121
121
  _decimals = null;
122
- /** `numeric(1)` : nombre de décimales fixe. */
122
+ /** `numeric(1)`: fixed number of decimals. */
123
123
  numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
124
- /** Ligne de totaux sous la table. */
124
+ /** Summary row below the table. */
125
125
  summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
126
126
  getSummarizers() { return this._summarizers; }
127
127
  weight(weight) { this._weight = weight; return this; }
128
- /** Tronque le texte affiché à `length` caractères, suivi de « ... » (port de `limit()`, Str::limit). */
128
+ /** Truncates the displayed text to `length` characters, followed by "...". */
129
129
  limit(length) { this._limit = length; return this; }
130
- /** Infobulle au survol du texte ; `null` pour ne pas en afficher. */
130
+ /** Tooltip on hover; `null` for none. */
131
131
  tooltip(tooltip) { this._tooltip = tooltip; return this; }
132
132
  copyable(condition = true) { this._copyable = condition; return this; }
133
- /** Montant formaté dans la devise donnée (`money('usd')`). */
133
+ /** Amount formatted in the given currency, e.g. `money('usd')`. */
134
134
  money(currency = 'usd') { this._money = currency; return this; }
135
- /** Texte affiché quand la valeur est vide. */
135
+ /** Text displayed when the value is empty. */
136
136
  placeholder(text) { this._placeholder = text; return this; }
137
- /** Contenu de cellule. Filament a deux balisages : un chemin court pour le texte simple, un chemin complet dès qu'une option est posée. */
137
+ /** Cell content: a short markup for plain text, a full markup as soon as an option (badge, icon, color…) is set. */
138
138
  async toHtml(record) {
139
139
  const state = await this.getState(record);
140
140
  if ((state === null || state === undefined || state === '') && this._placeholder !== null)
141
141
  return `<div class="fi-ta-text"><p class="fi-ta-placeholder">${e(this._placeholder)}</p></div>`;
142
- // Valeur vide sans texte de remplacement : Filament rend un conteneur nu.
142
+ // Empty value without placeholder: render a bare container.
143
143
  if (state === null || state === undefined || state === '')
144
144
  return `<div class="${cls(['fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, (this._money || this._numeric) && this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}"></div>`;
145
145
  const currency = typeof this._money === 'function' ? this._money(record) : this._money;
@@ -153,7 +153,7 @@ export class TextColumn extends Column {
153
153
  const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
154
154
  const iconName = this._icon?.(record) ?? null;
155
155
  const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
156
- // Comme Filament : sans icône, la cellule passe par le chemin court, qui écrit les classes dans l'autre ordre.
156
+ // Without an icon the cell uses the short markup, which writes the classes in the other order.
157
157
  return `<div class="${iconName ? 'fi-ta-text-item fi-ta-text' : 'fi-ta-text fi-ta-text-item'} fi-ta-text-has-badges"><span class="${classes}">${iconName ? icon(iconName, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) }) : ''}${e(shown)}</span></div>`;
158
158
  }
159
159
  if (!this._weight && !this._copyable)
@@ -171,7 +171,7 @@ export class TextColumn extends Column {
171
171
  }">${e(limited)}</span>` : e(limited);
172
172
  return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${content}</div>`;
173
173
  }
174
- // Hors pastille : une icône précède le texte (chemin complet), une couleur teinte le texte.
174
+ // Outside a badge: an icon precedes the text (full markup) and a color tints the text.
175
175
  const plainIcon = this._icon?.(record) ?? null;
176
176
  const plainColor = typeof this._color === 'function' ? this._color(record) : this._color;
177
177
  const colorClasses = plainColor ? (colorManager?.getComponentClasses('text', plainColor) ?? []) : [];
@@ -193,7 +193,7 @@ export class TextColumn extends Column {
193
193
  return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${inner}</div>`;
194
194
  }
195
195
  }
196
- /** Résumé d'une colonne sous la table (port de Summarizers\Summarizer) : somme, moyenne, nombre ou étendue. */
196
+ /** Column summary below the table: sum, average, count or range. */
197
197
  export class Summarizer {
198
198
  _label = null;
199
199
  _money = null;
@@ -202,33 +202,33 @@ export class Summarizer {
202
202
  getLabel() { return this._label; }
203
203
  format(value) { return this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money }).format(value) : new Intl.NumberFormat('en-US').format(value); }
204
204
  }
205
- /** Total d'une colonne (port du summarizer Sum). */
205
+ /** Column total. */
206
206
  export class Sum extends Summarizer {
207
207
  kind = 'sum';
208
208
  static make() { return configured(new Sum()); }
209
209
  getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.sum.label'); }
210
210
  summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.sum[column] ?? 0) }; }
211
211
  }
212
- /** Moyenne d'une colonne (port du summarizer Average). */
212
+ /** Column average. */
213
213
  export class Average extends Summarizer {
214
214
  kind = 'avg';
215
215
  static make() { return configured(new Average()); }
216
216
  getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.average.label'); }
217
217
  summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.avg[column] ?? 0) }; }
218
218
  }
219
- /** Nombre d'enregistrements (port du summarizer Count). */
219
+ /** Record count. */
220
220
  export class Count extends Summarizer {
221
221
  kind = 'count';
222
222
  static make() { return configured(new Count()); }
223
223
  getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.count.label'); }
224
224
  summarize(_column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.count) }; }
225
225
  }
226
- /** Plus petite et plus grande valeur d'une colonne (port du summarizer Range). */
226
+ /** Smallest and largest value of a column. */
227
227
  export class Range extends Summarizer {
228
228
  kind = 'range';
229
229
  _minimalDateTime = false;
230
230
  static make() { return configured(new Range()); }
231
- /** Dates : le jour seul quand les deux bornes diffèrent de jour, le jour et l'heure sinon ; une seule valeur si elles sont identiques. */
231
+ /** Dates: day only when the bounds fall on different days, day and time otherwise; a single value when both are equal. */
232
232
  minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
233
233
  summarize(column, values) {
234
234
  let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
@@ -248,7 +248,7 @@ export class Range extends Summarizer {
248
248
  return { type: 'range', label: this._label, value: bounds.join(' - ') };
249
249
  }
250
250
  }
251
- /** Regroupement des lignes d'une table (port de Grouping\Group) : les lignes sont triées par la colonne, un en-tête précède chaque groupe. */
251
+ /** Row grouping: rows are sorted by the column and each group gets a header row. */
252
252
  export class Group {
253
253
  id;
254
254
  _label = null;
@@ -261,17 +261,17 @@ export class Group {
261
261
  constructor(id) { this.id = id; }
262
262
  static make(id) { return configured(new Group(id)); }
263
263
  label(label) { this._label = label; return this; }
264
- /** Colonne de la requête qui porte la valeur du groupe, quand elle diffère de l'identifiant (relation). */
264
+ /** Query column holding the group value, when it differs from the id (relationship). */
265
265
  column(column) { this._column = column; return this; }
266
- /** Titre du groupe tiré de l'enregistrement (libellé d'une énumération, par exemple). */
266
+ /** Group title computed from the record (e.g. an enum label). */
267
267
  getTitleUsing(resolver) { this._title = resolver; return this; }
268
268
  getDescriptionUsing(resolver) { this._description = resolver; return this; }
269
- /** Noms de Filament (`getTitleFromRecordUsing`, `getDescriptionFromRecordUsing`). */
269
+ /** Aliases using the `get…FromRecordUsing` naming. */
270
270
  getTitleFromRecordUsing(resolver) { return this.getTitleUsing(resolver); }
271
271
  getDescriptionFromRecordUsing(resolver) { return this.getDescriptionUsing(resolver); }
272
- /** Les groupes se replient d'un clic sur leur en-tête. */
272
+ /** Groups collapse when their header is clicked. */
273
273
  collapsible(condition = true) { this._collapsible = condition; return this; }
274
- /** La colonne est une date : les lignes d'un même jour forment un groupe. */
274
+ /** The column is a date: rows from the same day form one group. */
275
275
  date(condition = true) { this._date = condition; return this; }
276
276
  titlePrefixedWithLabel(condition = true) { this._titlePrefixedWithLabel = condition; return this; }
277
277
  getId() { return this.id; }
@@ -285,7 +285,7 @@ export class Group {
285
285
  isCollapsible() { return this._collapsible; }
286
286
  isDate() { return this._date; }
287
287
  isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
288
- /** Valeur du groupe pour un enregistrement, en texte ; `null` quand elle est vide. */
288
+ /** Group value of a record, as text; `null` when empty. */
289
289
  getStringKey(record) {
290
290
  let key = record?.[this.getColumn()];
291
291
  if (key === null || key === undefined || key === '')
@@ -303,7 +303,7 @@ export class Group {
303
303
  return key;
304
304
  }
305
305
  getDescription(record) { return this._description ? this._description(record) : null; }
306
- /** Restreint une requête aux enregistrements du groupe de clé donnée. */
306
+ /** Restricts a query to the records of the group with the given key. */
307
307
  scope(query, key) {
308
308
  if (key === null)
309
309
  query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
@@ -314,35 +314,35 @@ export class Group {
314
314
  }
315
315
  }
316
316
  /**
317
- * Mise en page des colonnes (port de Tables\Columns\Layout) : dès qu'une table en contient une, ses enregistrements sont
318
- * rendus en cartes (`fi-ta-content`) et non plus en lignes de tableau ; le tri passe par un sélecteur « Sort by ».
317
+ * Column layout: as soon as a table has one, records are rendered as cards (`fi-ta-content`) instead of table rows,
318
+ * and sorting goes through a "Sort by" select.
319
319
  */
320
320
  export class ColumnLayout {
321
321
  _children;
322
322
  constructor(children) { this._children = children; }
323
323
  getChildren() { return this._children; }
324
- /** Colonnes feuilles, dans l'ordre : ce sont elles que la table trie, cherche et masque. */
324
+ /** Leaf columns, in order: the ones the table sorts, searches and toggles. */
325
325
  getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
326
326
  }
327
- /** Colonnes côte à côte, à partir d'un point de rupture (port de Layout\Split). */
327
+ /** Columns side by side, from a given breakpoint. */
328
328
  export class Split extends ColumnLayout {
329
329
  _from = null;
330
330
  static make(children) { return new Split(children); }
331
331
  from(breakpoint) { this._from = breakpoint; return this; }
332
332
  getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
333
333
  }
334
- /** Colonnes l'une sous l'autre (port de Layout\Stack). */
334
+ /** Columns stacked vertically. */
335
335
  export class Stack extends ColumnLayout {
336
336
  _space = null;
337
337
  _alignment = 'start';
338
338
  static make(children) { return new Stack(children); }
339
- /** Espace entre les colonnes : 1 (petit), 2 (moyen) ou 3 (grand). */
339
+ /** Gap between columns: 1 (small), 2 (medium) or 3 (large). */
340
340
  space(space = 1) { this._space = space; return this; }
341
341
  alignment(alignment) { this._alignment = alignment; return this; }
342
342
  getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
343
343
  }
344
344
  const layoutCell = (classes, html) => `<div class="${['fi-grid-col', 'fi-growable', ...classes].join(' ')}" style="--col-span-default: span 1 / span 1;">${html}</div>`;
345
- /** Rendu d'un enregistrement selon la mise en page : les colonnes masquées par l'utilisateur sont sautées. */
345
+ /** Renders a record through the layout, skipping columns the user has hidden. */
346
346
  async function renderColumnLayout(node, record, key, visible) {
347
347
  if (node instanceof ColumnLayout) {
348
348
  const children = [];
@@ -353,11 +353,11 @@ async function renderColumnLayout(node, record, key, visible) {
353
353
  if (!visible.has(node))
354
354
  return '';
355
355
  const alignment = node.getAlignment();
356
- // Dans une mise en page, le texte est « en ligne » et porte lui-même l'alignement (comme Filament).
356
+ // Inside a layout, text is inline and carries its own alignment.
357
357
  const html = (await node.toHtml(record, key)).replace(/class="([^"]*\bfi-ta-text\b[^"]*)"/, (_match, classes) => `class="${classes.replace(/ fi-align-\w+/g, '')} fi-inline${alignment ? ` fi-align-${alignment}` : ''}"`);
358
358
  return layoutCell(['fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : [])], html);
359
359
  }
360
- /** Groupe de colonnes : un intitulé commun au-dessus des en-têtes (port de ColumnGroup). */
360
+ /** Column group: a shared label above the column headers. */
361
361
  export class ColumnGroup {
362
362
  label;
363
363
  _columns = [];
@@ -367,24 +367,24 @@ export class ColumnGroup {
367
367
  c._group = this.label; return this; }
368
368
  getColumns() { return this._columns; }
369
369
  }
370
- /** Pastille de couleur (port de ColorColumn). */
370
+ /** Color swatch column. */
371
371
  export class ColorColumn extends Column {
372
372
  static make(name) { return configured(new ColorColumn(name)); }
373
373
  async toHtml(record) {
374
374
  const state = await this.getState(record);
375
- // Seules les couleurs hexadécimales ou fonctionnelles simples sont injectées dans le style.
375
+ // Only hex or simple functional colors are injected into the style attribute (no CSS injection).
376
376
  const color = typeof state === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(state.trim()) ? state.trim() : null;
377
377
  return `<div class="fi-ta-color">${color ? `<div aria-label="${e(color)}" role="img" class="fi-ta-color-item" style="background-color: ${color};"></div>` : ''}</div>`;
378
378
  }
379
379
  }
380
- /** Image (port partiel de ImageColumn) : l'état est l'adresse de l'image. Seules les adresses locales ou http(s) sont rendues. */
380
+ /** Image column: the state is the image URL. Only local or http(s) URLs are rendered. */
381
381
  export class ImageColumn extends Column {
382
382
  _alt = null;
383
383
  _height = '2.5rem';
384
384
  _circular = false;
385
385
  static make(name) { return configured(new ImageColumn(name)); }
386
386
  alt(resolver) { this._alt = resolver; return this; }
387
- /** Hauteur CSS de l'image (`2.5rem` par défaut, comme Filament). */
387
+ /** CSS height of the image (`2.5rem` by default). */
388
388
  imageHeight(height) { if (/^[0-9.]+(rem|px|em)$/.test(height))
389
389
  this._height = height; return this; }
390
390
  circular(condition = true) { this._circular = condition; return this; }
@@ -397,7 +397,7 @@ export class ImageColumn extends Column {
397
397
  return `<div class="fi-ta-image"><img${alt !== null ? ` alt="${e(alt)}"` : ''} src="${e(src)}" style="height: ${this._height};"${this._circular ? ' class="fi-circular"' : ''} /></div>`;
398
398
  }
399
399
  }
400
- /** Icône selon la valeur ; `boolean()` affiche une coche verte ou une croix rouge (port partiel de IconColumn). */
400
+ /** Icon depending on the value; `boolean()` shows a green check or a red cross. */
401
401
  export class IconColumn extends Column {
402
402
  _boolean = false;
403
403
  static make(name) { return configured(new IconColumn(name)); }
@@ -413,7 +413,7 @@ export class IconColumn extends Column {
413
413
  }
414
414
  const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
415
415
  const isNumeric = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
416
- /** Règles Laravel reconnues par `rules([...])`, avec leurs messages anglais. Une règle inconnue est refusée à la déclaration plutôt qu'ignorée. */
416
+ /** String rules accepted by `rules([...])`, with their messages. An unknown rule throws at declaration time instead of being ignored. */
417
417
  const STRING_RULES = {
418
418
  boolean: () => ({ name: 'boolean', passes: (v) => [true, false, 0, 1, '0', '1'].includes(v), message: (a) => `The ${a} field must be true or false.` }),
419
419
  string: () => ({ name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }),
@@ -426,10 +426,10 @@ const STRING_RULES = {
426
426
  in: (list) => ({ name: 'in', passes: (v) => list.split(',').includes(String(v)), message: (a) => `The selected ${a} is invalid.` }),
427
427
  };
428
428
  /**
429
- * Base des colonnes modifiables dans la cellule (port des traits CanBeValidated et CanUpdateState de Filament). Le navigateur appelle
430
- * `updateTableColumnState(colonne, clé, valeur)` sur le composant hôte : la colonne doit être déclarée, visible et active pour cet
431
- * enregistrement, la valeur passer les règles ; ensuite seulement viennent `beforeStateUpdated`, l'écriture et `afterStateUpdated`.
432
- * Comme dans Filament, l'écriture ne consulte pas les droits du modèle : `disabled()` reste le moyen de restreindre la modification.
429
+ * Base class of in-cell editable columns. The browser calls `updateTableColumnState(column, key, value)` on the host component:
430
+ * the column must be declared, visible and enabled for this record, and the value must pass the rules; only then do
431
+ * `beforeStateUpdated`, the write and `afterStateUpdated` run. The write does not check model permissions: use `disabled()`
432
+ * to restrict editing.
433
433
  */
434
434
  export class EditableColumn extends Column {
435
435
  _disabled = false;
@@ -442,31 +442,31 @@ export class EditableColumn extends Column {
442
442
  _tooltip = null;
443
443
  disabled(condition = true) { this._disabled = condition; return this; }
444
444
  async isDisabledFor(record) { return typeof this._disabled === 'function' ? this._disabled(record) : this._disabled; }
445
- /** Une cellule modifiable n'est ni un lien ni un bouton vers l'enregistrement. */
445
+ /** An editable cell is neither a link nor a button to the record. */
446
446
  isClickDisabled() { return true; }
447
447
  isEditable() { return true; }
448
- /** Règles appliquées à la valeur reçue : chaînes Laravel (`required`, `max:255`, `in:a,b`…) ou règles `{ name, passes, message }`. */
448
+ /** Rules applied to the received value: strings (`required`, `max:255`, `in:a,b`…) or `{ name, passes, message }` objects. */
449
449
  rules(rules) {
450
450
  for (const rule of rules)
451
451
  if (typeof rule === 'string' && rule !== 'required' && rule !== 'nullable' && !STRING_RULES[rule.split(':')[0]])
452
- throw new Error(`Règle de validation non prise en charge par les colonnes : ${rule}`);
452
+ throw new Error(`Validation rule not supported by columns: ${rule}`);
453
453
  this._rules = [...rules];
454
454
  return this;
455
455
  }
456
456
  getRules() { return this._rules; }
457
- /** Équivalent de l'ajout de `required` aux règles. */
457
+ /** Same as adding `required` to the rules. */
458
458
  required(condition = true) { this._rules = [...this._rules.filter((rule) => rule !== 'required'), ...(condition ? ['required'] : [])]; return this; }
459
459
  isRequired() { return this._rules.includes('required'); }
460
- /** Nom de la colonne dans les messages (par défaut, le libellé avec une minuscule initiale). */
460
+ /** Column name used in messages (defaults to the label with a lowercase first letter). */
461
461
  validationAttribute(label) { this._validationAttribute = label; return this; }
462
- /** Messages remplaçant ceux de Laravel, par nom de règle : `{ max: 'Trop long.' }`. */
462
+ /** Custom messages by rule name, e.g. `{ max: 'Too long.' }`. */
463
463
  validationMessages(messages) { this._validationMessages = { ...messages }; return this; }
464
464
  getValidationAttribute() { return this._validationAttribute ?? this.getLabel().replace(/^./, (c) => c.toLowerCase()); }
465
- /** Règles propres au type de colonne (options d'une liste, par exemple), vérifiées après celles de l'application. */
465
+ /** Rules specific to the column type (e.g. select options). */
466
466
  getImplicitRules() { return []; }
467
467
  /**
468
- * Message d'erreur, ou `null` si la valeur est acceptable. Comme le validateur de Laravel : une valeur vide ne passe que `required`,
469
- * et plusieurs erreurs donnent le premier message suivi de « (and N more errors) ».
468
+ * Error message, or `null` if the value is valid. An empty value is only checked by `required`, and several errors
469
+ * produce the first message followed by "(and N more errors)".
470
470
  */
471
471
  validate(input) {
472
472
  const attribute = this.getValidationAttribute();
@@ -479,11 +479,11 @@ export class EditableColumn extends Column {
479
479
  fail('required', `The ${attribute} field is required.`);
480
480
  }
481
481
  else {
482
- // Règles du type de colonne d'abord : une valeur de mauvaise forme (liste, objet forgé) s'arrête là.
482
+ // Column-type rules first: a malformed value (list, forged object) stops here.
483
483
  const implicit = this.getImplicitRules().find((rule) => !rule.passes(input));
484
484
  if (implicit)
485
485
  return this._validationMessages[implicit.name] ?? implicit.message(attribute);
486
- // Les règles Laravel portées ne valent que pour un scalaire : une liste ou un objet les fait échouer.
486
+ // String rules only apply to scalars: a list or an object fails them.
487
487
  const isScalar = ['string', 'number', 'boolean'].includes(typeof input);
488
488
  for (const rule of this._rules) {
489
489
  if (rule === 'required' || rule === 'nullable')
@@ -497,20 +497,20 @@ export class EditableColumn extends Column {
497
497
  return null;
498
498
  return errors.length === 1 ? errors[0] : `${errors[0]} (and ${errors.length - 1} more error${errors.length > 2 ? 's' : ''})`;
499
499
  }
500
- /** Écriture personnalisée, à la place de la mise à jour de la colonne de l'enregistrement. Sa valeur de retour est renvoyée au navigateur. */
500
+ /** Custom write, replacing the update of the record's column. Its return value is sent back to the browser. */
501
501
  updateStateUsing(callback) { this._updateStateUsing = callback; return this; }
502
502
  beforeStateUpdated(callback) { this._beforeStateUpdated = callback; return this; }
503
503
  afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
504
- /** Infobulle au survol du contrôle, tant qu'aucune erreur n'est affichée. */
504
+ /** Tooltip on the control, shown while no error is displayed. */
505
505
  tooltip(tooltip) { this._tooltip = tooltip; return this; }
506
506
  getUpdateStateMessages() {
507
507
  return { saving: t('filament-tables::table.columns.editable.saving'), saved: t('filament-tables::table.columns.editable.saved'), failure: t('filament-tables::table.columns.editable.failure') };
508
508
  }
509
- /** Valeur écrite à partir d'une valeur validée : une valeur vide devient `null` (comme `blank()` dans Filament). */
509
+ /** Value written from a validated value: blank values become `null`. */
510
510
  castState(input) { return isBlank(input) ? null : input; }
511
511
  /**
512
- * Enregistre une valeur déjà validée (port de `updateState`). Seul le nom déclaré de la colonne est écrit : une colonne de relation
513
- * (`auteur.nom`) n'est modifiable qu'avec `updateStateUsing()`.
512
+ * Saves an already validated value. Only the column's declared name is written: a relationship column (`author.name`)
513
+ * can only be edited with `updateStateUsing()`.
514
514
  */
515
515
  async updateState(record, input, adapter) {
516
516
  const state = this.castState(input);
@@ -529,7 +529,7 @@ export class EditableColumn extends Column {
529
529
  await this._afterStateUpdated?.({ state, record });
530
530
  return state;
531
531
  }
532
- /** Expression `x-tooltip` des contrôles modifiables : l'infobulle de la colonne, remplacée par l'erreur de validation quand il y en a une. */
532
+ /** `x-tooltip` expression of editable controls: the column tooltip, replaced by the validation error when there is one. */
533
533
  tooltipExpression(record) {
534
534
  const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
535
535
  return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
@@ -544,7 +544,7 @@ export class EditableColumn extends Column {
544
544
  aria: { content: null },
545
545
  }`;
546
546
  }
547
- /** Ouverture commune des cellules modifiables : composant Alpine chargé à la demande, état serveur et clé de l'enregistrement. */
547
+ /** Shared opening markup of editable cells: lazily loaded Alpine component, server state and record key. */
548
548
  editableAttributes(component, asset, recordKey, state) {
549
549
  return new Attributes().merge({
550
550
  'x-load': 'x-load',
@@ -563,8 +563,7 @@ export class EditableColumn extends Column {
563
563
  static STATUS_HTML = `<p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p>`;
564
564
  }
565
565
  /**
566
- * Liste déroulante modifiable dans la cellule (port de SelectColumn, liste native seulement). La valeur reçue doit être l'une des
567
- * options déclarées.
566
+ * In-cell editable select (native select only). The received value must be one of the declared options.
568
567
  */
569
568
  export class SelectColumn extends EditableColumn {
570
569
  _options = [];
@@ -634,11 +633,11 @@ export class SelectColumn extends EditableColumn {
634
633
  }" x-on:click.prevent.stop class="fi-input-wrp"><select x-model="state" ${select.toHtml()}>${options}</select></div><p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p></div>`;
635
634
  }
636
635
  }
637
- /** Valeur booléenne reçue du navigateur (déjà passée par la règle `boolean`) : `true`, `false` ou `null`. */
636
+ /** Boolean value received from the browser (already checked by the `boolean` rule): `true`, `false` or `null`. */
638
637
  const toBoolean = (value) => (isBlank(value) ? null : value === true || value === 1 || value === '1');
639
638
  const ALIGNMENTS = ['start', 'center', 'end', 'left', 'right', 'justify', 'between'];
640
639
  const alignmentClass = (alignment) => (alignment && ALIGNMENTS.includes(alignment) ? `fi-align-${alignment}` : null);
641
- /** Interrupteur modifiable dans la cellule (port de ToggleColumn). La valeur reçue doit être un booléen (règle `boolean` par défaut). */
640
+ /** In-cell editable toggle. The received value must be a boolean (`boolean` rule by default). */
642
641
  export class ToggleColumn extends EditableColumn {
643
642
  _onColor = null;
644
643
  _offColor = null;
@@ -646,17 +645,17 @@ export class ToggleColumn extends EditableColumn {
646
645
  _offIcon = null;
647
646
  constructor(name) { super(name); this._rules = ['boolean']; }
648
647
  static make(name) { return configured(new ToggleColumn(name)); }
649
- /** Couleur de l'interrupteur allumé (`primary` par défaut) et éteint (`gray`). */
648
+ /** Toggle color when on (`primary` by default) and off (`gray`). */
650
649
  onColor(color) { this._onColor = color; return this; }
651
650
  offColor(color) { this._offColor = color; return this; }
652
- /** Icônes affichées dans le bouton de l'interrupteur. */
651
+ /** Icons shown inside the toggle's thumb. */
653
652
  onIcon(icon) { this._onIcon = icon; return this; }
654
653
  offIcon(icon) { this._offIcon = icon; return this; }
655
654
  castState(input) { return toBoolean(input); }
656
655
  async toHtml(record, recordKey = '') {
657
656
  const state = Boolean(await this.getState(record));
658
657
  const isDisabled = await this.isDisabledFor(record);
659
- // Le gris est la couleur par défaut de l'interrupteur : il ne pose aucune classe (HasDefaultGrayColor).
658
+ // Gray is the toggle's default color and adds no class.
660
659
  const colorClasses = (color) => (color === 'gray' ? [] : (colorManager?.getComponentClasses('toggle', color) ?? []));
661
660
  const onColorClasses = colorClasses(this._onColor ?? 'primary');
662
661
  const onClasses = ['fi-toggle-on', ...onColorClasses].join(' ');
@@ -668,11 +667,11 @@ export class ToggleColumn extends EditableColumn {
668
667
  'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes()['aria-describedby']",
669
668
  'x-tooltip': this.tooltipExpression(record),
670
669
  }).class(['fi-toggle']);
671
- // Interrupteur allumé : masqué (x-cloak) jusqu'au démarrage d'Alpine, remplacé en attendant par une copie statique, comme dans Filament.
670
+ // The live toggle is hidden (x-cloak) until Alpine starts; a static copy is shown meanwhile.
672
671
  return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><div aria-checked="${state ? 'true' : 'false'}" x-bind:aria-checked="state ? 'true' : 'false'" x-bind:tabindex="$el.hasAttribute('disabled') ? '-1' : '0'" x-bind:aria-disabled="isLoading || $el.hasAttribute('disabled') ? 'true' : null" x-on:click.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.enter.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.space.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-bind:class="state ? ${js(onClasses)} : ${js(offClasses)}"${state ? ' x-cloak' : ''} role="switch" ${button.toHtml()}><div><div aria-hidden="true">${toggleIcon(this._offIcon)}</div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>${state ? `<div x-cloak="inline-flex" wire:ignore class="${e(['fi-toggle fi-toggle-on fi-hidden', ...onColorClasses].join(' '))}"><div><div aria-hidden="true"></div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>` : ''}${EditableColumn.STATUS_HTML}</div>`;
673
672
  }
674
673
  }
675
- /** Case à cocher modifiable dans la cellule (port de CheckboxColumn). La valeur reçue doit être un booléen (règle `boolean` par défaut). */
674
+ /** In-cell editable checkbox. The received value must be a boolean (`boolean` rule by default). */
676
675
  export class CheckboxColumn extends EditableColumn {
677
676
  constructor(name) { super(name); this._rules = ['boolean']; }
678
677
  static make(name) { return configured(new CheckboxColumn(name)); }
@@ -692,7 +691,7 @@ export class CheckboxColumn extends EditableColumn {
692
691
  }" x-model="state" ${input.toHtml()} />${EditableColumn.STATUS_HTML}</div>`;
693
692
  }
694
693
  }
695
- /** Champ de saisie modifiable dans la cellule (port de TextInputColumn). Seuls un texte ou un nombre sont acceptés du navigateur. */
694
+ /** In-cell editable text input. Only a string or a number is accepted from the browser. */
696
695
  export class TextInputColumn extends EditableColumn {
697
696
  _type = 'text';
698
697
  _mask = null;
@@ -708,9 +707,9 @@ export class TextInputColumn extends EditableColumn {
708
707
  _prefixInline = false;
709
708
  _suffixInline = false;
710
709
  static make(name) { return configured(new TextInputColumn(name)); }
711
- /** Type HTML du champ (`text` par défaut ; `number`, `email`, `color`…). */
710
+ /** HTML input type (`text` by default; `number`, `email`, `color`…). */
712
711
  type(type) { this._type = type && /^[a-z-]+$/.test(type) ? type : 'text'; return this; }
713
- /** Masque de saisie Alpine (`x-mask`) ; le champ devient alors un champ texte. */
712
+ /** Alpine input mask (`x-mask`); the input then becomes a text input. */
714
713
  mask(mask) { this._mask = mask; return this; }
715
714
  inputMode(mode) { this._inputMode = mode; return this; }
716
715
  step(step) { this._step = step; return this; }
@@ -731,7 +730,7 @@ export class TextInputColumn extends EditableColumn {
731
730
  const state = raw === undefined ? null : raw;
732
731
  const isDisabled = await this.isDisabledFor(record);
733
732
  const type = this._mask ? 'text' : this._type;
734
- // Icône d'affixe : la couleur grise est celle par défaut, sans classe (comme InputComponent\WrapperComponent\IconComponent).
733
+ // Affix icon: gray is the default color and adds no class.
735
734
  const affixIcon = (name, color) => (name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []) }) : '');
736
735
  const outer = this.editableAttributes('textInputTableColumn', 'text-input', recordKey, state).class(['fi-ta-text-input']);
737
736
  const input = new Attributes().merge({
@@ -749,8 +748,8 @@ export class TextInputColumn extends EditableColumn {
749
748
  }
750
749
  }
751
750
  /**
752
- * Colonne au rendu libre (port de ViewColumn) : `view()` reçoit une fonction qui renvoie le HTML de la cellule, ou le nom d'une vue EJS
753
- * rendue avec `{ state, record, column }`. Ce HTML est celui de l'application : les données qu'il contient doivent y être échappées (`e()`).
751
+ * Custom-rendered column: `view()` takes a function returning the cell HTML, or the name of an EJS view rendered with
752
+ * `{ state, record, column }`. This HTML is the application's own: data inside it must be escaped there (`e()`).
754
753
  */
755
754
  export class ViewColumn extends Column {
756
755
  _view = null;
@@ -760,26 +759,26 @@ export class ViewColumn extends Column {
760
759
  async toHtml(record) {
761
760
  const state = await this.getState(record);
762
761
  if (this._view === null)
763
- throw new Error(`ViewColumn ${this.name} : aucune vue déclarée (view()).`);
762
+ throw new Error(`ViewColumn ${this.name}: no view declared (view()).`);
764
763
  if (typeof this._view === 'function')
765
764
  return this._view({ state, record, column: this });
766
765
  return views.render(this._view, { ...this._viewData, state, record, column: this, getState: () => state, getRecord: () => record });
767
766
  }
768
767
  }
769
- /** @deprecated Port de BadgeColumn : utiliser `TextColumn.make(nom).badge()`. */
768
+ /** @deprecated Use `TextColumn.make(name).badge()`. */
770
769
  export class BadgeColumn extends TextColumn {
771
770
  constructor(name) { super(name); this.badge(); }
772
771
  static make(name) { return configured(new BadgeColumn(name)); }
773
772
  }
774
- /** @deprecated Port de BooleanColumn : utiliser `IconColumn.make(nom).boolean()`. */
773
+ /** @deprecated Use `IconColumn.make(name).boolean()`. */
775
774
  export class BooleanColumn extends IconColumn {
776
775
  constructor(name) { super(name); this.boolean(); }
777
776
  static make(name) { return configured(new BooleanColumn(name)); }
778
777
  }
779
778
  /**
780
- * Écriture d'une cellule modifiable reçue du navigateur (port de `updateTableColumnState`). Tout est revérifié : la colonne doit
781
- * être déclarée, modifiable et visible, l'enregistrement dans la portée de l'adaptateur et non désactivé pour cette colonne,
782
- * `authorize` (droit de modification de l'hôte) l'accepter, et la valeur passer les règles de la colonne.
779
+ * Writes an editable cell value received from the browser. Everything is checked again: the column must be declared, editable
780
+ * and visible, the record within the adapter's scope and not disabled for this column, `authorize` (the host's edit permission)
781
+ * must accept it, and the value must pass the column's rules.
783
782
  */
784
783
  export async function updateTableColumnState(table, host, adapter, columnName, recordKey, input, options = {}) {
785
784
  const unavailable = { error: t('filament-tables::table.columns.editable.unavailable') };
@@ -796,7 +795,7 @@ export async function updateTableColumnState(table, host, adapter, columnName, r
796
795
  return { error };
797
796
  return column.updateState(record, input, adapter);
798
797
  }
799
- /** Filtre « corbeille » des modèles à suppression douce (port de TrashedFilter). */
798
+ /** "Trashed" filter for soft-deletable models. */
800
799
  export class TrashedFilter {
801
800
  name = 'trashed';
802
801
  static make() { return configured(new TrashedFilter()); }
@@ -811,7 +810,7 @@ export class TrashedFilter {
811
810
  }
812
811
  }
813
812
  const filterLabel = (name) => name.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
814
- /** Filtre par valeur d'une colonne, choisie dans une liste (port de SelectFilter, sélection simple). */
813
+ /** Filters on a column value picked from a list (single select). */
815
814
  export class SelectFilter {
816
815
  name;
817
816
  _label = null;
@@ -820,9 +819,9 @@ export class SelectFilter {
820
819
  constructor(name) { this.name = name; }
821
820
  static make(name) { return configured(new SelectFilter(name)); }
822
821
  label(label) { this._label = label; return this; }
823
- /** Colonne filtrée quand elle diffère du nom du filtre (clé étrangère d'une relation, par exemple). */
822
+ /** Filtered column when it differs from the filter name (e.g. a relationship's foreign key). */
824
823
  column(column) { this._column = column; return this; }
825
- /** Options fixes, ou fonction asynchrone (enregistrements d'une relation). */
824
+ /** Fixed options, or an async function (e.g. records of a relationship). */
826
825
  options(options) {
827
826
  this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
828
827
  return this;
@@ -831,21 +830,21 @@ export class SelectFilter {
831
830
  getDefaultState() { return { value: null }; }
832
831
  getFormDefinition() { return { placeholder: t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) ? this._options : [] }; }
833
832
  getFormOptions() { return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options); }
834
- /** Comme Filament : les options d'un SelectFilter sont évaluées à chaque rendu, le sélecteur porte donc une clé dérivée d'elles. */
833
+ /** Options are evaluated on every render, so the select gets a key derived from them (forces a re-render when they change). */
835
834
  hasDynamicOptions = true;
836
835
  isActive(state) { return typeof state.value === 'string' && state.value !== ''; }
837
836
  apply(query, state) {
838
837
  if (!this.isActive(state))
839
838
  return;
840
839
  const value = String(state.value).slice(0, 255);
841
- // Options fixes : seule une valeur proposée filtre. Options chargées : la valeur est un paramètre lié, une valeur inconnue ne trouve rien.
840
+ // Fixed options: only an offered value filters. Loaded options: the value is a bound parameter, an unknown value matches nothing.
842
841
  if (Array.isArray(this._options) && !this._options.some(([option]) => option === value))
843
842
  return;
844
843
  const column = this._column ?? this.name;
845
844
  (query.modifiers ??= []).push((q) => { q.where(column, value); });
846
845
  }
847
846
  }
848
- /** Filtre à trois états sur une colonne booléenne : tous, oui, non (port de TernaryFilter). */
847
+ /** Three-state filter on a boolean column: all, yes, no. */
849
848
  export class TernaryFilter {
850
849
  name;
851
850
  _label = null;
@@ -863,7 +862,7 @@ export class TernaryFilter {
863
862
  (query.modifiers ??= []).push((q) => { q.where(column, on ? 1 : 0); });
864
863
  }
865
864
  }
866
- /** Filtre à formulaire libre (port de Filter avec `schema()`) : la requête et les indicateurs sont écrits par l'application. */
865
+ /** Custom form filter (`schema()`): the query and indicators are written by the application. */
867
866
  export class Filter {
868
867
  name;
869
868
  _label = null;
@@ -873,11 +872,11 @@ export class Filter {
873
872
  constructor(name) { this.name = name; }
874
873
  static make(name) { return configured(new Filter(name)); }
875
874
  label(label) { this._label = label; return this; }
876
- /** Champs du filtre ; leurs noms sont les seules clés d'état acceptées du navigateur. */
875
+ /** Filter fields; their names are the only state keys accepted from the browser. */
877
876
  schema(components) { this._components = components; return this; }
878
- /** Condition posée sur la requête à partir de l'état nettoyé du formulaire. */
877
+ /** Query condition built from the sanitized form state. */
879
878
  query(modifier) { this._query = modifier; return this; }
880
- /** Pastilles des valeurs appliquées : `{ champ: libellé }` ; retirer une pastille efface son champ. */
879
+ /** Indicators of applied values as `{ field: label }`; removing one clears its field. */
881
880
  indicateUsing(resolver) { this._indicate = resolver; return this; }
882
881
  fields() { return this._components.map((c) => (typeof c?.getName === 'function' ? String(c.getName()) : null)).filter((name) => name !== null); }
883
882
  getLabel() { return this._label ?? filterLabel(this.name); }
@@ -925,13 +924,13 @@ export class Table {
925
924
  _groupingDirectionSettingHidden = false;
926
925
  _groupingSettingsInDropdownOnDesktop = false;
927
926
  _collapsedGroupsByDefault = false;
928
- /** Regroupements proposés dans la barre d'outils (un identifiant de colonne vaut `Group.make(identifiant)`). */
927
+ /** Groupings offered in the toolbar (a column id is shorthand for `Group.make(id)`). */
929
928
  groups(groups) { this._groups = groups.map((g) => (typeof g === 'string' ? Group.make(g) : g)); return this; }
930
929
  getGroups() { return this._groups; }
931
930
  getGroup(id) { return this._groups.find((g) => g.getId() === id) ?? null; }
932
931
  /**
933
- * Regroupement par défaut (port de `defaultGroup()`). S'il fait partie de `groups()`, il est seulement présélectionné à l'ouverture
934
- * (`initializeTableGrouping`) et l'utilisateur peut le retirer ; sinon il s'applique toujours, sans sélecteur.
932
+ * Default grouping. If it is one of `groups()`, it is only preselected when the table opens (`initializeTableGrouping`)
933
+ * and the user can remove it; otherwise it always applies, without a selector.
935
934
  */
936
935
  defaultGroup(group, direction = 'asc') { this._defaultGroup = group; this._defaultGroupDirection = direction === 'desc' ? 'desc' : 'asc'; return this; }
937
936
  getDefaultGroup() {
@@ -943,21 +942,21 @@ export class Table {
943
942
  }
944
943
  getDefaultGroupDirection() { return this._defaultGroupDirection; }
945
944
  isDefaultGroupSelectable() { const group = this.getDefaultGroup(); return group !== null && this.getGroup(group.getId()) !== null; }
946
- /** Seuls les en-têtes et résumés des groupes sont affichés, sans les lignes ni la pagination (port de `groupsOnly()`). */
945
+ /** Shows only group headers and summaries, without rows or pagination. */
947
946
  groupsOnly(condition = true) { this._groupsOnly = condition; return this; }
948
947
  isGroupsOnly() { return this._groupsOnly; }
949
- /** Masque le sélecteur de regroupement de la barre d'outils. */
948
+ /** Hides the toolbar grouping selector. */
950
949
  groupingSettingsHidden(condition = true) { this._groupingSettingsHidden = condition; return this; }
951
950
  areGroupingSettingsHidden() { return this._groupingSettingsHidden; }
952
- /** Masque le choix du sens (croissant / décroissant) du regroupement. */
951
+ /** Hides the grouping direction (ascending / descending) choice. */
953
952
  groupingDirectionSettingHidden(condition = true) { this._groupingDirectionSettingHidden = condition; return this; }
954
953
  isGroupingDirectionSettingHidden() { return this._groupingDirectionSettingHidden; }
955
- /** Sur grand écran aussi, le sélecteur de regroupement est dans un menu déroulant. */
954
+ /** Puts the grouping selector in a dropdown on desktop too. */
956
955
  groupingSettingsInDropdownOnDesktop(condition = true) { this._groupingSettingsInDropdownOnDesktop = condition; return this; }
957
- /** @deprecated Ancien nom de `groupingSettingsInDropdownOnDesktop()`. */
956
+ /** @deprecated Use `groupingSettingsInDropdownOnDesktop()`. */
958
957
  groupsInDropdownOnDesktop(condition = true) { return this.groupingSettingsInDropdownOnDesktop(condition); }
959
958
  areGroupingSettingsInDropdownOnDesktop() { return this._groupingSettingsInDropdownOnDesktop; }
960
- /** Les groupes repliables s'affichent repliés. */
959
+ /** Collapsible groups start collapsed. */
961
960
  collapsedGroupsByDefault(condition = true) { this._collapsedGroupsByDefault = condition; return this; }
962
961
  areGroupsCollapsedByDefault() { return this._collapsedGroupsByDefault; }
963
962
  _columnsLayout = null;
@@ -966,45 +965,45 @@ export class Table {
966
965
  this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
967
966
  return this;
968
967
  }
969
- /** Mise en page des colonnes, ou `null` pour un tableau classique. */
968
+ /** Column layout, or `null` for a regular table. */
970
969
  getColumnsLayout() { return this._columnsLayout; }
971
- /** Action montée au clic sur une ligne (ex. `'edit'`), à la place d'une URL. */
970
+ /** Action mounted when a row is clicked (e.g. `'edit'`), instead of a URL. */
972
971
  recordAction(name) { this._recordAction = name; return this; }
973
972
  getRecordAction() { return this._recordAction; }
974
- /** Titre affiché dans l'en-tête de la table. */
973
+ /** Heading shown in the table header. */
975
974
  heading(heading) { this._heading = heading; return this; }
976
975
  getHeading() { return this._heading; }
977
- /** Actions affichées dans l'en-tête de la table. */
976
+ /** Actions shown in the table header. */
978
977
  headerActions(actions) { this._headerActions = actions; return this; }
979
978
  getHeaderActions() { return this._headerActions; }
980
- /** Actions groupées (menu « Bulk actions ») ; sans appel, l'hôte fournit ses actions par défaut. */
979
+ /** Bulk actions ("Bulk actions" menu); if never called, the host provides its default ones. */
981
980
  groupedBulkActions(actions) { this._bulkActions = actions; return this; }
982
981
  getBulkActions() { return this._bulkActions; }
983
- /** Actions de ligne : des `Action` ou un `ActionGroup` (menu déroulant). */
982
+ /** Row actions: `Action`s or an `ActionGroup` (dropdown). */
984
983
  recordActions(actions) { this._recordActions = actions; return this; }
985
984
  getRecordActions() { return this._recordActions; }
986
- /** Classes CSS ajoutées à la ligne selon l'enregistrement. */
985
+ /** CSS classes added to a row depending on its record. */
987
986
  recordClasses(resolver) { this._recordClasses = resolver; return this; }
988
987
  getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
989
988
  filters(filters, options = {}) { this._filters = filters; if (options.layout)
990
989
  this._filtersLayout = options.layout; return this; }
991
990
  filtersLayout(layout) { this._filtersLayout = layout; return this; }
992
991
  getFiltersLayout() { return this._filtersLayout; }
993
- /** Les filtres ne s'appliquent qu'au bouton « Apply » : c'est déjà le comportement de toutes les tables (méthode gardée pour la compatibilité d'API). */
992
+ /** No-op kept for API compatibility: filters always wait for the "Apply" button. */
994
993
  deferFilters() { return this; }
995
994
  defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
996
995
  recordUrl(resolver) { this._recordUrl = resolver; return this; }
997
996
  _reorderColumn = null;
998
- /** Lignes réordonnables à la main ; `column` reçoit le rang (port de `reorderable()`). */
997
+ /** Lets users reorder rows by hand; `column` stores the position. */
999
998
  reorderable(column) { this._reorderColumn = column; return this; }
1000
999
  getReorderColumn() { return this._reorderColumn; }
1001
1000
  paginationPageOptions(options) { this._pageOptions = options; return this; }
1002
1001
  _defaultPageOption = null;
1003
- /** Nombre de lignes par page à l'ouverture (port de `defaultPaginationPageOption()`). */
1002
+ /** Rows per page when the table opens. */
1004
1003
  defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
1005
1004
  getDefaultPageOption() { return this._defaultPageOption !== null && this._pageOptions.includes(this._defaultPageOption) ? this._defaultPageOption : (this._pageOptions.includes(10) ? 10 : this._pageOptions[0]); }
1006
1005
  _paginationMode = 'default';
1007
- /** `'simple'` : boutons précédent / suivant seulement, sans numéros de page ni total (port de `PaginationMode::Simple`). */
1006
+ /** `'simple'`: previous / next buttons only, without page numbers or total. */
1008
1007
  paginationMode(mode) { this._paginationMode = mode; return this; }
1009
1008
  getPaginationMode() { return this._paginationMode; }
1010
1009
  stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
@@ -1018,24 +1017,26 @@ export class Table {
1018
1017
  isStackedOnMobile() { return this._stackedOnMobile; }
1019
1018
  getPluralModelLabel() { return this._label; }
1020
1019
  hasToggleableColumns() { return this._columns.some((c) => c.isToggleable()); }
1021
- /** État par défaut du gestionnaire de colonnes (port de getDefaultTableColumnState). */
1020
+ /** Default column manager state. */
1022
1021
  getDefaultColumnState() {
1023
1022
  return this._columns.map((c) => ({ type: 'column', name: c.name, label: c.getLabel(), isHidden: false, isToggled: !c.isToggleable() || !c.isToggledHiddenByDefault(), isToggleable: c.isToggleable(), isToggledHiddenByDefault: c.isToggleable() ? c.isToggledHiddenByDefault() : null }));
1024
1023
  }
1025
1024
  getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
1026
1025
  }
1027
- /** Colonnes affichées : celles que l'état du gestionnaire laisse cochées. L'état du navigateur ne peut masquer qu'une colonne déclarée « toggleable ». */
1026
+ /** Visible columns: those left checked in the column manager. Browser state can only hide columns declared toggleable. */
1028
1027
  export function getVisibleColumns(table, host) {
1029
1028
  const hidden = new Set((Array.isArray(host.tableColumns) ? host.tableColumns : []).filter((c) => c && c.isToggled === false).map((c) => c.name));
1030
1029
  return table.getColumns().filter((c) => !(c.isToggleable() && hidden.has(c.name)));
1031
1030
  }
1032
- /** Nettoie un état de colonnes reçu du navigateur : seul `isToggled` des colonnes « toggleable » est repris. */
1031
+ /** Sanitizes column state from the browser: only `isToggled` of toggleable columns is kept. */
1033
1032
  export function sanitizeColumnState(table, state) {
1034
1033
  const received = new Map((Array.isArray(state) ? state : []).filter((c) => c && typeof c === 'object').map((c) => [String(c.name), c.isToggled]));
1035
1034
  return table.getDefaultColumnState().map((c) => (c.isToggleable && typeof received.get(c.name) === 'boolean' ? { ...c, isToggled: received.get(c.name) } : c));
1036
1035
  }
1037
- /** Requête de liste (recherche, recherches par colonne, filtres, tri) correspondant à l'état de l'hôte, sans pagination.
1038
- * Seul ce que la table déclare est accepté, quel que soit l'état envoyé par le navigateur. */
1036
+ /**
1037
+ * List query (search, column searches, filters, sort) for the host's state, without pagination.
1038
+ * Only what the table declares is accepted, whatever state the browser sends.
1039
+ */
1039
1040
  export function getTableQuery(table, host) {
1040
1041
  const [column, direction] = (host.tableSort ?? '').split(':');
1041
1042
  const sortColumn = table.getColumn(column);
@@ -1048,7 +1049,7 @@ export function getTableQuery(table, host) {
1048
1049
  const grouping = getTableGrouping(table, host);
1049
1050
  if (grouping)
1050
1051
  query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
1051
- // L'hôte peut restreindre la requête (onglet de liste actif).
1052
+ // The host can restrict the query (active list tab).
1052
1053
  host.modifyTableQuery?.(query);
1053
1054
  for (const filter of table.getFilters()) {
1054
1055
  const state = host.tableFilters?.[filter.name];
@@ -1056,7 +1057,7 @@ export function getTableQuery(table, host) {
1056
1057
  }
1057
1058
  return query;
1058
1059
  }
1059
- /** Regroupement courant : seul un groupe déclaré par la table est accepté, quel que soit l'état envoyé par le navigateur. */
1060
+ /** Current grouping: only a group declared by the table is accepted, whatever state the browser sends. */
1060
1061
  export function getTableGrouping(table, host) {
1061
1062
  if (host.isTableReordering === true)
1062
1063
  return null;
@@ -1064,25 +1065,25 @@ export function getTableGrouping(table, host) {
1064
1065
  const group = table.getGroup(id);
1065
1066
  if (group)
1066
1067
  return { group, direction: direction === 'desc' ? 'desc' : 'asc' };
1067
- // Comme Filament : un regroupement par défaut absent du sélecteur s'applique toujours ; présent, il ne vaut que s'il est choisi.
1068
+ // A default grouping missing from the selector always applies; one listed in the selector only applies when chosen.
1068
1069
  const fallback = table.isDefaultGroupSelectable() ? null : table.getDefaultGroup();
1069
1070
  return fallback ? { group: fallback, direction: table.getDefaultGroupDirection() } : null;
1070
1071
  }
1071
- /** À l'ouverture de la table (montage de l'hôte) : présélectionne le regroupement par défaut quand l'utilisateur peut le changer. */
1072
+ /** When the table opens (host mount): preselects the default grouping when the user can change it. */
1072
1073
  export function initializeTableGrouping(table, host) {
1073
1074
  const group = table.getDefaultGroup();
1074
1075
  if ((host.tableGrouping === null || host.tableGrouping === undefined || host.tableGrouping === '') && group && table.isDefaultGroupSelectable())
1075
1076
  host.tableGrouping = `${group.getId()}:${table.getDefaultGroupDirection()}`;
1076
1077
  }
1077
1078
  export function countActiveFilters(table, host) {
1078
- // Comme Filament : le compteur est le nombre d'indicateurs ; un filtre sans indicateurs compte pour un quand il est actif.
1079
+ // The count is the number of indicators; an active filter without indicators counts as one.
1079
1080
  return table.getFilters().reduce((total, f) => { const s = host.tableFilters?.[f.name]; return total + (!s || typeof s !== 'object' ? 0 : f.getIndicators ? f.getIndicators(s).length : f.isActive(s) ? 1 : 0); }, 0);
1080
1081
  }
1081
- /** Indicateurs des filtres appliqués (seuls les filtres qui en déclarent en ont). */
1082
+ /** Indicators of the applied filters (only filters that declare some have them). */
1082
1083
  export function getFilterIndicators(table, host) {
1083
1084
  return table.getFilters().flatMap((f) => { const s = host.tableFilters?.[f.name]; return s && typeof s === 'object' && f.getIndicators ? f.getIndicators(s).map((indicator) => ({ filter: f.name, ...indicator })) : []; });
1084
1085
  }
1085
- /** Port de sortTable() : non trié → asc → desc → non trié. */
1086
+ /** Cycles a column's sort: none → asc → desc → none. */
1086
1087
  export function sortTable(host, column = null, direction = null) {
1087
1088
  const [current, currentDirection] = (host.tableSort ?? '').split(':');
1088
1089
  if (column === (host.tableSort ? current : null))
@@ -1092,13 +1093,13 @@ export function sortTable(host, column = null, direction = null) {
1092
1093
  host.tableSort = direction ? `${column}:${direction}` : null;
1093
1094
  host.paginators.page = 1;
1094
1095
  }
1095
- /** Charge la page d'enregistrements correspondant à l'état de l'hôte. */
1096
+ /** Loads the page of records matching the host's state. */
1096
1097
  export async function getTableRecords(table, host, adapter) {
1097
1098
  const options = table.getPageOptions();
1098
1099
  const perPage = options.includes(Number(host.tableRecordsPerPage)) ? Number(host.tableRecordsPerPage) : options[0];
1099
1100
  return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage });
1100
1101
  }
1101
- /** Éléments de pagination : port de UrlWindow de Laravel avec onEachSide(0), comme Filament. */
1102
+ /** Pagination elements: the first and last pages and a window around the current page, with `...` gaps. */
1102
1103
  export function paginationElements(current, last, onEachSide = 0) {
1103
1104
  const range = (a, b) => Array.from({ length: Math.max(0, b - a + 1) }, (_, i) => a + i);
1104
1105
  if (last < onEachSide * 2 + 8)
@@ -1112,9 +1113,9 @@ export function paginationElements(current, last, onEachSide = 0) {
1112
1113
  }
1113
1114
  let colorManager = null;
1114
1115
  export const useColorManager = (manager) => { colorManager = manager; };
1115
- /** Nombre maximal de lignes affichées (et réordonnées) en mode tri manuel. */
1116
+ /** Maximum number of rows shown (and reorderable) in reordering mode. */
1116
1117
  export const REORDER_LIMIT = 1000;
1117
- /** Ligne de résumé (port de `tables::summary.row`) ; `groupsOnly` : intitulé seul en tête, puis une cellule par colonne. */
1118
+ /** Summary row; with `groupsOnly`, a leading label cell, then one cell per column. */
1118
1119
  function summaryRowHtml(row, span, groupsOnly, placeholders) {
1119
1120
  const items = (cell) => cell.items.map((item) => `<div class="fi-ta-${item.type}-summary">${item.label !== null ? `<span class="fi-ta-${item.type}-summary-label">${e(item.label)}</span>` : ''}<span>${e(item.value)}</span></div>`).join('');
1120
1121
  const heading = groupsOnly
@@ -1123,16 +1124,16 @@ function summaryRowHtml(row, span, groupsOnly, placeholders) {
1123
1124
  return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${heading}${row.cells.map((cell) => `<td class="fi-ta-cell fi-align-${e(cell.alignment)} ">${items(cell)}</td>`).join('')}${placeholders.actions ? '<td></td>' : ''}</tr>`;
1124
1125
  }
1125
1126
  /**
1126
- * Résumés de la table (port de `tables::summary`) : sans pagination, une seule ligne « Summary » ; avec plusieurs pages, un en-tête
1127
- * puis « This page » et « All … ». Renvoie aussi de quoi calculer la ligne de résumé d'un groupe (requête restreinte au groupe).
1127
+ * Table summaries: a single "Summary" row without pagination; with several pages, a header then "This page" and "All …" rows.
1128
+ * Also returns what is needed to compute a group's summary row (query restricted to the group).
1128
1129
  */
1129
1130
  async function summarize(table, host, adapter, visible, page, options) {
1130
- // Groupes seuls : la colonne du groupe n'a pas de cellule de résumé (son intitulé est déjà en tête de ligne).
1131
+ // Groups only: the grouped column has no summary cell (its label already leads the row).
1131
1132
  const columns = options.groupsOnly && options.group ? visible.filter((c) => c.name !== options.group.getColumn()) : visible;
1132
1133
  const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers() : []));
1133
1134
  if (!lists.some((list) => list.length) || !page.records.length)
1134
1135
  return null;
1135
- // Comme Filament : la première cellule porte l'intitulé de la ligne ; elle s'étend jusqu'à la première colonne résumée qui la suit.
1136
+ // The first cell holds the row label and spans up to the first summarized column after it.
1136
1137
  const next = lists.findIndex((list, i) => i > 0 && list.length > 0);
1137
1138
  const span = options.groupsOnly ? 0 : next < 0 ? columns.length : next;
1138
1139
  const columnOf = (c) => c.getSortColumn?.() ?? c.name;
@@ -1166,23 +1167,23 @@ async function summarize(table, host, adapter, visible, page, options) {
1166
1167
  : [{ heading: t('filament-tables::table.summary.heading', { label }), cells: cells(all), striped: true }];
1167
1168
  return {
1168
1169
  html: header + rows.map(rowHtml).join(''),
1169
- /** Ligne de résumé d'un groupe : agrégats de tous les enregistrements du groupe (pas seulement ceux de la page). */
1170
+ /** Group summary row: aggregates over all the group's records (not only those on the page). */
1170
1171
  group: async (group, key, title) => {
1171
1172
  const values = await aggregate({ ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] });
1172
1173
  return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: cells(values), striped: false });
1173
1174
  },
1174
1175
  };
1175
1176
  }
1176
- /** Plafond d'enregistrements lus pour une table `groupsOnly` (non paginée, comme dans Filament). */
1177
+ /** Maximum records read for a `groupsOnly` table (which is not paginated). */
1177
1178
  export const GROUPS_ONLY_LIMIT = 1000;
1178
1179
  export async function renderTable(table, host, adapter, options = {}) {
1179
1180
  const reorderColumn = options.canReorder === false ? null : table.getReorderColumn();
1180
1181
  const reordering = reorderColumn !== null && host.isTableReordering === true;
1181
- // Tri manuel : toutes les lignes de la requête, dans l'ordre de la colonne de rang, sans pagination ni actions.
1182
+ // Reordering: all rows of the query, in position order, without pagination or actions.
1182
1183
  if (reordering)
1183
1184
  options = { ...options, bulkActions: [], headerActions: options.headerActions, rowActions: undefined, rowActionsHtml: undefined, filtersForm: null };
1184
1185
  const grouping = reordering ? null : getTableGrouping(table, host);
1185
- // Groupes seuls : ni pagination ni lignes d'enregistrements, seulement une ligne de résumé par groupe (comme Filament).
1186
+ // Groups only: no pagination and no record rows, just one summary row per group.
1186
1187
  const groupsOnly = grouping !== null && table.isGroupsOnly();
1187
1188
  const page = reordering
1188
1189
  ? await adapter.paginate({ ...getTableQuery(table, host), sort: { column: reorderColumn, direction: 'asc' }, page: 1, perPage: REORDER_LIMIT })
@@ -1194,7 +1195,7 @@ export async function renderTable(table, host, adapter, options = {}) {
1194
1195
  const hasBulk = (options.bulkActions ?? []).length > 0;
1195
1196
  const selectable = (hasBulk || reordering) && !groupsOnly;
1196
1197
  const filled = (value) => value !== null && value.trim() !== '';
1197
- // Comme Filament : un nouveau groupe commence quand le titre change ; le groupe précédent reçoit alors sa ligne de résumé.
1198
+ // A new group starts when the title changes; the previous group then gets its summary row.
1198
1199
  let previousGroup = { key: null, title: null };
1199
1200
  const columnsLayout = table.getColumnsLayout();
1200
1201
  const visibleSet = new Set(visible);
@@ -1213,9 +1214,9 @@ export async function renderTable(table, host, adapter, options = {}) {
1213
1214
  if (!groupsOnly)
1214
1215
  groupHeader = {
1215
1216
  key: groupKey, title: groupTitle, label, description, collapsible: grouping.group.isCollapsible(),
1216
- // Clés de tout le groupe (pas seulement de la page), pour sa case « tout sélectionner ».
1217
+ // Keys of the whole group (not only the page), for its "select all" checkbox.
1217
1218
  keys: hasBulk ? await adapter.listKeys({ ...query, modifiers: [...(query.modifiers ?? []), (q) => grouping.group.scope(q, groupKey)] }) : [],
1218
- // En-tête sans rien à annoncer : masqué aux technologies d'assistance.
1219
+ // Header with nothing to announce: hidden from assistive technologies.
1219
1220
  ariaHidden: !(filled(groupTitle) || filled(label) || filled(description) || grouping.group.isCollapsible() || selectable),
1220
1221
  };
1221
1222
  }
@@ -1241,19 +1242,19 @@ export async function renderTable(table, host, adapter, options = {}) {
1241
1242
  wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
1242
1243
  key, url: reordering ? null : recordAction ? null : table.getRecordUrl(record), cells, actions: groupsOnly ? [] : ((await options.rowActions?.(record)) ?? []),
1243
1244
  actionsHtml: options.rowActionsHtml && !groupsOnly ? await options.rowActionsHtml(record) : null, classes: table.getRecordClasses(record),
1244
- // Clic sur la ligne : l'action est montée avec la clé de l'enregistrement (même expression que Filament).
1245
+ // Row click: the action is mounted with the record key.
1245
1246
  mountExpression: reordering ? null : recordAction ? `mountAction(${js(recordAction)}, {}, ${js({ table: true, recordKey: key })})` : null,
1246
1247
  });
1247
1248
  }
1248
1249
  const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
1249
- // Résumés : jamais en mode « tri manuel » ; ceux des groupes s'intercalent avant l'en-tête du groupe suivant.
1250
+ // Summaries: never while reordering; group summaries go before the next group's header.
1250
1251
  const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions } });
1251
1252
  let trailingGroupSummary = '';
1252
1253
  if (summary && grouping) {
1253
1254
  for (const row of rows)
1254
1255
  if (row.summaryBefore)
1255
1256
  row.groupSummaryHtml = await summary.group(grouping.group, row.summaryBefore.key, row.summaryBefore.title);
1256
- // Dernier groupe de la page : résumé seulement s'il ne continue pas sur la page suivante (port de shouldRenderTrailingGroupedTableSummary).
1257
+ // Last group on the page: summarized only if it does not continue on the next page.
1257
1258
  let trailing = filled(previousGroup.title);
1258
1259
  if (trailing && !groupsOnly && page.page < page.lastPage) {
1259
1260
  const nextRecord = (await adapter.paginate({ ...getTableQuery(table, host), page: page.page * page.perPage + 1, perPage: 1 })).records[0];
@@ -1277,11 +1278,11 @@ export async function renderTable(table, host, adapter, options = {}) {
1277
1278
  grouping: { directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
1278
1279
  id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
1279
1280
  reorder: reorderColumn !== null ? { active: reordering } : null,
1280
- // Comme Filament : pas de cases à cocher sans action groupée (ni en mode « groupes seuls »), pas de colonne d'actions sans action de ligne.
1281
+ // No checkboxes without bulk actions (or in groups-only mode), no actions column without row actions.
1281
1282
  selectable, hasRowActions,
1282
- // Comme Filament (Str::random) : la clé change à chaque rendu pour forcer le remplacement de la case à cocher.
1283
+ // The key changes on every render to force the checkbox to be replaced.
1283
1284
  randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
1284
- // Total sélectionnable par « tout sélectionner » : tous les enregistrements de la requête courante.
1285
+ // "Select all" covers every record of the current query.
1285
1286
  allCount: table.getPaginationMode() === 'simple' ? '' : page.total, resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
1286
1287
  columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
1287
1288
  linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'),
@@ -1289,7 +1290,7 @@ export async function renderTable(table, host, adapter, options = {}) {
1289
1290
  filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
1290
1291
  hasColumnManager: !reordering && table.hasToggleableColumns(),
1291
1292
  afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
1292
- // Ligne d'intitulés de groupes : une cellule par colonne isolée, une cellule fusionnée par suite de colonnes du même groupe.
1293
+ // Column group header row: one cell per ungrouped column, one merged cell per run of columns in the same group.
1293
1294
  headerGroups: visible.some((c) => c._group !== null) ? visible.reduce((runs, c) => {
1294
1295
  const last = runs[runs.length - 1];
1295
1296
  if (c._group !== null && last && last.label === c._group)